diff --git a/apps/web/src/pages/generate/hooks/useStep3Preview.ts b/apps/web/src/pages/generate/hooks/useStep3Preview.ts index 3dc7f3cd3..bf75735a9 100644 --- a/apps/web/src/pages/generate/hooks/useStep3Preview.ts +++ b/apps/web/src/pages/generate/hooks/useStep3Preview.ts @@ -3,33 +3,33 @@ * 封装预览信息计算 + 预览生成逻辑 * TODO: 后端预览接口对齐后,替换 mock 逻辑为真实 API 调用 */ -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import type { EditingTemplate } from "@/api/editing-planner"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react" +import type { EditingTemplate } from "@/api/editing-planner" interface UseStep3PreviewProps { - templates: EditingTemplate[]; - selectedTemplate: string; - materialMode: "manual" | "auto"; - selectedMaterials: string[]; - smartSelectedIds: string[]; - duration: number; - videoRatio: string; + templates: EditingTemplate[] + selectedTemplate: string + materialMode: "manual" | "auto" + selectedMaterials: string[] + smartSelectedIds: string[] + duration: number + videoRatio: string } -export type PreviewStatus = "idle" | "generating" | "ready" | "error"; +export type PreviewStatus = "idle" | "generating" | "ready" | "error" /** 预览生成结果 */ export interface PreviewResult { /** 预计片段数 */ - clipCount: number; + clipCount: number /** 预计转场数 */ - transitionCount: number; + transitionCount: number /** 素材使用率 */ - materialUsage: number; + materialUsage: number /** 预览视频URL(后端接口出来后填充) */ - previewVideoUrl: string | null; + previewVideoUrl: string | null /** 生成耗时(秒) */ - generateDuration: number; + generateDuration: number } export function useStep3Preview({ @@ -44,26 +44,24 @@ export function useStep3Preview({ const templateName = useMemo( () => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择", [templates, selectedTemplate], - ); + ) const materialCount = useMemo(() => { if (materialMode === "auto") { - return `${smartSelectedIds.length} 个素材(智能匹配)`; + return `${smartSelectedIds.length} 个素材(智能匹配)` } - return `${selectedMaterials.length} 个素材`; - }, [materialMode, selectedMaterials.length, smartSelectedIds.length]); + return `${selectedMaterials.length} 个素材` + }, [materialMode, selectedMaterials.length, smartSelectedIds.length]) - const materialTotal = - materialMode === "auto" - ? smartSelectedIds.length - : selectedMaterials.length; + const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length /* ── 预览生成状态 ── */ - const [previewStatus, setPreviewStatus] = useState("idle"); - const [previewResult, setPreviewResult] = useState( - null, - ); - const [previewError, setPreviewError] = useState(""); + const [previewStatus, setPreviewStatus] = useState("idle") + const [previewResult, setPreviewResult] = useState(null) + const [previewError, setPreviewError] = useState("") + + // 请求 ID 计数器,用于防止竞态条件(旧请求覆盖新状态) + const requestIdRef = useRef(0) /* ── 关键参数变化时重置预览状态(避免旧数据预览被误用) ── */ const prevDepsRef = useRef({ @@ -73,7 +71,7 @@ export function useStep3Preview({ smartSelectedIds: [...smartSelectedIds].sort().join(","), duration, videoRatio, - }); + }) useEffect(() => { const currentKey = [ @@ -83,7 +81,7 @@ export function useStep3Preview({ [...smartSelectedIds].sort().join(","), duration, videoRatio, - ].join("|"); + ].join("|") const prevKey = [ prevDepsRef.current.selectedTemplate, @@ -92,12 +90,14 @@ export function useStep3Preview({ prevDepsRef.current.smartSelectedIds, prevDepsRef.current.duration, prevDepsRef.current.videoRatio, - ].join("|"); + ].join("|") if (prevKey !== currentKey && previewStatus !== "idle") { - setPreviewStatus("idle"); - setPreviewResult(null); - setPreviewError(""); + // 参数变化时递增请求 ID,作废之前进行中的预览生成 + requestIdRef.current += 1 + setPreviewStatus("idle") + setPreviewResult(null) + setPreviewError("") } prevDepsRef.current = { @@ -107,7 +107,7 @@ export function useStep3Preview({ smartSelectedIds: [...smartSelectedIds].sort().join(","), duration, videoRatio, - }; + } }, [ selectedTemplate, materialMode, @@ -116,7 +116,7 @@ export function useStep3Preview({ duration, videoRatio, previewStatus, - ]); + ]) /** * 生成预览 @@ -127,26 +127,33 @@ export function useStep3Preview({ */ const generatePreview = useCallback(async () => { if (!selectedTemplate) { - setPreviewError("请先选择模板"); - setPreviewStatus("error"); - return; + setPreviewError("请先选择模板") + setPreviewStatus("error") + return } if (materialTotal === 0) { - setPreviewError("请先选择素材"); - setPreviewStatus("error"); - return; + setPreviewError("请先选择素材") + setPreviewStatus("error") + return } - setPreviewStatus("generating"); - setPreviewError(""); + // 生成新的请求 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)); + await new Promise((resolve) => setTimeout(resolve, 1500)) + + // 竞态检查:如果请求已被新请求或参数变化作废,则丢弃结果 + if (requestIdRef.current !== requestId) return // Mock 结果 - const estimatedClips = Math.max(3, Math.min(materialTotal * 2, 20)); + const estimatedClips = Math.max(3, Math.min(materialTotal * 2, 20)) const result: PreviewResult = { clipCount: estimatedClips, transitionCount: estimatedClips - 1, @@ -156,23 +163,24 @@ export function useStep3Preview({ ), previewVideoUrl: null, // 后端接口出来后填充 generateDuration: 1.5, - }; + } - setPreviewResult(result); - setPreviewStatus("ready"); + setPreviewResult(result) + setPreviewStatus("ready") } catch (e) { - setPreviewError(e instanceof Error ? e.message : "预览生成失败"); - setPreviewStatus("error"); + if (requestIdRef.current !== requestId) return + setPreviewError(e instanceof Error ? e.message : "预览生成失败") + setPreviewStatus("error") } - }, [selectedTemplate, materialTotal]); + }, [selectedTemplate, materialTotal]) /** 重新生成预览 */ const regeneratePreview = useCallback(() => { - generatePreview(); - }, [generatePreview]); + generatePreview() + }, [generatePreview]) /** 是否可以下一步(预览已生成) */ - const canProceed = previewStatus === "ready"; + const canProceed = previewStatus === "ready" return { templateName, @@ -186,7 +194,7 @@ export function useStep3Preview({ canProceed, generatePreview, regeneratePreview, - }; + } } -export default useStep3Preview; +export default useStep3Preview