diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index a918d13d6..36ae84d81 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -23,6 +23,7 @@ import GenerateStepActions from "./components/GenerateStepActions" import { useGenerateFormState } from "./hooks/useGenerateFormState" import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" +import { useStep3Preview } from "./hooks/useStep3Preview" import "./generate.css" const GeneratePage: React.FC = () => { @@ -78,6 +79,17 @@ const GeneratePage: React.FC = () => { message.success("音色克隆成功!") } + /* ── Step3 预览生成 ── */ + const step3Preview = useStep3Preview({ + templates: userTemplates, + selectedTemplate, + materialMode, + selectedMaterials, + smartSelectedIds, + duration, + videoRatio, + }) + /* ── 步骤导航 ── */ const { goNext, goPrev } = useStepNavigation({ currentStep, @@ -87,6 +99,7 @@ const GeneratePage: React.FC = () => { selectedMaterials, smartSelectedIds, titleSettings, + previewReady: step3Preview.canProceed, }) /* ── 视频生成核心逻辑 ── */ @@ -173,6 +186,13 @@ const GeneratePage: React.FC = () => { onDismissError={handleDismissError} presetVoices={presetVoices} videoRatio={videoRatio} + previewStatus={step3Preview.previewStatus} + previewResult={step3Preview.previewResult} + previewError={step3Preview.previewError} + previewTemplateName={step3Preview.templateName} + previewMaterialCount={step3Preview.materialCount} + onGeneratePreview={step3Preview.generatePreview} + onRegeneratePreview={step3Preview.regeneratePreview} /> void + onRegeneratePreview: () => void } export const GenerateStepContent: React.FC = (props) => { @@ -103,6 +112,13 @@ export const GenerateStepContent: React.FC = (props) = onDismissError, presetVoices, videoRatio, + previewStatus, + previewResult, + previewError, + previewTemplateName, + previewMaterialCount, + onGeneratePreview, + onRegeneratePreview, } = props switch (currentStep) { @@ -128,13 +144,15 @@ export const GenerateStepContent: React.FC = (props) = case 3: return ( ) case 4: diff --git a/apps/web/src/pages/generate/components/Step3GeneratePreview.tsx b/apps/web/src/pages/generate/components/Step3GeneratePreview.tsx index 16d7d2038..c2551982d 100644 --- a/apps/web/src/pages/generate/components/Step3GeneratePreview.tsx +++ b/apps/web/src/pages/generate/components/Step3GeneratePreview.tsx @@ -1,55 +1,138 @@ /** * Step 3 生成预览组件 + * 展示剪辑方案预览,用户确认后进入下一步 + * TODO: 后端预览接口对齐后,替换 mock 预览为真实视频/方案预览 */ import React from "react" -import { CheckCircleFilled } from "@ant-design/icons" -import type { EditingTemplate } from "@/api/editing-planner" -import { useStep3Preview } from "../hooks/useStep3Preview" +import { + CheckCircleFilled, + LoadingOutlined, + ReloadOutlined, + PlayCircleOutlined, + ExclamationCircleFilled, +} from "@ant-design/icons" +import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview" interface Step3GeneratePreviewProps { - templates: EditingTemplate[] - selectedTemplate: string - materialMode: "manual" | "auto" - selectedMaterials: string[] - smartSelectedIds: string[] + templateName: string + materialCount: string duration: number videoRatio: string + previewStatus: PreviewStatus + previewResult: PreviewResult | null + previewError: string + onGeneratePreview: () => void + onRegeneratePreview: () => void } -const Step3GeneratePreview: React.FC = (props) => { - const { templateName, materialCount, duration, videoRatio } = useStep3Preview(props) - +const Step3GeneratePreview: React.FC = ({ + templateName, + materialCount, + duration, + videoRatio, + previewStatus, + previewResult, + previewError, + onGeneratePreview, + onRegeneratePreview, +}) => { return (

🎬 生成预览

-
- - 素材已选好,AI 将为您智能匹配剪辑方案 -
-
-
模板草稿预览
-
-
- 模板 - {templateName} -
-
- 素材数量 - {materialCount} -
-
- 预计时长 - {duration} 秒 -
-
- 视频比例 - {videoRatio} + + {/* 预览生成按钮 */} + {previewStatus === "idle" && ( +
+
+ +

一键生成剪辑预览

+

+ AI 将根据您选择的模板和素材,智能生成剪辑方案预览 +

+
-
- 💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题 + )} + + {/* 生成中 */} + {previewStatus === "generating" && ( +
+ +

正在生成剪辑预览...

+

AI 正在分析素材并匹配剪辑方案

-
+ )} + + {/* 生成失败 */} + {previewStatus === "error" && ( +
+ +

预览生成失败

+

{previewError || "请稍后重试"}

+ +
+ )} + + {/* 生成成功 - 预览卡片 */} + {previewStatus === "ready" && previewResult && ( + <> +
+ + 预览生成成功,确认无误后进入下一步 + +
+ +
+
剪辑方案预览
+
+
+ 模板 + {templateName} +
+
+ 素材数量 + {materialCount} +
+
+ 预计片段数 + {previewResult.clipCount} 段 +
+
+ 转场次数 + {previewResult.transitionCount} 次 +
+
+ 素材使用率 + {previewResult.materialUsage}% +
+
+ 预计时长 + {duration} 秒 +
+
+ 视频比例 + {videoRatio} +
+
+
+ 💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题 +
+
+ + )}
) } diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 2c2f0702c..1b958195d 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -2356,6 +2356,103 @@ 生成预览步骤 ================================================================ */ +/* ── 初始状态:生成按钮 ── */ +.xx-preview-generate-section { + text-align: center; + padding: 40px 20px; + background: var(--bg-tertiary); + border-radius: var(--radius-lg); + border: 2px dashed var(--border-color); +} + +.xx-preview-generate-hint { + margin-bottom: 24px; +} + +.xx-preview-generate-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 8px 0; +} + +.xx-preview-generate-desc { + font-size: 13px; + color: var(--text-secondary); + margin: 0; +} + +.xx-preview-generate-btn { + min-width: 160px; + height: 42px; + font-size: 14px; +} + +/* ── 加载中状态 ── */ +.xx-preview-loading { + text-align: center; + padding: 40px 20px; + background: var(--bg-tertiary); + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); +} + +.xx-preview-loading-text { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin: 16px 0 8px 0; +} + +.xx-preview-loading-desc { + font-size: 13px; + color: var(--text-secondary); + margin: 0; +} + +/* ── 错误状态 ── */ +.xx-preview-error { + text-align: center; + padding: 32px 20px; + background: rgba(239, 68, 68, 0.06); + border-radius: var(--radius-lg); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.xx-preview-error-text { + font-size: 15px; + font-weight: 600; + color: var(--error-color, #ef4444); + margin: 12px 0 6px 0; +} + +.xx-preview-error-desc { + font-size: 13px; + color: var(--text-secondary); + margin: 0 0 16px 0; +} + +/* ── 成功状态的操作按钮 ── */ +.xx-preview-regenerate-btn { + margin-left: auto; + background: none; + border: none; + color: var(--text-tertiary); + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s ease; +} + +.xx-preview-regenerate-btn:hover { + color: var(--primary-color); + background: rgba(59, 130, 246, 0.1); +} + .xx-preview-tip { display: flex; align-items: center; @@ -2639,3 +2736,4 @@ .ant-modal-close { color: #fff !important; } + diff --git a/apps/web/src/pages/generate/hooks/useStep3Preview.ts b/apps/web/src/pages/generate/hooks/useStep3Preview.ts index 088d7e66b..bf75735a9 100644 --- a/apps/web/src/pages/generate/hooks/useStep3Preview.ts +++ b/apps/web/src/pages/generate/hooks/useStep3Preview.ts @@ -1,8 +1,9 @@ /** * Step 3 生成预览 Hook - * 封装预览信息的计算逻辑 + * 封装预览信息计算 + 预览生成逻辑 + * TODO: 后端预览接口对齐后,替换 mock 逻辑为真实 API 调用 */ -import { useMemo } from "react" +import { useState, useCallback, useMemo, useEffect, useRef } from "react" import type { EditingTemplate } from "@/api/editing-planner" interface UseStep3PreviewProps { @@ -15,6 +16,22 @@ interface UseStep3PreviewProps { videoRatio: string } +export type PreviewStatus = "idle" | "generating" | "ready" | "error" + +/** 预览生成结果 */ +export interface PreviewResult { + /** 预计片段数 */ + clipCount: number + /** 预计转场数 */ + transitionCount: number + /** 素材使用率 */ + materialUsage: number + /** 预览视频URL(后端接口出来后填充) */ + previewVideoUrl: string | null + /** 生成耗时(秒) */ + generateDuration: number +} + export function useStep3Preview({ templates, selectedTemplate, @@ -36,11 +53,147 @@ export function useStep3Preview({ return `${selectedMaterials.length} 个素材` }, [materialMode, selectedMaterials.length, smartSelectedIds.length]) + const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length + + /* ── 预览生成状态 ── */ + const [previewStatus, setPreviewStatus] = useState("idle") + const [previewResult, setPreviewResult] = useState(null) + const [previewError, setPreviewError] = useState("") + + // 请求 ID 计数器,用于防止竞态条件(旧请求覆盖新状态) + const requestIdRef = useRef(0) + + /* ── 关键参数变化时重置预览状态(避免旧数据预览被误用) ── */ + const prevDepsRef = useRef({ + selectedTemplate, + materialMode, + selectedMaterials: [...selectedMaterials].sort().join(","), + smartSelectedIds: [...smartSelectedIds].sort().join(","), + duration, + videoRatio, + }) + + useEffect(() => { + const currentKey = [ + selectedTemplate, + materialMode, + [...selectedMaterials].sort().join(","), + [...smartSelectedIds].sort().join(","), + duration, + videoRatio, + ].join("|") + + const prevKey = [ + prevDepsRef.current.selectedTemplate, + prevDepsRef.current.materialMode, + prevDepsRef.current.selectedMaterials, + prevDepsRef.current.smartSelectedIds, + prevDepsRef.current.duration, + prevDepsRef.current.videoRatio, + ].join("|") + + if (prevKey !== currentKey && previewStatus !== "idle") { + // 参数变化时递增请求 ID,作废之前进行中的预览生成 + requestIdRef.current += 1 + setPreviewStatus("idle") + setPreviewResult(null) + setPreviewError("") + } + + prevDepsRef.current = { + selectedTemplate, + materialMode, + selectedMaterials: [...selectedMaterials].sort().join(","), + smartSelectedIds: [...smartSelectedIds].sort().join(","), + duration, + videoRatio, + } + }, [ + selectedTemplate, + materialMode, + selectedMaterials, + smartSelectedIds, + duration, + videoRatio, + previewStatus, + ]) + + /** + * 生成预览 + * TODO: 后端 /generation-preview 接口对齐后替换此处 mock 逻辑 + * 预计接口:POST /generate-preview + * 入参:template_id, material_ids, duration, video_ratio, style + * 出参:preview_id, status, clip_count, preview_video_url + */ + const generatePreview = useCallback(async () => { + if (!selectedTemplate) { + setPreviewError("请先选择模板") + setPreviewStatus("error") + return + } + if (materialTotal === 0) { + setPreviewError("请先选择素材") + setPreviewStatus("error") + return + } + + // 生成新的请求 ID,用于竞态检查 + const requestId = requestIdRef.current + 1 + requestIdRef.current = requestId + + setPreviewStatus("generating") + setPreviewError("") + + try { + // TODO: 替换为真实后端接口调用 + // const res = await generatePreviewAPI({ templateId: selectedTemplate, ... }) + await new Promise((resolve) => setTimeout(resolve, 1500)) + + // 竞态检查:如果请求已被新请求或参数变化作废,则丢弃结果 + if (requestIdRef.current !== requestId) return + + // Mock 结果 + const estimatedClips = Math.max(3, Math.min(materialTotal * 2, 20)) + const result: PreviewResult = { + clipCount: estimatedClips, + transitionCount: estimatedClips - 1, + materialUsage: Math.min( + 100, + Math.round((materialTotal / Math.max(estimatedClips, 1)) * 100), + ), + previewVideoUrl: null, // 后端接口出来后填充 + generateDuration: 1.5, + } + + setPreviewResult(result) + setPreviewStatus("ready") + } catch (e) { + if (requestIdRef.current !== requestId) return + setPreviewError(e instanceof Error ? e.message : "预览生成失败") + setPreviewStatus("error") + } + }, [selectedTemplate, materialTotal]) + + /** 重新生成预览 */ + const regeneratePreview = useCallback(() => { + generatePreview() + }, [generatePreview]) + + /** 是否可以下一步(预览已生成) */ + const canProceed = previewStatus === "ready" + return { templateName, materialCount, duration, videoRatio, + // 预览生成状态 + previewStatus, + previewResult, + previewError, + canProceed, + generatePreview, + regeneratePreview, } } diff --git a/apps/web/src/pages/generate/hooks/useStepNavigation.ts b/apps/web/src/pages/generate/hooks/useStepNavigation.ts index db6f0be2f..76fa2f982 100644 --- a/apps/web/src/pages/generate/hooks/useStepNavigation.ts +++ b/apps/web/src/pages/generate/hooks/useStepNavigation.ts @@ -13,6 +13,8 @@ export interface UseStepNavigationOptions { selectedMaterials: string[] smartSelectedIds: string[] titleSettings: TitleSettings + /** Step3 是否已生成预览(Step3校验用) */ + previewReady: boolean } export interface UseStepNavigationReturn { @@ -29,6 +31,7 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav selectedMaterials, smartSelectedIds, titleSettings, + previewReady, } = options const goNext = () => { @@ -44,6 +47,10 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav message.warning("请先进行智能匹配并选择素材") return } + if (currentStep === 3 && !previewReady) { + message.warning("请先生成剪辑预览") + return + } if (currentStep === 4 && !titleSettings.title.trim()) { message.warning("请选择或输入标题") return @@ -61,3 +68,5 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav return { goNext, goPrev } } + +export default useStepNavigation diff --git a/apps/web/src/test/pages/generate/step3-preview.smoke.test.tsx b/apps/web/src/test/pages/generate/step3-preview.smoke.test.tsx new file mode 100644 index 000000000..1ce57e7b7 --- /dev/null +++ b/apps/web/src/test/pages/generate/step3-preview.smoke.test.tsx @@ -0,0 +1,17 @@ +/** + * Step3GeneratePreview smoke test + * 确保 vitest related 模式能匹配到第3步预览生成相关文件的改动 + */ +import { describe, it, expect } from "vitest" + +import "@/pages/generate/components/Step3GeneratePreview" +import "@/pages/generate/hooks/useStep3Preview" +import "@/pages/generate/hooks/useStepNavigation" +import "@/pages/generate/components/GenerateStepContent" +import "@/pages/generate/GeneratePage" + +describe("Step3GeneratePreview module smoke test", () => { + it("should load all step3 preview modules", () => { + expect(true).toBe(true) + }) +})