From 7a06ad52de5a5fc18633ca4858e6704d7260e401 Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Sun, 27 Sep 2026 23:12:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(generate):=20=E4=BF=AE=E5=A4=8D=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=9D=A1=E5=8D=A156%=E4=B8=8D=E5=88=B7=E6=96=B0P1=20b?= =?UTF-8?q?ug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因分析: 1. 后端任务状态新增/历史脏值(pending/queued/unknown等)未在前端 normalizeTaskStatus映射中,fallback分支既不更新progress也不终止 轮询,导致视觉停在上一次progress(约56%) 2. 旧版轮询网络/5xx错误连续10次即终止,浏览器tab切后台被节流或 短暂网络抖动后永久停止轮询,用户看到的是停滞的旧progress 3. 浏览器后台tab的setTimeout被节流,切回前台时不会主动拉取最新状态, 必须等下一个定时器才能看到结果 4. 生成完成后需手动点击「下一步」,用户误以为仍在渲染 修复: - 新增normalizeTaskStatus兼容pending/queued/scheduled/processing/ rendering/unknown/complete/done等状态,pending阶段显示≤5%进度, unknown状态保持上次进度继续轮询 - 网络/5xx错误改为指数退避无限重试(最大15s间隔),连续5次错误弹 一次提示;4xx才终止(任务不存在/未授权) - visibilitychange监听:页面从后台切回立即触发一次poll,秒级看到最新状态 - unmount时useEffect cleanup兜底清理所有定时器和控制器 - 进度停滞探测(15s无变化自动缩短到3s再查),防后端最后progress回调丢失 - awaiting_cover阶段results接口write-after-read抖动:空结果补1.5s后再查 - 终态前强制onProgress(100),避免UI停在95% - 生成完成(单视频finalVideo就绪/批量全部完成且至少一个成功)后延迟800ms 自动跳转到Step5封面选择页,无需手动点下一步 验证:tsc/eslint/prettier/vite build全通过 --- apps/web/src/pages/generate/GeneratePage.tsx | 43 +++ .../generate-video/useGenerationPolling.ts | 307 ++++++++++++++---- 2 files changed, 295 insertions(+), 55 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 5f59a0eed..e94f17923 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -494,6 +494,49 @@ const GeneratePage: React.FC = () => { navigate, ]) + /* ── 生成完成后自动跳转封面选择页(修复进度条卡 56% 看不到结果的体验问题) ── + 单视频:生成完成且有 finalVideo 时延迟 800ms 自动跳 step5 + 批量:所有任务完成(无 running 项)且至少有一个成功时延迟 800ms 跳转 + 失败/用户手动点重试时不触发(progress 回到 0 或 generating=false 且无 finalVideo) + */ + useEffect(() => { + if (currentStep !== 4) return + if (generating) return + if (generateError) return + if (!generated) return + + let shouldJump = false + if (isBatch) { + const allDone = + batchTasks.length > 0 && + batchTasks.every( + (t) => t.status === "completed" || t.status === "awaiting_cover" || t.status === "failed", + ) + const anySuccess = batchTasks.some( + (t) => t.status === "completed" || t.status === "awaiting_cover", + ) + shouldJump = allDone && anySuccess + } else { + shouldJump = !!(finalVideo || currentTaskId) + } + if (!shouldJump) return + + const t = setTimeout(() => { + if (currentStep === 4) setCurrentStep(5) + }, 800) + return () => clearTimeout(t) + }, [ + currentStep, + generating, + generateError, + generated, + isBatch, + batchTasks, + finalVideo, + currentTaskId, + setCurrentStep, + ]) + /* ── 布局 class ── */ const layoutClassName = "xx-generate-layout full-width" 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..275ac6d13 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" @@ -25,19 +25,80 @@ interface UseGenerationPollingOptions { onBatchTaskUpdate?: (taskId: string, patch: Partial) => void } -/** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */ -const MAX_RETRYABLE_ERRORS = 10 -/** 获取结果的最大重试次数 */ +/** 获取结果的最大重试次数(5xx/网络错误时;4xx 直接放弃) */ const MAX_RESULTS_RETRIES = 3 +/** 轮询基础间隔(毫秒) */ +const BASE_POLL_INTERVAL_MS = 2000 +/** 初始启动延迟 */ +const INITIAL_DELAY_MS = 1000 +/** 网络/5xx 错误时的退避基础间隔(指数退避,最大 15s) */ +const NETWORK_ERROR_BASE_MS = 2000 +const NETWORK_ERROR_MAX_MS = 15000 +/** 连续网络错误达到阈值弹一次提示(避免 toast 风暴) */ +const NETWORK_ERROR_NOTICE_THRESHOLD = 5 +/** 进度长时间无变化时,缩短轮询间隔主动探测,防止后端最后一次 progress 回调丢失 */ +const STALL_PROBE_MS = 15000 + /** - * 生成状态轮询 Hook(v4 — 批量任务独立状态 + 单任务重试) + * 把后端返回的任务状态字符串归一化到前端处理的状态。 + * 兼容历史脏数据/后端新增状态(pending/queued/scheduled 等),避免出现"未知状态→既不更新进度也不终止"的卡死。 + */ +function normalizeTaskStatus( + raw: string | undefined, +): "completed" | "awaiting_cover" | "failed" | "cancelled" | "running" | "pending" | "unknown" { + if (!raw) return "unknown" + const s = String(raw).trim().toLowerCase() + if ( + s === "completed" || + s === "success" || + s === "done" || + s === "finished" || + s === "complete" + ) { + return "completed" + } + if ( + s === "awaiting_cover" || + s === "waiting_cover" || + s === "pending_cover" || + s === "video_ready" || + s === "rendered" + ) { + return "awaiting_cover" + } + if (s === "failed" || s === "error" || s === "err" || s === "fail") return "failed" + if (s === "cancelled" || s === "canceled") return "cancelled" + if ( + s === "running" || + s === "processing" || + s === "in_progress" || + s === "rendering" || + s === "process" + ) { + return "running" + } + if (s === "pending" || s === "queued" || s === "scheduled") return "pending" + return "unknown" +} + +/** + * 生成状态轮询 Hook(v5 — 修复进度卡 56% 的 P1 bug) * - * startPolling(taskId) 轮询单个任务; - * startPollingBatch(tasks) 并行轮询 N 个任务: - * - 每个任务独立进度/状态/失败,通过 onBatchTaskUpdate 实时回传 - * - 全部成功才 onComplete(聚合视频按变体顺序);任一失败不影响其他任务继续 - * - retryTask(taskId) 单独重试失败任务(重新轮询,后端任务仍在跑则直接接续) + * v5 修复点(2026-09-27): + * 1. pending/queued/unknown 状态兼容:旧版只处理 completed/awaiting_cover/failed/cancelled/running, + * 任务刚入队(pending)或后端新增状态时会走到 fallback 分支,但 progress 可能停在上一次值, + * 视觉上"卡"在 56%。新版本 pending 显示 ≤5%,unknown 保持上次进度继续轮询,不阻断。 + * 2. 网络/5xx 错误无限重试+指数退避:旧版连续 10 次错误就终止轮询。新版本只在 4xx(任务不存在/ + * 未授权)时终止,其他错误指数退避重试,连续错误达到阈值弹一次"网络不稳定"提示。 + * 3. 页面可见性恢复主动 poll:浏览器后台 tab 会被节流 setTimeout,用户切回标签立即触发一次 poll, + * 无需等下一个定时器,感知"秒跳"到完成状态。 + * 4. 进度停滞探测:若 STALL_PROBE_MS 内进度无变化但任务仍 running,缩短到 3s 主动再查一次, + * 防止后端最后一次 progress/status 更新丢失。 + * 5. unmount 时清理所有定时器:useEffect cleanup 兜底,避免组件卸载后残留定时器触发 setState 警告。 + * 6. 终态前强制推 100%:completed/awaiting_cover 分支先 onProgress(100) 再 onComplete,避免 UI 停在 95%。 + * 7. 终态 results 接口抖动兜底:awaiting_cover 时 results 可能因 write-after-read 返回空, + * 补一次 1.5s 后的重试再判定失败。 */ export function useGenerationPolling({ onProgress, @@ -49,21 +110,93 @@ export function useGenerationPolling({ const cancelledRef = useRef(false) /** 批量任务上下文:taskId → 变体序号 */ const batchContextRef = useRef>(new Map()) + /** 当前单任务轮询的 taskId(供 visibilitychange 快速调度) */ + const activeTaskRef = useRef(null) + /** 当前批量轮询的 tasks(供 visibilitychange 全量触发一次) */ + const activeBatchRef = useRef<{ taskId: string; variantIndex: number }[]>([]) + /** 批量下每个 task 独立的调度控制对象 */ + const batchControllersRef = useRef< + Map< + string, + { + done: boolean + schedule: (delay: number) => void + } + > + >(new Map()) + /** 单任务 controller(供 visibilitychange 使用) */ + const singleTaskScheduleRef = useRef<{ done: boolean; schedule: (delay: number) => void } | null>( + null, + ) const [, forceTick] = useState(0) - const clearTimer = useCallback(() => { - cancelledRef.current = true + const _clearAllTimers = useCallback(() => { progressTimer.current.forEach((t) => clearTimeout(t)) progressTimer.current = [] }, []) - /** 任务完成后拉取结果列表,带重试 */ + const clearTimer = useCallback(() => { + cancelledRef.current = true + _clearAllTimers() + batchControllersRef.current.clear() + singleTaskScheduleRef.current = null + activeTaskRef.current = null + activeBatchRef.current = [] + }, [_clearAllTimers]) + + /** unmount 兜底清理 */ + useEffect(() => { + const controllers = batchControllersRef + const singleCtrl = singleTaskScheduleRef + return () => { + cancelledRef.current = true + _clearAllTimers() + controllers.current.clear() + singleCtrl.current = null + } + }, [_clearAllTimers]) + + /** 页面从后台切回时,立即触发一次 poll(避免浏览器后台 tab 节流导致的延迟) */ + useEffect(() => { + const handleVisibility = () => { + if (document.visibilityState !== "visible") return + if (cancelledRef.current) return + // 单任务 + if ( + activeTaskRef.current && + singleTaskScheduleRef.current && + !singleTaskScheduleRef.current.done + ) { + const t = setTimeout(() => { + singleTaskScheduleRef.current?.schedule(0) + }, 0) + progressTimer.current.push(t) + } + // 批量 + activeBatchRef.current.forEach(({ taskId }) => { + const ctrl = batchControllersRef.current.get(taskId) + if (ctrl && !ctrl.done) { + const t = setTimeout(() => ctrl.schedule(0), 0) + progressTimer.current.push(t) + } + }) + } + document.addEventListener("visibilitychange", handleVisibility) + return () => document.removeEventListener("visibilitychange", handleVisibility) + }, []) + + /** 任务完成后拉取结果列表,带重试(网络/5xx 重试,4xx 直接放弃) */ const fetchResultsWithRetry = useCallback( async (taskId: string, attempt = 0): Promise => { try { return await getGenerationTaskResults(taskId) } catch (err) { if (cancelledRef.current) return null + const status = axios.isAxiosError(err) ? err.response?.status : undefined + if (status && status >= 400 && status < 500) { + console.warn(`[获取结果4xx,放弃重试] taskId=${taskId} status=${status}`) + return null + } console.error(`[获取生成结果失败] 第 ${attempt + 1} 次`, err) if (attempt < MAX_RESULTS_RETRIES - 1) { await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))) @@ -87,8 +220,6 @@ export function useGenerationPolling({ /** * 轮询单个任务。 - * - isBatch=true:状态变化通过 onBatchTaskUpdate 回传,不触发整体 onProgress/onComplete - * - resolve(videos) 成功;reject(Error) 失败 */ const pollSingleTask = useCallback( ( @@ -101,8 +232,54 @@ export function useGenerationPolling({ }, ): Promise => { return new Promise((resolve, reject) => { - let consecutiveErrors = 0 let done = false + let consecutiveErrors = 0 + let lastProgress = 0 + let lastProgressAt = Date.now() + let hasNoticedNetworkError = false + + const finish = (videos: unknown[], taskStatus: "completed" | "awaiting_cover") => { + if (done) return + done = true + callbacks?.onTaskProgress?.(100) + if (!callbacks && runId === 0) { + onProgress(100) + } + callbacks?.onTaskCompleted?.(videos, taskStatus) + resolve(videos) + } + + const fail = (msg: string) => { + if (done) return + done = true + callbacks?.onTaskFailed?.(msg) + reject(new Error(msg)) + } + + const scheduleNext = (delay: number) => { + if (cancelledRef.current || done) return + const timeSinceProgress = Date.now() - lastProgressAt + // 停滞探测:长时间进度不变且是正常轮询间隔,缩短到 3s 主动再查一次 + const actualDelay = + delay === BASE_POLL_INTERVAL_MS && timeSinceProgress > STALL_PROBE_MS ? 3000 : delay + const timer = setTimeout(poll, actualDelay) + progressTimer.current.push(timer) + } + + // 暴露 schedule 给 visibilitychange 使用 + const scheduleApi = { + get done() { + return done + }, + schedule: (d: number) => { + if (!done) scheduleNext(d) + }, + } + if (!callbacks && runId === 0) { + singleTaskScheduleRef.current = scheduleApi + } else { + batchControllersRef.current.set(taskId, scheduleApi) + } const poll = async () => { if (cancelledRef.current || done) return @@ -111,65 +288,75 @@ export function useGenerationPolling({ if (cancelledRef.current || done) return consecutiveErrors = 0 - if (task.status === "completed" || task.status === "awaiting_cover") { - done = true - const videos = await fetchResultsWithRetry(taskId) + const status = normalizeTaskStatus(task.status) + + if (status === "completed" || status === "awaiting_cover") { + let videos = await fetchResultsWithRetry(taskId) if (cancelledRef.current) return - if (videos === null) { - const msg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看" - callbacks?.onTaskFailed?.(msg) - reject(new Error(msg)) + // results 为空时补一次短延迟重试(write-after-read 抖动) + if ((!videos || videos.length === 0) && status === "awaiting_cover") { + await new Promise((r) => setTimeout(r, 1500)) + if (cancelledRef.current || done) return + videos = await fetchResultsWithRetry(taskId) + } + if (videos === null || videos.length === 0) { + const msg = "视频已生成,但获取结果列表失败,请刷新页面或稍后在任务列表查看" + fail(msg) return } - callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover") - resolve(videos) + finish(videos, status as "completed" | "awaiting_cover") return } - if (task.status === "failed" || task.status === "cancelled") { - done = true + if (status === "failed" || status === "cancelled") { const rawMsg = task.error_info?.error_message || task.error_message || - (task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试") + (status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试") const msg = safeExtractError(rawMsg) - callbacks?.onTaskFailed?.(msg) - reject(new Error(msg)) + fail(msg) return } - const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0))) + // running / pending / unknown:继续轮询 + const rawPct = Number(task.progress) || 0 + const pct = + status === "pending" + ? Math.max(0, Math.min(5, Math.round(rawPct))) + : Math.max(0, Math.min(99, Math.round(rawPct))) + if (pct !== lastProgress) { + lastProgress = pct + lastProgressAt = Date.now() + } callbacks?.onTaskProgress?.(pct) if (!callbacks && runId === 0) { onProgress(pct) } - const timer = setTimeout(poll, 2000) - progressTimer.current.push(timer) + scheduleNext(BASE_POLL_INTERVAL_MS) } catch (pollErr) { if (cancelledRef.current || done) return console.error("[轮询出错] taskId:", taskId, pollErr) const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined if (status && status >= 400 && status < 500) { - done = true const msg = extractErrorMessage(pollErr, status) - callbacks?.onTaskFailed?.(msg) - reject(new Error(msg)) + fail(msg) return } + // 网络/5xx:指数退避重试,永不终止 consecutiveErrors += 1 - if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) { - done = true - const msg = "任务状态查询连续失败,请稍后在任务列表查看结果" - callbacks?.onTaskFailed?.(msg) - reject(new Error(msg)) - return + if (consecutiveErrors >= NETWORK_ERROR_NOTICE_THRESHOLD && !hasNoticedNetworkError) { + hasNoticedNetworkError = true + message.warning("网络不稳定,正在继续查询任务状态…") } - const timer = setTimeout(poll, 3000) - progressTimer.current.push(timer) + const backoff = Math.min( + NETWORK_ERROR_BASE_MS * Math.pow(1.5, Math.min(consecutiveErrors - 1, 6)), + NETWORK_ERROR_MAX_MS, + ) + scheduleNext(backoff) } } - const timer = setTimeout(poll, 1500) + const timer = setTimeout(poll, INITIAL_DELAY_MS) progressTimer.current.push(timer) }) }, @@ -180,38 +367,49 @@ export function useGenerationPolling({ const startPolling = useCallback( (taskId: string) => { cancelledRef.current = false + _clearAllTimers() batchContextRef.current.clear() + batchControllersRef.current.clear() + activeTaskRef.current = taskId + activeBatchRef.current = [] + singleTaskScheduleRef.current = null pollSingleTask(taskId, 0) .then((videos) => { + activeTaskRef.current = null + singleTaskScheduleRef.current = null if (cancelledRef.current) return onProgress(100) onComplete(videos) message.success("视频生成完成!") }) .catch((err: Error) => { + activeTaskRef.current = null + singleTaskScheduleRef.current = null if (cancelledRef.current) return console.error("[生成失败] taskId:", taskId, err.message) onFailed(err.message) message.error(err.message) }) }, - [pollSingleTask, onProgress, onComplete, onFailed], + [pollSingleTask, onProgress, onComplete, onFailed, _clearAllTimers], ) /** - * 批量多任务轮询: - * - 每个任务独立进度/状态回传 onBatchTaskUpdate - * * 全部完成后按变体顺序聚合视频 onComplete - * - 部分失败:整体不 onFailed(第5步逐卡片展示失败+重试按钮);全部失败才 onFailed + * 批量多任务轮询 */ const startPollingBatch = useCallback( (tasks: { taskId: string; variantIndex: number }[]) => { cancelledRef.current = false + _clearAllTimers() + batchControllersRef.current.clear() + singleTaskScheduleRef.current = null const runId = Date.now() const progressMap = new Map() const resultMap = new Map() const failureMap = new Map() batchContextRef.current = new Map(tasks.map((t) => [t.taskId, t.variantIndex])) + activeBatchRef.current = [...tasks] + activeTaskRef.current = null const reportAggregateProgress = () => { if (cancelledRef.current) return @@ -228,7 +426,6 @@ export function useGenerationPolling({ onComplete(ordered) message.success(`全部 ${tasks.length} 个视频生成完成!`) } else if (resultMap.size > 0) { - // 部分失败:成功的视频聚合进成片列表(可进封面),失败卡片带重试按钮 onProgress(100) const ordered = tasks .filter((t) => resultMap.has(t.taskId)) @@ -273,14 +470,14 @@ export function useGenerationPolling({ checkAllSettled() }, }).catch(() => { - // 失败已在 onTaskFailed 处理,这里吞掉 Promise rejection + // 失败已在 onTaskFailed 处理 }) }) }, - [pollSingleTask, onProgress, onComplete, onFailed, onBatchTaskUpdate], + [pollSingleTask, onProgress, onComplete, onFailed, onBatchTaskUpdate, _clearAllTimers], ) - /** 单独重试失败任务(第5步卡片「重试此视频」):先调后端重试接口,再轮询 */ + /** 单独重试失败任务(第5步卡片「重试此视频」) */ const retryTask = useCallback( async (taskId: string) => { if (cancelledRef.current) cancelledRef.current = false @@ -289,9 +486,9 @@ export function useGenerationPolling({ try { await retryGenerationTaskApi(taskId) } catch (err) { - // 后端不支持重试或任务不可重试:直接重新轮询(任务可能已被自动恢复) console.warn("[重试任务接口调用失败,改为直接轮询]", err) } + batchControllersRef.current.delete(taskId) pollSingleTask(taskId, Date.now(), { onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }), onTaskCompleted: (videos, taskStatus) => { -- 2.54.0