66024c12b6
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m53s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Unit Tests (push) Successful in 8m13s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 10m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m23s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
/**
|
||
* Step 4 生成预览组件(支持多预览)
|
||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||
*/
|
||
import React from "react"
|
||
import {
|
||
CheckCircleFilled,
|
||
LoadingOutlined,
|
||
ReloadOutlined,
|
||
PlayCircleOutlined,
|
||
ExclamationCircleFilled,
|
||
ClockCircleOutlined,
|
||
} from "@ant-design/icons"
|
||
import { InputNumber } from "antd"
|
||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||
|
||
interface Step4GeneratePreviewProps {
|
||
templateName: string
|
||
materialCount: string
|
||
duration: number
|
||
videoRatio: string
|
||
previewCount: number
|
||
onPreviewCountChange: (count: number) => void
|
||
items: PreviewItem[]
|
||
selectedIndex: number
|
||
onSelectPreview: (index: number) => void
|
||
overallStatus: PreviewStatus
|
||
overallError: string
|
||
overallProgress: number
|
||
anyGenerating: boolean
|
||
onGeneratePreview: () => void
|
||
onRegeneratePreview: () => void
|
||
}
|
||
|
||
/** 预览数量选项 */
|
||
const PREVIEW_COUNT_OPTIONS = [
|
||
{ value: 1, label: "1个" },
|
||
{ value: 2, label: "2个" },
|
||
{ value: 3, label: "3个" },
|
||
]
|
||
|
||
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||
templateName: _templateName,
|
||
materialCount: _materialCount,
|
||
videoRatio,
|
||
previewCount,
|
||
onPreviewCountChange,
|
||
items,
|
||
selectedIndex,
|
||
onSelectPreview,
|
||
overallStatus,
|
||
overallError,
|
||
overallProgress,
|
||
anyGenerating,
|
||
onGeneratePreview,
|
||
onRegeneratePreview,
|
||
}) => {
|
||
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
|
||
const isIdle = overallStatus === "idle"
|
||
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
|
||
|
||
return (
|
||
<div className="xx-form-section">
|
||
<h3>🎬 生成预览</h3>
|
||
|
||
{/* 预览数量选择器(仅在 idle 状态显示) */}
|
||
{isIdle && (
|
||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||
<span style={{ fontSize: 14, color: "#666" }}>预览数量:</span>
|
||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||
{PREVIEW_COUNT_OPTIONS.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => onPreviewCountChange(opt.value)}
|
||
style={{
|
||
padding: "4px 12px",
|
||
borderRadius: 6,
|
||
border: previewCount === opt.value ? "1px solid #1677ff" : "1px solid #d9d9d9",
|
||
background: previewCount === opt.value ? "#e6f4ff" : "#fff",
|
||
color: previewCount === opt.value ? "#1677ff" : "#666",
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
fontWeight: previewCount === opt.value ? 600 : 400,
|
||
}}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
<InputNumber
|
||
min={1}
|
||
max={10}
|
||
value={previewCount}
|
||
onChange={(val) => val && onPreviewCountChange(val)}
|
||
style={{ width: 70 }}
|
||
placeholder="自定义"
|
||
/>
|
||
<span style={{ fontSize: 12, color: "#999", marginLeft: 4 }}>(1~10)</span>
|
||
</div>
|
||
{previewCount > 1 && (
|
||
<span style={{ fontSize: 12, color: "#999" }}>生成多个预览可对比不同剪辑效果</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 预览生成按钮(idle 状态) */}
|
||
{isIdle && (
|
||
<div className="xx-preview-generate-section">
|
||
<div className="xx-preview-generate-hint">
|
||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||
<p className="xx-preview-generate-desc">
|
||
AI 将根据您选择的模板、素材和配音,智能生成
|
||
{previewCount > 1 ? `${previewCount}个不同版本的` : ""}视频预览(480p 低清版)
|
||
</p>
|
||
</div>
|
||
<button
|
||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||
onClick={onGeneratePreview}
|
||
>
|
||
✨ 生成预览{previewCount > 1 ? `(${previewCount}个)` : ""}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 整体排队中(所有都在 pending) */}
|
||
{anyGenerating && items.every((it) => it.status === "pending") && (
|
||
<div className="xx-preview-loading">
|
||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 多预览网格(生成中/完成/部分完成) */}
|
||
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
|
||
<div
|
||
className="xx-preview-grid"
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
|
||
gap: 12,
|
||
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
|
||
margin: "0 auto 16px",
|
||
}}
|
||
>
|
||
{items.map((item) => {
|
||
const isSelected = item.index === selectedIndex
|
||
return (
|
||
<div
|
||
key={item.index}
|
||
onClick={() => {
|
||
if (item.status === "ready") onSelectPreview(item.index)
|
||
}}
|
||
style={{
|
||
borderRadius: 8,
|
||
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
||
overflow: "hidden",
|
||
cursor: item.status === "ready" ? "pointer" : "default",
|
||
opacity: item.status === "error" ? 0.6 : 1,
|
||
transition: "all 0.2s",
|
||
}}
|
||
>
|
||
{/* 缩略图/状态区域 */}
|
||
<div
|
||
style={{
|
||
aspectRatio,
|
||
background: "#000",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
{item.status === "ready" && item.result && (
|
||
<video
|
||
src={item.result.videoUrl}
|
||
controls
|
||
autoPlay
|
||
muted
|
||
loop
|
||
preload="metadata"
|
||
style={{
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: "cover",
|
||
}}
|
||
/>
|
||
)}
|
||
{(item.status === "pending" || item.status === "generating") && (
|
||
<div style={{ textAlign: "center" }}>
|
||
<LoadingOutlined style={{ fontSize: 24, color: "#fff" }} spin />
|
||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginTop: 8 }}>
|
||
{item.status === "pending" ? "排队中..." : `生成中 ${item.progress}%`}
|
||
</p>
|
||
</div>
|
||
)}
|
||
{item.status === "error" && (
|
||
<div style={{ textAlign: "center", padding: 8 }}>
|
||
<ExclamationCircleFilled style={{ fontSize: 20, color: "#ef4444" }} />
|
||
<p
|
||
style={{
|
||
color: "rgba(255,255,255,0.7)",
|
||
fontSize: 11,
|
||
marginTop: 4,
|
||
}}
|
||
>
|
||
生成失败
|
||
</p>
|
||
</div>
|
||
)}
|
||
{/* 选中角标 */}
|
||
{isSelected && item.status === "ready" && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: 4,
|
||
right: 4,
|
||
background: "#1677ff",
|
||
color: "#fff",
|
||
fontSize: 10,
|
||
padding: "2px 6px",
|
||
borderRadius: 4,
|
||
}}
|
||
>
|
||
预览 #{item.index + 1}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* 底部信息 */}
|
||
{item.status === "ready" && item.result && (
|
||
<div
|
||
style={{
|
||
padding: "6px 8px",
|
||
background: "#fafafa",
|
||
fontSize: 11,
|
||
color: "#666",
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
}}
|
||
>
|
||
<span>{item.result.duration.toFixed(1)}秒</span>
|
||
<span>{item.result.clipCount}段</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* 整体进度条(多预览生成中) */}
|
||
{anyGenerating && (
|
||
<div className="xx-preview-progress-bar" style={{ marginBottom: 12 }}>
|
||
<div className="xx-preview-progress-fill" style={{ width: `${overallProgress}%` }} />
|
||
</div>
|
||
)}
|
||
|
||
{/* 全部完成提示 */}
|
||
{overallStatus === "ready" && (
|
||
<div className="xx-preview-tip">
|
||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||
<span>
|
||
{items.filter((it) => it.status === "ready").length} 个预览生成成功
|
||
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
|
||
</span>
|
||
<button
|
||
className="xx-preview-regenerate-btn"
|
||
onClick={onRegeneratePreview}
|
||
title="重新生成"
|
||
>
|
||
<ReloadOutlined /> 重新生成
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 全部失败 */}
|
||
{isError && (
|
||
<div className="xx-preview-error">
|
||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||
<p className="xx-preview-error-text">预览生成失败</p>
|
||
<p className="xx-preview-error-desc">
|
||
{typeof overallError === "string" && overallError ? overallError : "请稍后重试"}
|
||
</p>
|
||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||
<ReloadOutlined /> 重新生成
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Step4GeneratePreview
|