From 83491297f6cb4601664da7afef50b6596ab9f5a9 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 23 Aug 2026 13:16:59 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(generate):=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E8=87=AA=E5=8A=A8=E4=BF=9D=E5=AD=98=E8=8D=89?= =?UTF-8?q?=E7=A8=BF+=E5=88=9B=E5=BB=BA=E4=BB=BB=E5=8A=A1=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E4=BF=AE=E5=A4=8D+=E8=BD=AE=E8=AF=A2=E6=94=B9?= =?UTF-8?q?=E6=96=B0=E6=8E=A5=E5=8F=A3+=E6=B8=85=E7=90=86=E5=B9=BD?= =?UTF-8?q?=E7=81=B5API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Step2选素材后防抖500ms自动保存asset_ids到草稿 2. Step4标题变化后防抖800ms自动保存title到草稿 3. Step6封面请求带上title_config(初始调用+预览缺失后重试) 4. Step7创建任务带source_edit_plan_id + cover_url优先thumbnail_url 5. 轮询改用GET /generation/tasks/{task_id},task_id从创建响应获取 6. 删除7个幽灵API函数:getEditPlans/createEditPlan/deleteEditPlan/ cancelGeneration/copyEditPlan/generateFromTemplate,BGM预设路径改为 /templates/{id}/editor/bgm/presets 7. CreateGenerationTaskResponse对齐后端批量结构{items,total} 8. 所有自动保存失败console.warn静默,不弹窗不阻塞 --- apps/web/src/api/bgm/bgm.ts | 15 +- apps/web/src/api/generation/cover.ts | 13 ++ apps/web/src/api/tasks/tasks.ts | 7 + apps/web/src/api/tasks/types.ts | 16 ++- apps/web/src/api/template-editor/editPlans.ts | 45 +----- apps/web/src/api/template-editor/index.ts | 9 -- apps/web/src/api/template-editor/types.ts | 11 ++ apps/web/src/api/templates/index.ts | 3 - apps/web/src/api/templates/templates.ts | 14 -- .../components/bgm-selector/index.tsx | 12 +- .../components/bgm-selector/useBgmSelector.ts | 10 +- apps/web/src/pages/generate/GeneratePage.tsx | 1 + .../components/GenerateStepContent.tsx | 2 + .../components/Step2MaterialSelect.tsx | 2 + .../components/Step4TitleSettings.tsx | 2 + .../generate/hooks/generate-video/types.ts | 2 + .../generate-video/useGenerationPolling.ts | 131 +++++++++++------- .../pages/generate/hooks/useDraftAutoSave.ts | 97 +++++++++++++ .../pages/generate/hooks/useGenerateVideo.ts | 16 ++- .../pages/generate/hooks/useStep2Materials.ts | 12 ++ .../generate/hooks/useStep4Title/index.ts | 41 +++++- .../src/pages/generate/hooks/useStep6Cover.ts | 28 ++++ apps/web/src/test/api/bgm.test.ts | 4 +- apps/web/src/test/api/edit-plans.test.ts | 85 ------------ apps/web/src/test/api/templates.test.ts | 17 --- .../src/test/pages/EditingPlanner.test.tsx | 2 - .../src/test/pages/TemplateLibrary.test.tsx | 1 - 27 files changed, 360 insertions(+), 238 deletions(-) create mode 100644 apps/web/src/pages/generate/hooks/useDraftAutoSave.ts diff --git a/apps/web/src/api/bgm/bgm.ts b/apps/web/src/api/bgm/bgm.ts index d9dc58e62..181d84ddc 100644 --- a/apps/web/src/api/bgm/bgm.ts +++ b/apps/web/src/api/bgm/bgm.ts @@ -4,11 +4,20 @@ import apiClient from "../client" import type { BgmPreset, BgmPresetsQuery } from "./types" -/** 获取 BGM 预设列表 */ -export const getBgmPresets = async (params?: BgmPresetsQuery): Promise => { +/** + * 获取 BGM 预设列表 + * @param templateId 模板/草稿 ID + * @param params 分类/关键词筛选 + */ +export const getBgmPresets = async ( + templateId: string, + params?: BgmPresetsQuery, +): Promise => { const searchParams: Record = {} if (params?.category) searchParams.category = params.category if (params?.keyword) searchParams.keyword = params.keyword - const res = await apiClient.get("/bgm/presets", { params: searchParams }) + const res = await apiClient.get(`/templates/${templateId}/editor/bgm/presets`, { + params: searchParams, + }) return res.data?.data ?? res.data ?? [] } diff --git a/apps/web/src/api/generation/cover.ts b/apps/web/src/api/generation/cover.ts index 089e6de25..3a230220d 100644 --- a/apps/web/src/api/generation/cover.ts +++ b/apps/web/src/api/generation/cover.ts @@ -1,9 +1,22 @@ import apiClient from "../client" +export interface GenerateCoverTitleConfig { + text?: string + font?: string + font_size?: number + font_color?: string + position?: string + bold?: boolean + stroke?: boolean + shadow?: boolean +} + export interface GenerateCoverRequest { asset_ids: string[] cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate" frame_time?: number + /** 标题样式,用于在封面上叠加标题文字 */ + title_config?: GenerateCoverTitleConfig } export interface GenerateCoverResponse { diff --git a/apps/web/src/api/tasks/tasks.ts b/apps/web/src/api/tasks/tasks.ts index 6b0ad6cc3..80b0fdb08 100644 --- a/apps/web/src/api/tasks/tasks.ts +++ b/apps/web/src/api/tasks/tasks.ts @@ -6,6 +6,7 @@ import apiClient from "../client" import type { CreateGenerationTaskRequest, CreateGenerationTaskResponse, + GenerationTaskDetail, TaskItem, TaskListParams, TaskListResponse, @@ -19,6 +20,12 @@ export const createGenerationTask = async ( return data } +/** 获取单个生成任务详情(轮询用) */ +export const getGenerationTask = async (taskId: string): Promise => { + const { data } = await apiClient.get(`/generation/tasks/${taskId}`) + return data +} + /** 获取任务列表(支持分页和筛选) */ export const getTasks = async (params?: TaskListParams): Promise => { const { data } = await apiClient.get("/tasks", { diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 27f588cda..3dc06469d 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -82,10 +82,12 @@ export interface CreateGenerationTaskRequest { stroke?: boolean shadow?: boolean } + /** 关联的草稿 ID(编辑流程数据链路用) */ + source_edit_plan_id?: string } -/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */ -export interface CreateGenerationTaskResponse { +/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */ +export interface GenerationTaskDetail { id: string project_id: string asset_library_id: string @@ -95,8 +97,18 @@ export interface CreateGenerationTaskResponse { asset_ids: string[] title_ids: string[] voice_ids: string[] + source_edit_plan_id?: string status: string progress: number result_count: number error_message: string + error_info?: TaskErrorInfo + created_at?: string | null + updated_at?: string | null +} + +/** 创建生成任务响应(后端返回批量结构 {items, total}) */ +export interface CreateGenerationTaskResponse { + items: GenerationTaskDetail[] + total: number } diff --git a/apps/web/src/api/template-editor/editPlans.ts b/apps/web/src/api/template-editor/editPlans.ts index c4d0785f1..54a6d576f 100644 --- a/apps/web/src/api/template-editor/editPlans.ts +++ b/apps/web/src/api/template-editor/editPlans.ts @@ -4,51 +4,29 @@ import apiClient from "../client" import type { EditPlan, - EditPlanListParams, - EditPlanListResponse, - CreateEditPlanRequest, UpdateEditPlanRequest, GenerateResponse, GenerationStatusResponse, EditPlanGeneration, GeneratedVideo, - CopyEditPlanRequest, } from "./types" -/** 获取模板草稿列表(支持分页和筛选) */ -export async function getEditPlans(params?: EditPlanListParams): Promise { - const response = await apiClient.get("/templates/drafts", { - params, - }) - return response.data -} - /** 获取单个模板草稿 */ export async function getEditPlan(templateId: string): Promise { const response = await apiClient.get(`/templates/${templateId}/editor`) return response.data } -/** 创建模板草稿 */ -export async function createEditPlan(data: CreateEditPlanRequest): Promise { - const response = await apiClient.post("/templates/drafts", data) - return response.data -} - -/** 更新模板草稿 */ +/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */ export async function updateEditPlan( templateId: string, data: UpdateEditPlanRequest, + signal?: AbortSignal, ): Promise { - const response = await apiClient.put(`/templates/${templateId}/editor`, data) + const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal }) return response.data } -/** 删除模板草稿 */ -export async function deleteEditPlan(templateId: string): Promise { - await apiClient.delete(`/templates/${templateId}/editor`) -} - /** 触发生成 */ export async function generateEditPlan(templateId: string): Promise { const response = await apiClient.post(`/templates/${templateId}/editor/generate`) @@ -72,20 +50,3 @@ export async function getGenerationTaskResults(taskId: string): Promise { - await apiClient.post(`/templates/${templateId}/editor/cancel`) -} - -/** 复制模板草稿(含所有片段配置) */ -export async function copyEditPlan( - templateId: string, - data?: CopyEditPlanRequest, -): Promise { - const response = await apiClient.post( - `/templates/${templateId}/editor/copy`, - data || {}, - ) - return response.data -} diff --git a/apps/web/src/api/template-editor/index.ts b/apps/web/src/api/template-editor/index.ts index af50dd265..41a521c7f 100644 --- a/apps/web/src/api/template-editor/index.ts +++ b/apps/web/src/api/template-editor/index.ts @@ -15,10 +15,7 @@ export type { EditPlanSegment, EditPlanConfig, EditPlan, - CreateEditPlanRequest, UpdateEditPlanRequest, - EditPlanListParams, - EditPlanListResponse, GenerateResponse, EditPlanGeneration, ClipStatusItem, @@ -37,7 +34,6 @@ export type { ClipReorderResponse, ClipBatchDeleteResponse, ClipsFromAssetsResponse, - CopyEditPlanRequest, TransitionEffect, MediaAsset, } from "./types" @@ -53,17 +49,12 @@ export { // 模板草稿 CRUD + 生成 export { - getEditPlans, getEditPlan, - createEditPlan, updateEditPlan, - deleteEditPlan, generateEditPlan, getGenerationStatus, getEditPlanGenerations, getGenerationTaskResults, - cancelGeneration, - copyEditPlan, } from "./editPlans" // 片段 CRUD + 批量操作 diff --git a/apps/web/src/api/template-editor/types.ts b/apps/web/src/api/template-editor/types.ts index 9e1920e3d..46c367624 100644 --- a/apps/web/src/api/template-editor/types.ts +++ b/apps/web/src/api/template-editor/types.ts @@ -118,6 +118,17 @@ export interface EditPlanConfig { generate_count?: number /** 素材模式 */ material_mode?: string + /** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */ + title?: { + text?: string + font?: string + font_size?: number + color?: string + position?: string + bold?: boolean + stroke?: boolean + shadow?: boolean + } /** 预览视频 URL(封面生成用) */ rendered_storage_key?: string /** 生成任务 ID */ diff --git a/apps/web/src/api/templates/index.ts b/apps/web/src/api/templates/index.ts index bece1cbb4..2ed58152f 100644 --- a/apps/web/src/api/templates/index.ts +++ b/apps/web/src/api/templates/index.ts @@ -9,8 +9,6 @@ export type { TemplateSegment, TemplateListParams, TemplateListResponse, - GenerateFromTemplateRequest, - GenerateFromTemplateResponse, CopyTemplateResponse, } from "./types" @@ -24,5 +22,4 @@ export { getTemplate, toggleFavoriteTemplate, copyTemplate, - generateFromTemplate, } from "./templates" diff --git a/apps/web/src/api/templates/templates.ts b/apps/web/src/api/templates/templates.ts index d388f932a..07b26ecba 100644 --- a/apps/web/src/api/templates/templates.ts +++ b/apps/web/src/api/templates/templates.ts @@ -5,8 +5,6 @@ import apiClient from "../client" import type { CopyTemplateResponse, - GenerateFromTemplateRequest, - GenerateFromTemplateResponse, TemplateItem, TemplateListParams, TemplateListResponse, @@ -45,15 +43,3 @@ export const copyTemplate = async (templateId: string): Promise(`/templates/${templateId}/copy`) return response.data } - -/** 从模板生成 */ -export const generateFromTemplate = async ( - templateId: string, - data?: GenerateFromTemplateRequest, -): Promise => { - const response = await apiClient.post( - `/templates/${templateId}/generate`, - data, - ) - return response.data -} diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx b/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx index 51aa19e31..6964addc2 100644 --- a/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx @@ -16,9 +16,17 @@ interface BgmSelectorProps { onClose: () => void config: BgmMixConfig onChange: (config: BgmMixConfig) => void + /** 模板/草稿 ID,用于请求 BGM 预设 */ + templateId?: string } -const BgmSelector: React.FC = ({ open, onClose, config, onChange }) => { +const BgmSelector: React.FC = ({ + open, + onClose, + config, + onChange, + templateId, +}) => { const { presets, loading, @@ -30,7 +38,7 @@ const BgmSelector: React.FC = ({ open, onClose, config, onChan loadPresets, handlePreview, stopPreview, - } = useBgmSelector(open) + } = useBgmSelector(open, templateId) /* ── 选中 BGM ── */ const handleSelect = useCallback( diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts b/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts index 8a47b6a6a..755adf839 100644 --- a/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts @@ -19,7 +19,7 @@ export const CATEGORY_LIST: { * BGM 选择器数据与交互 Hook * 封装列表加载、搜索、分类筛选、试听播放逻辑 */ -export function useBgmSelector(open: boolean) { +export function useBgmSelector(open: boolean, templateId?: string) { const [presets, setPresets] = useState([]) const [loading, setLoading] = useState(false) const [activeCategory, setActiveCategory] = useState("all") @@ -30,19 +30,23 @@ export function useBgmSelector(open: boolean) { /* ── 加载 BGM 列表 ── */ const loadPresets = useCallback(async () => { + if (!templateId) { + setPresets([]) + return + } setLoading(true) try { const params: { category?: string; keyword?: string } = {} if (activeCategory !== "all") params.category = activeCategory if (keyword.trim()) params.keyword = keyword.trim() - const data = await getBgmPresets(params) + const data = await getBgmPresets(templateId, params) setPresets(data) } catch { message.error("加载 BGM 列表失败") } finally { setLoading(false) } - }, [activeCategory, keyword]) + }, [activeCategory, keyword, templateId]) useEffect(() => { if (open) loadPresets() diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index c6bf2d163..449b30174 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -171,6 +171,7 @@ const GeneratePage: React.FC = () => { autoSubtitles, bgm, generateCount, + sourceEditPlanId: editPlanId, }) /* ================================================================ diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 433841a3b..e7cbca646 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -141,6 +141,7 @@ export const GenerateStepContent: React.FC = (props) = onSelectedMaterialsChange={onSelectedMaterialsChange} smartSelectedIds={smartSelectedIds} onSmartSelectedIdsChange={onSmartSelectedIdsChange} + selectedTemplate={selectedTemplate} /> ) case 3: @@ -156,6 +157,7 @@ export const GenerateStepContent: React.FC = (props) = ) case 5: diff --git a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx index 395916d42..19ff574e6 100644 --- a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx +++ b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx @@ -15,6 +15,8 @@ interface Step2MaterialSelectProps { onSelectedMaterialsChange: (ids: string[]) => void smartSelectedIds: string[] onSmartSelectedIdsChange: (ids: string[]) => void + /** 当前选中的模板/草稿 ID,用于自动保存 */ + selectedTemplate?: string } const Step2MaterialSelect: React.FC = (props) => { diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index d01dbf48d..c60d48c56 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -12,6 +12,8 @@ import AiTitleGenerator from "./title/AiTitleGenerator" interface Step4TitleSettingsProps { titleSettings: TitleSettings onTitleSettingsChange: (settings: TitleSettings) => void + /** 当前选中的模板/草稿 ID,用于自动保存 */ + selectedTemplate?: string } const Step4TitleSettings: React.FC = (props) => { diff --git a/apps/web/src/pages/generate/hooks/generate-video/types.ts b/apps/web/src/pages/generate/hooks/generate-video/types.ts index a1f0341c6..1c2b6e2a5 100755 --- a/apps/web/src/pages/generate/hooks/generate-video/types.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -19,6 +19,8 @@ export interface UseGenerateVideoProps { autoSubtitles: boolean bgm: boolean generateCount: number + /** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */ + sourceEditPlanId?: string | null } /** 生成阶段 */ diff --git a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts index 54bf7a27d..54e9efe38 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts @@ -1,87 +1,122 @@ import { useRef, useCallback } from "react" import { message } from "antd" -import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor" +import axios from "axios" +import { getGenerationTask } from "@/api/tasks/tasks" +import { getGenerationTaskResults } from "@/api/template-editor" import { safeExtractError } from "./errorUtils" interface UseGenerationPollingOptions { - templateId: string onProgress: (progress: number) => void onComplete: (videos: unknown[]) => void onFailed: (errorMsg: string) => void } +/** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */ +const MAX_RETRYABLE_ERRORS = 10 + /** - * 生成状态轮询 Hook - * 轮询生成状态,更新进度,处理完成/失败 + * 生成状态轮询 Hook(v2 — 改用 /generation/tasks/{task_id}) + * + * 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态, + * 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks + * 返回的 task_id 轮询任务详情,不再依赖 plan。 + * + * 错误处理: + * - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试 + * - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次 */ export const useGenerationPolling = ({ - templateId, onProgress, onComplete, onFailed, }: UseGenerationPollingOptions) => { const progressTimer = useRef>() + const cancelledRef = useRef(false) const clearTimer = useCallback(() => { + cancelledRef.current = true if (progressTimer.current) { clearTimeout(progressTimer.current) progressTimer.current = undefined } }, []) - const startPolling = useCallback(() => { - const poll = async () => { - try { - const data = await getGenerationStatus(templateId) + const startPolling = useCallback( + (taskId: string) => { + cancelledRef.current = false + let consecutiveErrors = 0 - if (data.plan_status === "completed") { - onProgress(100) - // 获取生成的视频结果 - let videos: unknown[] = [] - if (data.generation_task_id) { + const poll = async () => { + if (cancelledRef.current) return + try { + const task = await getGenerationTask(taskId) + consecutiveErrors = 0 + + if (task.status === "completed") { + onProgress(100) + let videos: unknown[] = [] try { - videos = await getGenerationTaskResults(data.generation_task_id) + videos = await getGenerationTaskResults(taskId) } catch (err) { console.error("[获取生成结果失败]", err) } + onComplete(videos) + message.success("视频生成完成!") + return } - onComplete(videos) - message.success("视频生成完成!") - return - } - if (data.plan_status === "failed") { - const dataAny = data as unknown as Record - const rawMsg = - dataAny.error_message || - dataAny.error || - dataAny.message || - (Array.isArray(data.clips) - ? (data.clips as { status: string; error_message?: string }[]).find( - (c) => c.status === "failed", - )?.error_message - : undefined) || - "视频生成失败,请联系管理员或重试" - const errorMsg = safeExtractError(rawMsg) - console.error("[生成失败] templateId:", templateId, "响应:", data) - onFailed(errorMsg) - message.error(errorMsg) - return - } - const clips = data.clips || [] - const total = clips.length || 1 - const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length - onProgress(Math.round((done / total) * 100)) + if (task.status === "failed" || task.status === "cancelled") { + const rawMsg = + task.error_info?.error_message || + task.error_message || + (task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试") + const errorMsg = safeExtractError(rawMsg) + console.error("[生成失败] taskId:", taskId, "响应:", task) + onFailed(errorMsg) + message.error(errorMsg) + return + } - progressTimer.current = setTimeout(poll, 2000) - } catch (pollErr) { - console.error("[轮询出错] templateId:", templateId, pollErr) - progressTimer.current = setTimeout(poll, 3000) + // pending / waiting / running — 继续轮询 + const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0))) + onProgress(pct) + progressTimer.current = setTimeout(poll, 2000) + } catch (pollErr) { + if (cancelledRef.current) return + console.error("[轮询出错] taskId:", taskId, pollErr) + + // 4xx 不可恢复,立即失败 + const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined + if (status && status >= 400 && status < 500) { + const msg = + (axios.isAxiosError(pollErr) && + (pollErr.response?.data as { detail?: string; message?: string } | undefined) + ?.detail) || + (axios.isAxiosError(pollErr) && + (pollErr.response?.data as { detail?: string; message?: string } | undefined) + ?.message) || + `查询任务失败 (${status})` + const errorMsg = safeExtractError(msg) + onFailed(errorMsg) + message.error(errorMsg) + return + } + + consecutiveErrors += 1 + if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) { + const errorMsg = "任务状态查询连续失败,请稍后在任务列表查看结果" + onFailed(errorMsg) + message.error(errorMsg) + return + } + progressTimer.current = setTimeout(poll, 3000) + } } - } - progressTimer.current = setTimeout(poll, 2000) - }, [templateId, onProgress, onComplete, onFailed]) + progressTimer.current = setTimeout(poll, 1500) + }, + [onProgress, onComplete, onFailed], + ) return { startPolling, clearTimer } } diff --git a/apps/web/src/pages/generate/hooks/useDraftAutoSave.ts b/apps/web/src/pages/generate/hooks/useDraftAutoSave.ts new file mode 100644 index 000000000..574055c5b --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useDraftAutoSave.ts @@ -0,0 +1,97 @@ +/** + * 草稿自动保存工具 Hook + * + * 背景:后端 PUT /templates/{id}/editor 的 config 是「整体替换」语义, + * 直接发送 { config: { asset_ids } } 会把 title 等其他字段覆盖掉。 + * 本 Hook 统一执行「GET 当前 config → 浅合并新字段 → PUT 回去」, + * 并用串行队列 + AbortController 保证: + * - 同一时刻只有一个保存请求在飞 + * - 快速连续变化时只提交最后一次 + * - 组件卸载时取消未完成请求 + * + * 保存失败只 console.warn,不弹窗、不阻塞。 + */ +import { useCallback, useEffect, useRef } from "react" +import { getEditPlan, updateEditPlan } from "@/api/template-editor" + +type ConfigPatch = Record + +export function useDraftAutoSave(templateId?: string) { + const timerRef = useRef>() + const abortRef = useRef(null) + // 待合并的补丁队列(解决「保存进行中又来了新变化」) + const pendingPatchRef = useRef(null) + const savingRef = useRef(false) + const templateIdRef = useRef(templateId) + templateIdRef.current = templateId + + const flush = useCallback(async () => { + const tid = templateIdRef.current + if (!tid) return + // 已有保存在飞:把新补丁暂存,等当前请求结束后再合并一次 + if (savingRef.current) return + savingRef.current = true + + // 把暂存的所有补丁合到一起 + const patch = pendingPatchRef.current + pendingPatchRef.current = null + if (!patch) { + savingRef.current = false + return + } + + const controller = new AbortController() + abortRef.current = controller + try { + // 1. 读当前 config(拿最新,避免覆盖别人/别的步骤写入的字段) + const current = await getEditPlan(tid) + if (controller.signal.aborted) return + const merged = { ...(current.config || {}), ...patch } + // 2. 写回完整合并后的 config + await updateEditPlan(tid, { config: merged }, controller.signal) + } catch (err) { + const name = (err as { name?: string })?.name + if (name !== "CanceledError" && name !== "AbortError") { + console.warn("[useDraftAutoSave] 自动保存草稿失败:", err) + } + } finally { + savingRef.current = false + // 如果保存期间又积累了新的变化,再触发一次 + if (pendingPatchRef.current && !controller.signal.aborted) { + timerRef.current = setTimeout(() => { + void flush() + }, 50) + } + } + }, []) + + /** + * 调度一次自动保存(防抖) + * @param patch 要合并进 config 的局部字段 + * @param delay 防抖毫秒数 + */ + const scheduleSave = useCallback( + (patch: ConfigPatch, delay = 500) => { + const tid = templateIdRef.current + if (!tid) return + if (timerRef.current) clearTimeout(timerRef.current) + // 累计补丁(同一周期内多次变化合并成一次写入) + pendingPatchRef.current = { ...(pendingPatchRef.current || {}), ...patch } + timerRef.current = setTimeout(() => { + void flush() + }, delay) + }, + [flush], + ) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + if (abortRef.current) abortRef.current.abort() + } + }, []) + + return { scheduleSave } +} + +export default useDraftAutoSave diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index 912fc6fe3..58d1c3b8e 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -34,7 +34,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { }, []) const { startPolling, clearTimer } = useGenerationPolling({ - templateId: selectedTemplate, onProgress: handleProgress, onComplete: handleComplete, onFailed: handleFailed, @@ -90,16 +89,20 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const assetIds = props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials + // 封面 URL:优先 AI 生成缩略图,兜底用户上传 + const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || "" + // 直接创建正式生成任务 - await createGenerationTask({ + const taskResp = await createGenerationTask({ template_id: selectedTemplate, asset_ids: assetIds, output_width: outputWidth, output_height: outputHeight, - cover_url: props.coverSettings?.upload_url || "", + cover_url: coverUrl, custom_title: props.titleSettings?.title || "", duration: props.duration || undefined, video_ratio: props.videoRatio, + ...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}), ...(props.titleSettings?.title ? { title_config: { @@ -116,7 +119,12 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { : {}), }) - startPolling() + // 从创建响应直接拿 task_id,改用新接口轮询 + const taskId = taskResp.items?.[0]?.id + if (!taskId) { + throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看") + } + startPolling(taskId) } catch (err: unknown) { console.error("[handleGenerate] 生成失败:", err) setGenerating(false) diff --git a/apps/web/src/pages/generate/hooks/useStep2Materials.ts b/apps/web/src/pages/generate/hooks/useStep2Materials.ts index 0d5a8c406..58d8fedc9 100755 --- a/apps/web/src/pages/generate/hooks/useStep2Materials.ts +++ b/apps/web/src/pages/generate/hooks/useStep2Materials.ts @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef } from "react" import { formatDuration } from "../utils/formatDuration" import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary" import { useSmartMatch } from "./step2-materials/useSmartMatch" +import { useDraftAutoSave } from "./useDraftAutoSave" interface UseStep2MaterialsProps { materialMode: "manual" | "auto" @@ -14,6 +15,8 @@ interface UseStep2MaterialsProps { onSelectedMaterialsChange: (ids: string[]) => void smartSelectedIds: string[] onSmartSelectedIdsChange: (ids: string[]) => void + /** 当前选中的模板/草稿 ID,用于自动保存 */ + selectedTemplate?: string } export function useStep2Materials({ @@ -23,6 +26,7 @@ export function useStep2Materials({ onSelectedMaterialsChange, smartSelectedIds, onSmartSelectedIdsChange, + selectedTemplate, }: UseStep2MaterialsProps) { const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } = useMaterialLibrary() @@ -53,6 +57,14 @@ export function useStep2Materials({ handleSmartMatch() }, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch]) + /* ── Step2 选择素材后自动保存草稿(防抖 500ms,失败静默) ── */ + const { scheduleSave } = useDraftAutoSave(selectedTemplate) + useEffect(() => { + if (!selectedTemplate) return + const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials + scheduleSave({ asset_ids: ids }, 500) + }, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave]) + /* ── 手动选择素材 ── */ const handleToggleMaterial = useCallback( (materialId: string) => { diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts index 3f6aaac81..c303d086c 100644 --- a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts +++ b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts @@ -4,17 +4,24 @@ import { getTitles } from "@/api/titles" import type { TitleSettings } from "../../types" import { useAiTitleGenerator } from "./useAiTitleGenerator" import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters" +import { useDraftAutoSave } from "../useDraftAutoSave" interface UseStep4TitleProps { titleSettings: TitleSettings onTitleSettingsChange: (settings: TitleSettings) => void + /** 当前选中的模板/草稿 ID,用于自动保存 */ + selectedTemplate?: string } /** * Step 4 标题设置 Hook * 封装 AI 标题生成、标题样式设置等逻辑 */ -export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) { +export function useStep4Title({ + titleSettings, + onTitleSettingsChange, + selectedTemplate, +}: UseStep4TitleProps) { // 标题库数据 const { data: userTitles = [] } = useQuery({ queryKey: ["titles"], @@ -42,6 +49,38 @@ export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4 const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect) const isFirstMount = useRef(true) + /* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */ + const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate) + useEffect(() => { + if (!selectedTemplate) return + scheduleTitleSave( + { + title: { + text: titleSettings.title, + font: titleSettings.font, + font_size: titleSettings.size, + color: titleSettings.color, + position: titleSettings.position, + bold: titleSettings.bold, + stroke: titleSettings.stroke, + shadow: titleSettings.shadow, + }, + }, + 800, + ) + }, [ + selectedTemplate, + titleSettings.title, + titleSettings.font, + titleSettings.size, + titleSettings.color, + titleSettings.position, + titleSettings.bold, + titleSettings.stroke, + titleSettings.shadow, + scheduleTitleSave, + ]) + // 当 AI 自动选择开关打开时,自动生成/选择一个标题填入 // 首次挂载时如果开关已经是 true 且无标题,也需要触发 useEffect(() => { diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 2cec825cf..f86141680 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -97,6 +97,20 @@ export function useStep6Cover({ const response = await generateCover(selectedTemplate, { asset_ids: assetIds, cover_type: "ai_frame", + ...(titleSettings?.title + ? { + title_config: { + text: titleSettings.title, + font: titleSettings.font, + font_size: titleSettings.size, + font_color: titleSettings.color, + position: titleSettings.position, + bold: titleSettings.bold, + stroke: titleSettings.stroke, + shadow: titleSettings.shadow, + }, + } + : {}), }) clearTimeout(timeoutId) const thumbnailUrl = response.cover?.image_url || "" @@ -205,6 +219,20 @@ export function useStep6Cover({ const retryResp = await generateCover(selectedTemplate, { asset_ids: assetIds, cover_type: "ai_frame", + ...(titleSettings?.title + ? { + title_config: { + text: titleSettings.title, + font: titleSettings.font, + font_size: titleSettings.size, + font_color: titleSettings.color, + position: titleSettings.position, + bold: titleSettings.bold, + stroke: titleSettings.stroke, + shadow: titleSettings.shadow, + }, + } + : {}), }) const retryUrl = retryResp.cover?.image_url || "" if (retryUrl) { diff --git a/apps/web/src/test/api/bgm.test.ts b/apps/web/src/test/api/bgm.test.ts index 3817c6dfa..3f4b23c25 100644 --- a/apps/web/src/test/api/bgm.test.ts +++ b/apps/web/src/test/api/bgm.test.ts @@ -32,7 +32,7 @@ describe("bgm API", () => { describe("getBgmPresets", () => { it("should resolve successfully", async () => { - await expect(getBgmPresets("test-params?")).resolves.not.toThrow() + await expect(getBgmPresets("test-template", { category: "test" })).resolves.not.toThrow() }) it("should reject on API error", async () => { @@ -42,7 +42,7 @@ describe("bgm API", () => { mockDelete.mockRejectedValue(new Error("Network error")) mockPatch.mockRejectedValue(new Error("Network error")) - await expect(getBgmPresets("test-params?")).rejects.toThrow() + await expect(getBgmPresets("test-template", { category: "test" })).rejects.toThrow() }) }) }) diff --git a/apps/web/src/test/api/edit-plans.test.ts b/apps/web/src/test/api/edit-plans.test.ts index e2327dd36..2186497dc 100644 --- a/apps/web/src/test/api/edit-plans.test.ts +++ b/apps/web/src/test/api/edit-plans.test.ts @@ -1,16 +1,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest" import { - getEditPlans, getEditPlan, - createEditPlan, updateEditPlan, - deleteEditPlan, generateEditPlan, getGenerationStatus, aiRecommendClips, getEditPlanGenerations, getGenerationTaskResults, - cancelGeneration, getEditPlanClips, getEditPlanClip, createEditPlanClip, @@ -19,7 +15,6 @@ import { reorderEditPlanClips, batchDeleteEditPlanClips, createClipsFromAssets, - copyEditPlan, getMediaAssets, getMediaAsset, } from "@/api/template-editor" @@ -53,22 +48,6 @@ describe("editPlans API", () => { mockPatch.mockResolvedValue({ data: { success: true, items: [] } }) }) - describe("getEditPlans", () => { - it("should resolve successfully", async () => { - await expect(getEditPlans("test-params?")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(getEditPlans("test-params?")).rejects.toThrow() - }) - }) - describe("getEditPlan", () => { it("should resolve successfully", async () => { await expect(getEditPlan("test-planId")).resolves.not.toThrow() @@ -85,22 +64,6 @@ describe("editPlans API", () => { }) }) - describe("createEditPlan", () => { - it("should resolve successfully", async () => { - await expect(createEditPlan({ name: "test-item" })).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(createEditPlan({ name: "test-item" })).rejects.toThrow() - }) - }) - describe("updateEditPlan", () => { it("should resolve successfully", async () => { await expect(updateEditPlan("test-planId")).resolves.not.toThrow() @@ -117,22 +80,6 @@ describe("editPlans API", () => { }) }) - describe("deleteEditPlan", () => { - it("should resolve successfully", async () => { - await expect(deleteEditPlan("test-planId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(deleteEditPlan("test-planId")).rejects.toThrow() - }) - }) - describe("generateEditPlan", () => { it("should resolve successfully", async () => { await expect(generateEditPlan("test-planId")).resolves.not.toThrow() @@ -213,22 +160,6 @@ describe("editPlans API", () => { }) }) - describe("cancelGeneration", () => { - it("should resolve successfully", async () => { - await expect(cancelGeneration("test-planId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(cancelGeneration("test-planId")).rejects.toThrow() - }) - }) - describe("getEditPlanClips", () => { it("should resolve successfully", async () => { await expect(getEditPlanClips("test-planId")).resolves.not.toThrow() @@ -357,22 +288,6 @@ describe("editPlans API", () => { }) }) - describe("copyEditPlan", () => { - it("should resolve successfully", async () => { - await expect(copyEditPlan("test-planId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(copyEditPlan("test-planId")).rejects.toThrow() - }) - }) - describe("getMediaAssets", () => { it("should resolve successfully", async () => { await expect(getMediaAssets("test-libraryId?")).resolves.not.toThrow() diff --git a/apps/web/src/test/api/templates.test.ts b/apps/web/src/test/api/templates.test.ts index 973093483..e5df7baaa 100644 --- a/apps/web/src/test/api/templates.test.ts +++ b/apps/web/src/test/api/templates.test.ts @@ -5,7 +5,6 @@ import { getTemplate, toggleFavoriteTemplate, copyTemplate, - generateFromTemplate, } from "@/api/templates" const mockGet = vi.fn() @@ -116,20 +115,4 @@ describe("templates API", () => { await expect(copyTemplate("test-templateId")).rejects.toThrow() }) }) - - describe("generateFromTemplate", () => { - it("should resolve successfully", async () => { - await expect(generateFromTemplate("test-templateId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(generateFromTemplate("test-templateId")).rejects.toThrow() - }) - }) }) diff --git a/apps/web/src/test/pages/EditingPlanner.test.tsx b/apps/web/src/test/pages/EditingPlanner.test.tsx index bdfb5f3e9..5a14b4acc 100644 --- a/apps/web/src/test/pages/EditingPlanner.test.tsx +++ b/apps/web/src/test/pages/EditingPlanner.test.tsx @@ -150,12 +150,10 @@ vi.mock("@/api/template-editor", () => ({ getMediaAssets: vi.fn().mockResolvedValue({ items: [] }), getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }), getEditPlan: vi.fn().mockResolvedValue({}), - createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }), updateEditPlan: vi.fn().mockResolvedValue({}), generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }), getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }), getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }), - cancelGeneration: vi.fn().mockResolvedValue({}), getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }), createEditPlanClip: vi.fn().mockResolvedValue({}), batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}), diff --git a/apps/web/src/test/pages/TemplateLibrary.test.tsx b/apps/web/src/test/pages/TemplateLibrary.test.tsx index 99c97942d..18a8f7fe5 100755 --- a/apps/web/src/test/pages/TemplateLibrary.test.tsx +++ b/apps/web/src/test/pages/TemplateLibrary.test.tsx @@ -109,7 +109,6 @@ vi.mock("@/api/templates", () => ({ getTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), toggleFavoriteTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), copyTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), - generateFromTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), })) vi.mock("@/pages/templates/TemplateLibrary.css", () => ({})) -- 2.54.0 From 9176284a78d0265dc708d1c87a9040587459929c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 23 Aug 2026 13:45:13 +0800 Subject: [PATCH 2/2] fix: auto-save failure preserves patch for retry; results fetch retries before failing - useDraftAutoSave: only clear pendingPatch after successful PUT; on GET/PUT failure merge patch back and retry with exponential backoff (max 5 retries), patch retained after exhaustion for next scheduleSave to pick up - useGenerationPolling: getGenerationTaskResults now retries up to 3 times before treating it as a failure; no longer silently returns empty list when result fetching fails --- .../generate-video/useGenerationPolling.ts | 36 +++++++++++--- .../pages/generate/hooks/useDraftAutoSave.ts | 49 ++++++++++++++----- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts index 54e9efe38..d65209263 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts @@ -13,6 +13,8 @@ interface UseGenerationPollingOptions { /** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */ const MAX_RETRYABLE_ERRORS = 10 +/** 获取结果的最大重试次数 */ +const MAX_RESULTS_RETRIES = 3 /** * 生成状态轮询 Hook(v2 — 改用 /generation/tasks/{task_id}) @@ -24,6 +26,7 @@ const MAX_RETRYABLE_ERRORS = 10 * 错误处理: * - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试 * - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次 + * - 任务完成后获取结果失败会重试 MAX_RESULTS_RETRIES 次,仍失败则 onFailed */ export const useGenerationPolling = ({ onProgress, @@ -41,6 +44,24 @@ export const useGenerationPolling = ({ } }, []) + /** 任务完成后拉取结果列表,带重试 */ + const fetchResultsWithRetry = useCallback( + async (taskId: string, attempt = 0): Promise => { + try { + return await getGenerationTaskResults(taskId) + } catch (err) { + if (cancelledRef.current) return null + console.error(`[获取生成结果失败] 第 ${attempt + 1} 次`, err) + if (attempt < MAX_RESULTS_RETRIES - 1) { + await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))) + return fetchResultsWithRetry(taskId, attempt + 1) + } + return null + } + }, + [], + ) + const startPolling = useCallback( (taskId: string) => { cancelledRef.current = false @@ -54,11 +75,14 @@ export const useGenerationPolling = ({ if (task.status === "completed") { onProgress(100) - let videos: unknown[] = [] - try { - videos = await getGenerationTaskResults(taskId) - } catch (err) { - console.error("[获取生成结果失败]", err) + const videos = await fetchResultsWithRetry(taskId) + if (cancelledRef.current) return + if (videos === null) { + const errorMsg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看" + console.error("[生成结果获取失败] taskId:", taskId) + onFailed(errorMsg) + message.error(errorMsg) + return } onComplete(videos) message.success("视频生成完成!") @@ -115,7 +139,7 @@ export const useGenerationPolling = ({ progressTimer.current = setTimeout(poll, 1500) }, - [onProgress, onComplete, onFailed], + [onProgress, onComplete, onFailed, fetchResultsWithRetry], ) return { startPolling, clearTimer } diff --git a/apps/web/src/pages/generate/hooks/useDraftAutoSave.ts b/apps/web/src/pages/generate/hooks/useDraftAutoSave.ts index 574055c5b..b924aeffe 100644 --- a/apps/web/src/pages/generate/hooks/useDraftAutoSave.ts +++ b/apps/web/src/pages/generate/hooks/useDraftAutoSave.ts @@ -8,6 +8,7 @@ * - 同一时刻只有一个保存请求在飞 * - 快速连续变化时只提交最后一次 * - 组件卸载时取消未完成请求 + * - 保存失败时保留补丁,自动重试(指数退避,最多 5 次) * * 保存失败只 console.warn,不弹窗、不阻塞。 */ @@ -16,6 +17,11 @@ import { getEditPlan, updateEditPlan } from "@/api/template-editor" type ConfigPatch = Record +/** 最大自动重试次数 */ +const MAX_RETRIES = 5 +/** 初始重试延迟(ms),每次翻倍 */ +const BASE_RETRY_DELAY = 1000 + export function useDraftAutoSave(templateId?: string) { const timerRef = useRef>() const abortRef = useRef(null) @@ -25,20 +31,19 @@ export function useDraftAutoSave(templateId?: string) { const templateIdRef = useRef(templateId) templateIdRef.current = templateId - const flush = useCallback(async () => { + const flush = useCallback(async (retryCount = 0) => { const tid = templateIdRef.current if (!tid) return // 已有保存在飞:把新补丁暂存,等当前请求结束后再合并一次 if (savingRef.current) return - savingRef.current = true - // 把暂存的所有补丁合到一起 - const patch = pendingPatchRef.current - pendingPatchRef.current = null - if (!patch) { + // 快照当前补丁,但先不清空 —— 成功后才清除,失败时保留以便重试 + const patchToSave = pendingPatchRef.current + if (!patchToSave) { savingRef.current = false return } + savingRef.current = true const controller = new AbortController() abortRef.current = controller @@ -46,18 +51,40 @@ export function useDraftAutoSave(templateId?: string) { // 1. 读当前 config(拿最新,避免覆盖别人/别的步骤写入的字段) const current = await getEditPlan(tid) if (controller.signal.aborted) return - const merged = { ...(current.config || {}), ...patch } + const merged = { ...(current.config || {}), ...patchToSave } // 2. 写回完整合并后的 config await updateEditPlan(tid, { config: merged }, controller.signal) + // 3. 保存成功才清除已保存的补丁 + // (保存期间可能有新补丁进来,只清除我们已经保存的部分) + pendingPatchRef.current = null } catch (err) { const name = (err as { name?: string })?.name - if (name !== "CanceledError" && name !== "AbortError") { - console.warn("[useDraftAutoSave] 自动保存草稿失败:", err) + if (name === "CanceledError" || name === "AbortError") { + // 组件卸载或新请求取消,不重试 + return } + console.warn("[useDraftAutoSave] 自动保存草稿失败:", err) + + // 保存失败:把本次尝试保存的补丁合并回 pendingPatchRef + // (保存期间可能有新补丁,新补丁优先) + pendingPatchRef.current = { + ...patchToSave, + ...(pendingPatchRef.current || {}), + } + + // 指数退避重试 + if (retryCount < MAX_RETRIES && !controller.signal.aborted) { + const delay = BASE_RETRY_DELAY * Math.pow(2, retryCount) + timerRef.current = setTimeout(() => { + void flush(retryCount + 1) + }, delay) + } + // 超过最大重试次数后,补丁仍保留在 pendingPatchRef 中, + // 下次 scheduleSave 触发时会一起带上 } finally { savingRef.current = false - // 如果保存期间又积累了新的变化,再触发一次 - if (pendingPatchRef.current && !controller.signal.aborted) { + // 保存期间又积累了新变化(且不是在重试路径中),再触发一次 + if (pendingPatchRef.current && !controller.signal.aborted && retryCount === 0) { timerRef.current = setTimeout(() => { void flush() }, 50) -- 2.54.0