@@ -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 ) = > {