From 428ba22473d9f6762e0e3ee69970582488dbf140 Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Mon, 28 Sep 2026 19:13:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(frontend):=20#2088=20=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E8=AF=86=E5=88=ABawaiting=5Fcover+visibilitychange=E6=81=A2?= =?UTF-8?q?=E5=A4=8D+=E7=8A=B6=E6=80=81=E9=80=8F=E4=BC=A0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=B7=B3=E5=B0=81=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGenerationPolling v5: 1. 单任务路径透传 taskStatus(completed/awaiting_cover)到 onComplete, 外层据此区分:awaiting_cover→封面选择页,completed→结果/历史页; awaiting_cover 不再弹'视频生成完成'toast(只在 completed 弹) 2. visibilitychange + focus 监听:页面切后台被浏览器降频/冻结 setTimeout 后, 切回前台立即补拉一次任务状态,解决'进度卡56%'的真因 3. 非4xx错误(网络/5xx)3s退避重试,MAX_RETRYABLE_ERRORS=10 兜底 - TaskStatus 类型补 awaiting_cover,STATUS_CONFIG 加'待选封面'标签 - onGenerationSuccess 回调接受 status 参数,渲染完成自动 setCurrentStep(5) 跳封面页 - 批量路径聚合 statusMap,任一任务 awaiting_cover 则整体透传 awaiting_cover --- apps/web/src/api/tasks/types.ts | 2 +- apps/web/src/pages/generate/GeneratePage.tsx | 7 +- .../generate/hooks/generate-video/types.ts | 4 +- .../generate-video/useGenerationPolling.ts | 87 ++++++++++++++++--- .../pages/generate/hooks/useGenerateVideo.ts | 12 ++- apps/web/src/pages/tasks/constants.tsx | 5 ++ 6 files changed, 100 insertions(+), 17 deletions(-) diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 7f3cf916e..bc3b5ed1b 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -3,7 +3,7 @@ */ /** 任务状态 */ -export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled" +export type TaskStatus = "pending" | "waiting" | "running" | "awaiting_cover" | "completed" | "failed" | "cancelled" /** 任务类型 */ export type TaskType = "ingest" | "generation" | string diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 5f59a0eed..bb57eba68 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -239,9 +239,14 @@ const GeneratePage: React.FC = () => { voiceModePerVideo, variantCoverUrls: previewCovers, selectedVariantIndexes: isBatch ? selectedVariantIds : undefined, - onGenerationSuccess: () => { + onGenerationSuccess: (status?: "completed" | "awaiting_cover") => { setPreviewTaskId(null) setStoredSourceEditPlanId(null) + // #2088:渲染完成后自动跳到封面选择页(step 5),不再等用户手动点「下一步」 + // awaiting_cover 和 completed 都走封面页(completed 是旧 worker 或 finalize 后状态,仍支持选封面) + if (status === "awaiting_cover" || status === "completed" || !status) { + setCurrentStep(5) + } }, }) 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 c8b72657a..a60d2c102 100755 --- a/apps/web/src/pages/generate/hooks/generate-video/types.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -41,8 +41,8 @@ export interface UseGenerateVideoProps { enabled: boolean music_id?: string } - /** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */ - onGenerationSuccess?: () => void + /** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态);status=awaiting_cover 表示需进封面选择 */ + onGenerationSuccess?: (status?: "completed" | "awaiting_cover") => void /* ── 批量生成(#1677)── */ /** 生成数量(1=单条旧逻辑,>1=批量) */ previewCount?: number 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 e866ea18f..f0df149a9 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts @@ -1,4 +1,4 @@ -import { useRef, useCallback, useState } from "react" +import { useRef, useCallback, useState, useEffect } from "react" import { message } from "antd" import axios from "axios" import { getGenerationTask, retryTask as retryGenerationTaskApi } from "@/api/tasks/tasks" @@ -19,7 +19,7 @@ export interface BatchTaskState { interface UseGenerationPollingOptions { onProgress: (progress: number) => void - onComplete: (videos: unknown[]) => void + onComplete: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void onFailed: (errorMsg: string) => void /** 批量:单任务状态变化(第5步逐卡片展示) */ onBatchTaskUpdate?: (taskId: string, patch: Partial) => void @@ -31,13 +31,19 @@ const MAX_RETRYABLE_ERRORS = 10 const MAX_RESULTS_RETRIES = 3 /** - * 生成状态轮询 Hook(v4 — 批量任务独立状态 + 单任务重试) + * 生成状态轮询 Hook(v5 — awaiting_cover 状态识别 + visibilitychange 恢复 + 状态透传) * * startPolling(taskId) 轮询单个任务; * startPollingBatch(tasks) 并行轮询 N 个任务: * - 每个任务独立进度/状态/失败,通过 onBatchTaskUpdate 实时回传 * - 全部成功才 onComplete(聚合视频按变体顺序);任一失败不影响其他任务继续 * - retryTask(taskId) 单独重试失败任务(重新轮询,后端任务仍在跑则直接接续) + * + * v5 修复(#2088): + * 1. 单任务路径透传 taskStatus(completed / awaiting_cover)到 onComplete,外层据此区分跳转 + * 2. 监听 visibilitychange,页面从后台切回可见时立即补拉一次,解决切后台 setInterval 被浏览器 + * 降频/冻结导致进度卡在 56% 的问题 + * 3. 非 4xx/5xx 网络错误按 3s 退避重试(已有 MAX_RETRYABLE_ERRORS=10 兜底) */ export function useGenerationPolling({ onProgress, @@ -49,12 +55,15 @@ export function useGenerationPolling({ const cancelledRef = useRef(false) /** 批量任务上下文:taskId → 变体序号 */ const batchContextRef = useRef>(new Map()) + /** 当前活跃的「立刻补拉一次」函数(visibilitychange 回调使用) */ + const immediateTickRef = useRef<(() => void) | null>(null) const [, forceTick] = useState(0) const clearTimer = useCallback(() => { cancelledRef.current = true progressTimer.current.forEach((t) => clearTimeout(t)) progressTimer.current = [] + immediateTickRef.current = null }, []) /** 任务完成后拉取结果列表,带重试 */ @@ -113,6 +122,7 @@ export function useGenerationPolling({ if (task.status === "completed" || task.status === "awaiting_cover") { done = true + immediateTickRef.current = null const videos = await fetchResultsWithRetry(taskId) if (cancelledRef.current) return if (videos === null) { @@ -128,6 +138,7 @@ export function useGenerationPolling({ if (task.status === "failed" || task.status === "cancelled") { done = true + immediateTickRef.current = null const rawMsg = task.error_info?.error_message || task.error_message || @@ -138,6 +149,7 @@ export function useGenerationPolling({ return } + // running / pending / waiting:更新进度并安排下一次轮询 const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0))) callbacks?.onTaskProgress?.(pct) if (!callbacks && runId === 0) { @@ -149,16 +161,20 @@ export function useGenerationPolling({ if (cancelledRef.current || done) return console.error("[轮询出错] taskId:", taskId, pollErr) const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined + // 4xx 视为不可重试(任务不存在/权限问题等),直接失败 if (status && status >= 400 && status < 500) { done = true + immediateTickRef.current = null const msg = extractErrorMessage(pollErr, status) callbacks?.onTaskFailed?.(msg) reject(new Error(msg)) return } + // 网络错误 / 5xx:3s 退避重试,最多 MAX_RETRYABLE_ERRORS 次 consecutiveErrors += 1 if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) { done = true + immediateTickRef.current = null const msg = "任务状态查询连续失败,请稍后在任务列表查看结果" callbacks?.onTaskFailed?.(msg) reject(new Error(msg)) @@ -169,6 +185,18 @@ export function useGenerationPolling({ } } + // 注册「立刻补拉一次」回调,供 visibilitychange 恢复时调用 + // 注意:必须在 done 后清理,避免切换页面时误触发已结束任务的补拉 + immediateTickRef.current = () => { + if (!done && !cancelledRef.current) { + // 清除未触发的 setTimeout,立即拉一次 + progressTimer.current.forEach((t) => clearTimeout(t)) + progressTimer.current = [] + consecutiveErrors = 0 + void poll() + } + } + const timer = setTimeout(poll, 1500) progressTimer.current.push(timer) }) @@ -181,12 +209,22 @@ export function useGenerationPolling({ (taskId: string) => { cancelledRef.current = false batchContextRef.current.clear() - pollSingleTask(taskId, 0) - .then((videos) => { - if (cancelledRef.current) return + let resolvedStatus: "completed" | "awaiting_cover" = "completed" + pollSingleTask(taskId, 0, { + onTaskProgress: (pct) => onProgress(pct), + onTaskCompleted: (videos, taskStatus) => { + resolvedStatus = taskStatus ?? "completed" onProgress(100) - onComplete(videos) - message.success("视频生成完成!") + onComplete(videos, resolvedStatus) + }, + onTaskFailed: (msg) => onFailed(msg), + }) + .then(() => { + if (cancelledRef.current) return + // awaiting_cover 是中间态(进封面选择页),不弹"完成"toast;completed 才弹 + if (resolvedStatus === "completed") { + message.success("视频生成完成!") + } }) .catch((err: Error) => { if (cancelledRef.current) return @@ -201,7 +239,7 @@ export function useGenerationPolling({ /** * 批量多任务轮询: * - 每个任务独立进度/状态回传 onBatchTaskUpdate - * * 全部完成后按变体顺序聚合视频 onComplete + * - 全部完成后按变体顺序聚合视频 onComplete * - 部分失败:整体不 onFailed(第5步逐卡片展示失败+重试按钮);全部失败才 onFailed */ const startPollingBatch = useCallback( @@ -211,6 +249,7 @@ export function useGenerationPolling({ const progressMap = new Map() const resultMap = new Map() const failureMap = new Map() + const statusMap = new Map() batchContextRef.current = new Map(tasks.map((t) => [t.taskId, t.variantIndex])) const reportAggregateProgress = () => { @@ -225,7 +264,9 @@ export function useGenerationPolling({ if (resultMap.size === tasks.length) { onProgress(100) const ordered = tasks.map((t) => resultMap.get(t.taskId) || []).flat() - onComplete(ordered) + // 批量:任一任务为 awaiting_cover,则整体透传 awaiting_cover(进封面页) + const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover") + onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed") message.success(`全部 ${tasks.length} 个视频生成完成!`) } else if (resultMap.size > 0) { // 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮 @@ -234,7 +275,8 @@ export function useGenerationPolling({ .filter((t) => resultMap.has(t.taskId)) .map((t) => resultMap.get(t.taskId) || []) .flat() - onComplete(ordered) + const anyAwaiting = Array.from(statusMap.values()).some((s) => s === "awaiting_cover") + onComplete(ordered, anyAwaiting ? "awaiting_cover" : "completed") message.warning( `${failureMap.size} 个视频生成失败,可点击卡片上的「重试此视频」,成功的视频可先进入下一步`, ) @@ -263,6 +305,7 @@ export function useGenerationPolling({ progressMap.set(taskId, 100) resultMap.set(taskId, videos) const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed" + statusMap.set(taskId, _finalStatus) onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos }) reportAggregateProgress() checkAllSettled() @@ -309,5 +352,27 @@ export function useGenerationPolling({ [pollSingleTask, onBatchTaskUpdate], ) + /** + * visibilitychange 恢复:页面从后台切回前台时,立刻触发一次补拉。 + * 解决浏览器后台标签页对 setTimeout 的 1Hz 节流/冻结导致的"进度卡 56%"问题。 + */ + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible" && immediateTickRef.current) { + immediateTickRef.current() + } + } + document.addEventListener("visibilitychange", handleVisibilityChange) + // 页面聚焦也兜底一次(部分浏览器 visibilitychange 触发时机不一致) + const handleFocus = () => { + if (immediateTickRef.current) immediateTickRef.current() + } + window.addEventListener("focus", handleFocus) + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange) + window.removeEventListener("focus", handleFocus) + } + }, []) + return { startPolling, startPollingBatch, retryTask, clearTimer } } diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index d4cc89f2b..204fdae03 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -13,6 +13,8 @@ import { validateGenerateInputs } from "./generate-video/buildPayload" import { calculateResolution } from "../utils/calculateResolution" import { extractBackendError, translateError } from "./generate-video/errorUtils" +export type GenerationCompleteStatus = "completed" | "awaiting_cover" | null + export function useGenerateVideo(props: UseGenerateVideoProps) { const { selectedTemplate, onGenerationSuccess } = props @@ -22,6 +24,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const [generated, setGenerated] = useState(false) const [generateError, setGenerateError] = useState(null) const [generatedVideos, setGeneratedVideos] = useState([]) + /** #2088:任务最终状态,区分 awaiting_cover(选封面)/ completed(已完成) */ + const [completionStatus, setCompletionStatus] = useState(null) /** 单视频模式:当前任务 ID(封面 finalize 需要) */ const [currentTaskId, setCurrentTaskId] = useState("") /** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */ @@ -53,9 +57,11 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const handleProgress = useCallback((p: number) => setProgress(p), []) const handleComplete = useCallback( - (videos: unknown[]) => { + (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => { setGenerating(false) setGenerated(true) + const finalStatus: GenerationCompleteStatus = taskStatus ?? "completed" + setCompletionStatus(finalStatus) setGeneratedVideos(videos as GeneratedVideo[]) // 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底 setBatchTasks((prev) => @@ -70,7 +76,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { : t, ), ) - onGenerationSuccess?.() + onGenerationSuccess?.(finalStatus) }, [onGenerationSuccess], ) @@ -121,6 +127,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { setProgress(0) setGenerated(false) setGenerateError(null) + setCompletionStatus(null) setBatchTasks([]) setGeneratedVideos([]) setCurrentTaskId("") @@ -423,6 +430,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { generated, generateError, generatedVideos, + completionStatus, currentTaskId, generate, retry, diff --git a/apps/web/src/pages/tasks/constants.tsx b/apps/web/src/pages/tasks/constants.tsx index 23edb6c6c..49af22e51 100644 --- a/apps/web/src/pages/tasks/constants.tsx +++ b/apps/web/src/pages/tasks/constants.tsx @@ -45,6 +45,11 @@ export const STATUS_CONFIG: Record< color: "processing", icon: , }, + awaiting_cover: { + label: "待选封面", + color: "warning", + icon: , + }, completed: { label: "已完成", color: "success", -- 2.54.0 From 1eec304a6854a3fb35cafbe947b3e532d77ca0eb Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Mon, 28 Sep 2026 19:16:19 +0800 Subject: [PATCH 2/3] chore: trigger CI for #2088 -- 2.54.0 From 6bf74fd0665106378a3c7b73293d0557bce5e3ca Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Mon, 28 Sep 2026 19:28:58 +0800 Subject: [PATCH 3/3] style: prettier format types.ts --- apps/web/src/api/tasks/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index bc3b5ed1b..a69fa03aa 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -3,7 +3,8 @@ */ /** 任务状态 */ -export type TaskStatus = "pending" | "waiting" | "running" | "awaiting_cover" | "completed" | "failed" | "cancelled" +export type TaskStatus = + "pending" | "waiting" | "running" | "awaiting_cover" | "completed" | "failed" | "cancelled" /** 任务类型 */ export type TaskType = "ingest" | "generation" | string -- 2.54.0