Compare commits

...

1 Commits

Author SHA1 Message Date
Coze Agent 7a06ad52de fix(generate): 修复进度条卡56%不刷新P1 bug
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m27s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m57s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m22s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m11s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m40s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m44s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 14m36s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
根因分析:
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全通过
2026-09-27 23:12:30 +08:00
2 changed files with 295 additions and 55 deletions
@@ -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"
@@ -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<BatchTaskState>) => 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<Map<string, number>>(new Map())
/** 当前单任务轮询的 taskId(供 visibilitychange 快速调度) */
const activeTaskRef = useRef<string | null>(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<unknown[] | null> => {
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<unknown[]> => {
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<string, number>()
const resultMap = new Map<string, unknown[]>()
const failureMap = new Map<string, string>()
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) => {