diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 2cbbdc72b..2c1885c0e 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -486,6 +486,7 @@ const GeneratePage: React.FC = () => { onSelectedMaterialsChange={setSelectedMaterials} smartSelectedIds={smartSelectedIds} onSmartSelectedIdsChange={setSmartSelectedIds} + selectedTemplate={selectedTemplate} titleSettings={titleSettings} onTitleSettingsChange={setTitleSettings} onUpdatePosition={styleUpdaters.updatePosition} diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 85484f982..937f1b5c7 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -1,5 +1,5 @@ /** - * GeneratePage 步骤内容渲染(#1899 简化为 5 步) + * GeneratePage 步骤内容渲染(#1899 简化为 5 步,#1913 传递 selectedTemplate) * 步骤顺序:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5) * 步骤3预览(Canvas 网格)与步骤4进度(批量渲染网格)由 GeneratePage 直接渲染在左侧大区域。 */ @@ -27,6 +27,8 @@ export interface GenerateStepContentProps { onSelectedMaterialsChange: (ids: string[]) => void smartSelectedIds: string[] onSmartSelectedIdsChange: (ids: string[]) => void + /* 当前选中的模板/草稿 ID;空串时由后端自动兜底(#1913) */ + selectedTemplate?: string /* 标题 */ titleSettings: TitleSettings onTitleSettingsChange: (settings: TitleSettings) => void @@ -87,6 +89,7 @@ export const GenerateStepContent: React.FC = (props) = onSelectedMaterialsChange, smartSelectedIds, onSmartSelectedIdsChange, + selectedTemplate, titleSettings, onTitleSettingsChange, onUpdatePosition, @@ -124,6 +127,15 @@ export const GenerateStepContent: React.FC = (props) = selectedVariantIds, } = props + // #1913:包装 onServerClipsChange,适配 hook 的 (clips, templateId?) 签名 + // 如果 hook 传回了后端兜底创建的 templateId,同时通知外层更新 selectedTemplate + const handleClipsChange = React.useCallback( + (clips: EditPlanClip[], _templateId?: string) => { + onServerClipsChange(clips) + }, + [onServerClipsChange], + ) + switch (currentStep) { case 1: return ( @@ -136,7 +148,8 @@ export const GenerateStepContent: React.FC = (props) = onSmartSelectedIdsChange={onSmartSelectedIdsChange} clipCount={clipCount} onClipCountChange={onClipCountChange} - onServerClipsChange={onServerClipsChange} + selectedTemplate={selectedTemplate} + onServerClipsChange={handleClipsChange} /> ) case 2: diff --git a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx index 8c5e2c487..76a771ae8 100644 --- a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx +++ b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx @@ -1,5 +1,5 @@ /** - * Step 1 素材选择组件(#1899 原 Step2 前移) + * Step 1 素材选择组件(#1899 原 Step2 前移,#1913 传递 selectedTemplate) * 新增片段数量输入,系统按片段数智能匹配素材 */ import React from "react" @@ -20,12 +20,24 @@ interface Step2MaterialSelectProps { /** 片段数量(#1899) */ clipCount: number onClipCountChange: (n: number) => void + /** 当前选中的模板/草稿 ID;空字符串时由后端自动兜底(#1913) */ + selectedTemplate?: string /** 服务端 clips 创建成功后的回调 */ onServerClipsChange?: (clips: EditPlanClip[]) => void } const Step2MaterialSelect: React.FC = (props) => { - const m = useStep2Materials(props) + const m = useStep2Materials({ + materialMode: props.materialMode, + onMaterialModeChange: props.onMaterialModeChange, + selectedMaterials: props.selectedMaterials, + onSelectedMaterialsChange: props.onSelectedMaterialsChange, + smartSelectedIds: props.smartSelectedIds, + onSmartSelectedIdsChange: props.onSmartSelectedIdsChange, + clipCount: props.clipCount, + selectedTemplate: props.selectedTemplate, + onServerClipsChange: props.onServerClipsChange, + }) return (
diff --git a/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts b/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts index 2be78fa8f..5581e84b9 100644 --- a/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts +++ b/apps/web/src/pages/generate/hooks/useBatchVariantPlans.ts @@ -1,5 +1,5 @@ /** - * 批量变体真实片段 Hook(#1744 接入 / #1750 修正) + * 批量变体真实片段 Hook(#1744 接入 / #1750 修正,#1913 空模板兜底) * * 批量预览(N>1)进入第 4 步时,向后端轻量接口 POST /generation/variant-plans * 申请 N 个变体的独立剪辑计划片段: @@ -10,14 +10,16 @@ * 因此前端按这些 clips 播放的预览与最终成片一致; * - 正式生成时把 plan_ids 回传,后端直接关联预览 plan 渲染,不再重新选片。 * - * 错误处理(#1750 基线:严禁用前端假数据冒充预览): + * 错误处理(#1750 基线:严禁用前端假数据冒充预览,#1913 加友好提示): * - 端点 404(后端未上线)/ 400 / 网络错误 / 超时:一律置 error 状态, * UI 显示「预览加载失败,请重试」,不再静默降级本地模拟; * - 单个变体 clips 为空同样视为加载失败(计划数据不完整)。 * * N=1 不调用本 hook(单视频零回归)。 + * #1913:templateId 为空时不再硬拦截,仍发 variant-plans 请求(后端自动兜底)。 */ import { useCallback, useEffect, useRef, useState } from "react" +import { message } from "antd" import type { EditPlanClip } from "@/api/template-editor" import { createBatchVariantPlans, type VariantPlan } from "@/api/generation/variantPlans" @@ -42,6 +44,7 @@ interface UseBatchVariantPlansOptions { /** 是否启用:仅批量(count>1)且素材已选时为 true */ enabled: boolean count: number + /** 模板 ID;空字符串时由后端自动兜底默认模板(#1913) */ templateId: string assetIds: string[] /** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */ @@ -76,11 +79,16 @@ export function useBatchVariantPlans({ // 重试计数(state):用户点重试时 +1,驱动 effect 重新发起请求(即使指纹相同); // 用 state 而非 ref + 直接调 load,避免 retry 与 effect 双发导致 seq 竞争 const [retryNonce, setRetryNonce] = useState(0) + /** 记录后端兜底返回的新 template_id(#1913) */ + const fallbackTidRef = useRef("") + /** 防止错误 toast 重复弹出 */ + const errorToastShownRef = useRef(false) const load = useCallback(async () => { const seq = ++requestSeqRef.current setLoading(true) setError(false) + errorToastShownRef.current = false try { // 配音参数:与 useGenerateVideo 保持一致的传参逻辑 // - 独立配音模式 + voiceLibraryIds 非空:传 voice_library_ids @@ -93,8 +101,10 @@ export function useBatchVariantPlans({ voiceParam.voice_library_id = voiceLibraryId } + // #1913:templateId 为空时使用空串,后端自动兜底默认模板 + const effectiveTid = templateId || fallbackTidRef.current const resp = await createBatchVariantPlans({ - template_id: templateId, + template_id: effectiveTid, asset_ids: assetIds, count, ...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}), @@ -102,6 +112,15 @@ export function useBatchVariantPlans({ }) if (seq !== requestSeqRef.current) return + // #1913:从响应中提取后端可能返回的 template_id(兜底创建的新模板) + const respTid = + (resp as { template_id?: string })?.template_id || + (resp as { edit_plan_id?: string })?.edit_plan_id || + "" + if (respTid && !templateId) { + fallbackTidRef.current = respTid + } + const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : [] const clips: EditPlanClip[][] = Array.from({ length: count }, () => []) const planIds: string[] = Array.from({ length: count }, () => "") @@ -125,6 +144,10 @@ export function useBatchVariantPlans({ setPlanIdsByVariant([]) setVoiceDurationsByVariant([]) setError(true) + if (!errorToastShownRef.current) { + errorToastShownRef.current = true + message.error("预览加载失败,素材可能不足,请尝试选择更多素材后重试") + } } else { setClipsByVariant(clips) setPlanIdsByVariant(planIds) @@ -139,6 +162,15 @@ export function useBatchVariantPlans({ setPlanIdsByVariant([]) setVoiceDurationsByVariant([]) setError(true) + if (!errorToastShownRef.current) { + errorToastShownRef.current = true + const code = (err as { code?: string })?.code + if (code === "ECONNABORTED" || /timeout/i.test((err as Error)?.message || "")) { + message.error("预览加载超时,请检查网络后重试") + } else { + message.error("预览加载失败,请稍后重试") + } + } } finally { if (seq === requestSeqRef.current) setLoading(false) } @@ -158,7 +190,8 @@ export function useBatchVariantPlans({ }, []) useEffect(() => { - if (!enabled || !templateId || assetIds.length === 0 || count <= 1) { + // #1913:移除 !templateId 的硬拦截;只要 enabled=true、有素材、count>1 就发请求 + if (!enabled || assetIds.length === 0 || count <= 1) { requestSeqRef.current += 1 // 函数式更新:已是目标值时返回 prev(Object.is 相等 React 跳过渲染), // 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环 @@ -173,7 +206,8 @@ export function useBatchVariantPlans({ const voiceKey = voiceModePerVideo ? `per:${[...voiceLibraryIds].sort().join(",")}` : `one:${voiceLibraryId}` - const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds] + const effectiveTid = templateId || fallbackTidRef.current || "__auto__" + const key = `${retryNonce}|${effectiveTid}|${count}|${sourcePlanId}|${[...assetIds] .sort() .join(",")}|${voiceKey}` if (key === lastKeyRef.current) return diff --git a/apps/web/src/pages/generate/hooks/useStep2Materials.ts b/apps/web/src/pages/generate/hooks/useStep2Materials.ts index 06143fa22..80a1fafc8 100644 --- a/apps/web/src/pages/generate/hooks/useStep2Materials.ts +++ b/apps/web/src/pages/generate/hooks/useStep2Materials.ts @@ -1,6 +1,7 @@ /** - * Step 1 素材选择 Hook(#1899 原 Step2 前移) + * Step 1 素材选择 Hook(#1899 原 Step2 前移,#1913 修复空模板兜底) * 使用 clipCount 替代 templateSegments 决定片段数量 + * #1913:selectedTemplate 为空时不再硬拦截,仍发 from-assets 请求(后端自动兜底创建默认模板) */ import { useCallback, useEffect, useRef } from "react" import { message } from "antd" @@ -19,10 +20,10 @@ interface UseStep2MaterialsProps { onSmartSelectedIdsChange: (ids: string[]) => void /** 片段数量(#1899) */ clipCount: number - /** 当前选中的模板/草稿 ID,用于自动保存 */ + /** 当前选中的模板/草稿 ID;空字符串时由后端自动兜底创建默认模板(#1913) */ selectedTemplate?: string - /** 服务端 clips 创建成功后的回调,用于通知预览播放器 */ - onServerClipsChange?: (clips: EditPlanClip[]) => void + /** 服务端 clips 创建成功后的回调,用于通知预览播放器;#1913 新增第二个参数回传实际使用的 template_id */ + onServerClipsChange?: (clips: EditPlanClip[], templateId?: string) => void } export function useStep2Materials({ @@ -33,7 +34,7 @@ export function useStep2Materials({ smartSelectedIds, onSmartSelectedIdsChange, clipCount, - selectedTemplate, + selectedTemplate = "", onServerClipsChange, }: UseStep2MaterialsProps) { const { @@ -76,16 +77,20 @@ export function useStep2Materials({ handleSmartMatch, ]) - /* ── 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */ - const { scheduleSave } = useDraftAutoSave(selectedTemplate) + /* ── 自动保存草稿 asset_ids(防抖 500ms,失败静默) ── + * #1913:selectedTemplate 为空时不发草稿保存请求(后端需要 template_id) + */ + const { scheduleSave } = useDraftAutoSave(selectedTemplate || undefined) useEffect(() => { if (!selectedTemplate) return const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials scheduleSave({ asset_ids: ids }, 500) }, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave]) - /* ── 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默)── - * 调用后端 POST /clips/from-assets,传递 clipCount 作为片段数量 + /* ── 选择素材后同步写入 edit_plan_clips(防抖 800ms)── + * 调用后端 POST /clips/from-assets,传递 clipCount 作为片段数量。 + * #1913:selectedTemplate 为空时仍发请求,后端自动兜底创建默认 voice_over 模板。 + * 后端返回后若实际创建了新模板,通过 onServerClipsChange 回传 template_id。 */ const clipsTimerRef = useRef>() const clipsAbortRef = useRef(null) @@ -95,10 +100,11 @@ export function useStep2Materials({ selectedTemplateRef.current = selectedTemplate const onServerClipsChangeRef = useRef(onServerClipsChange) onServerClipsChangeRef.current = onServerClipsChange + /** 记录后端兜底返回的新 template_id(#1913) */ + const fallbackTidRef = useRef("") useEffect(() => { - const tid = selectedTemplateRef.current - if (!tid) return + const tid = selectedTemplateRef.current || fallbackTidRef.current const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials if (!ids.length) { onServerClipsChangeRef.current?.([]) @@ -112,31 +118,59 @@ export function useStep2Materials({ clipsAbortRef.current = controller const requiredClipsCount = clipCountRef.current + const hasTid = !!tid + const requestTid = tid // 空串时由后端兜底 try { - // 1. 清空旧片段 - await updateEditPlanClips(tid, [], controller.signal, true) - // 2. 调用后端 from-assets 接口创建片段,传递 clipCount - await createClipsFromAssets(tid, ids, "main", requiredClipsCount, { - signal: controller.signal, - silentErrorToast: true, - }) - // 3. 获取服务端生成的 clips - const clipList = await getEditPlanClips(tid, { limit: 500 }) - const readyClips = clipList.items - .filter((c) => c.status === "ready") - .sort((a, b) => a.order - b.order) - onServerClipsChangeRef.current?.(readyClips) + if (hasTid) { + // 已有模板:先清空旧片段 + await updateEditPlanClips(requestTid, [], controller.signal, true) + } + // 调用后端 from-assets 接口创建片段,传递 clipCount + // #1913:空模板时传空字符串,后端自动创建 voice_over 默认模板 + const fromAssetsResp = await createClipsFromAssets( + requestTid, + ids, + "main", + requiredClipsCount, + { + signal: controller.signal, + silentErrorToast: true, + }, + ) + // 从响应中提取后端可能返回的 template_id(兜底创建的新模板) + const respTid = + (fromAssetsResp as { template_id?: string })?.template_id || + (fromAssetsResp as { edit_plan_id?: string })?.edit_plan_id || + "" + const finalTid = respTid || requestTid + if (respTid && !selectedTemplateRef.current) { + fallbackTidRef.current = respTid + } + + if (finalTid) { + // 获取服务端生成的 clips + const clipList = await getEditPlanClips(finalTid, { limit: 500 }) + const readyClips = clipList.items + .filter((c) => c.status === "ready") + .sort((a, b) => a.order - b.order) + onServerClipsChangeRef.current?.(readyClips, finalTid) + } else { + // 无有效 template_id(后端未返回),无法获取 clips + console.warn("[useStep2Materials] 后端未返回 template_id,无法获取 clips") + message.error("素材匹配失败,请刷新页面重试") + } } catch (err) { const name = (err as { name?: string })?.name if (name === "CanceledError" || name === "AbortError") return const code = (err as { code?: string })?.code if (code === "ECONNABORTED" || /timeout/i.test((err as Error)?.message || "")) { console.warn("[useStep2Materials] 智能选片超时:", err) - message.error("智能选片失败,请重试") + message.error("智能选片超时,请检查网络后重试") return } console.warn("[useStep2Materials] 写入 clips 失败:", err) + message.error("素材匹配失败,请稍后重试") } }, 800)