feat: 任务轮询 + Token刷新容错 + 提交防抖 + 文件上传Hook (0.0.20)
### 任务状态轮询 - 新增 useTaskPolling hook:批量轮询非终态任务,递归 setTimeout + next_poll_ms 自适应间隔 - TaskHistory 集成批量轮询,OutputPreview 移除独立轮询改为状态转换监听 - batchTaskStatus 修复数组参数序列化(GET + 逗号拼接,避免 bracket 格式) - 统一 POLLING_STATUSES 常量管理非终态状态 ### Token 刷新容错 - 双轨刷新:主动续期(JWT exp 75% 时间点 setTimeout)+ 被动兜底(handle401) - 刷新失败分级:永久失败(throw RefreshTokenExpiredError)→清token弹登录;临时失败(return null)→仅拒绝当前请求 - AppProvider 登录/自动登录/退出 启停主动刷新定时器 ### 提交按钮防抖 - 2s 节流防抖("你点击的太快了")+ 二次确认弹窗("您已提交,确认要再次提交吗?") - 修复 useAsyncMutation.execute 返回值判空(原 try/catch 死代码) ### 文件上传 Hook - 新增 useFileUpload hook:封装 request.upload() 为 antd Upload 兼容 customRequest - ImageInput/ImageListInput/AudioListInput 集成 customRequest - uploadValidation beforeUpload 返回值修正(false→true) ### 其他 - AnnouncementDrawer 接入公告 API + EnumResolver - billing/announcement/banner 服务模块完善 - CLAUDE.md 补充架构约定文档
This commit is contained in:
@@ -12,9 +12,9 @@
|
||||
// - 用户可读错误信息(errorMessage)
|
||||
// ============================================================
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useMemo, useState, useRef, useEffect, useCallback} from 'react';
|
||||
import {useAppContext} from '@/components/AppProvider';
|
||||
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task';
|
||||
import { TaskAPI, POLLING_STATUSES, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody, type TaskStatusResponseBody, type TaskStatusString } from '@/services/modules/task';
|
||||
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||
import { AuthAPI, type UserInfoBody } from '@/services/modules/auth';
|
||||
@@ -22,6 +22,7 @@ import { VipAPI, type VipLevelsItem } from '@/services/modules/vip-api';
|
||||
import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/services/modules/billing';
|
||||
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
||||
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
|
||||
import { upload } from '@/services/request';
|
||||
|
||||
// ============================================================
|
||||
// Banner
|
||||
@@ -203,6 +204,160 @@ export function useSubmitTask(options?: {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 任务状态轮询
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 批量轮询非终态任务状态,返回合并后的任务列表。
|
||||
*
|
||||
* - 自动识别 tasks 中的非终态任务(pending / submitted / processing)
|
||||
* - 调用 batchTaskStatus 批量查询状态
|
||||
* - 使用服务端返回的 next_poll_ms(本地限制 2s ~ 30s)自适应间隔
|
||||
* - 递归 setTimeout 而非 setInterval(避免请求堆积)
|
||||
* - onTerminal 回调:当某个任务变为终态时触发(用于触发完整列表刷新)
|
||||
*/
|
||||
export function useTaskPolling(
|
||||
tasks: TaskInfoItemResponseBody[],
|
||||
onTerminal?: () => void,
|
||||
): TaskInfoItemResponseBody[] {
|
||||
const [merged, setMerged] = useState<TaskInfoItemResponseBody[]>(tasks);
|
||||
const mergedRef = useRef(merged);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// 保持 ref 与 state 同步,避免轮询闭包读到过期数据
|
||||
mergedRef.current = merged;
|
||||
|
||||
// 源数据变化时同步(服务端拉取的新数据覆盖本地合并结果)
|
||||
useEffect(() => {
|
||||
setMerged(tasks);
|
||||
}, [tasks]);
|
||||
|
||||
// 用 ref 稳定化 onTerminal 回调,避免 useEffect 因回调引用变化而重启
|
||||
const onTerminalRef = useRef(onTerminal);
|
||||
onTerminalRef.current = onTerminal;
|
||||
|
||||
// 轮询循环:当 tasks(源数据)变化时重启
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
// 检查源数据中是否有非终态任务
|
||||
const hasNonTerminal = tasks.some(
|
||||
t => POLLING_STATUSES.includes(t.status as TaskStatusString),
|
||||
);
|
||||
if (!hasNonTerminal) return;
|
||||
|
||||
const poll = async () => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
// 从最新 merged 中取非终态 ID(而非闭包捕获的旧值)
|
||||
const currentMerged = mergedRef.current;
|
||||
const ids = currentMerged
|
||||
.filter(t => POLLING_STATUSES.includes(t.status as TaskStatusString))
|
||||
.map(t => t.id);
|
||||
|
||||
if (ids.length === 0) return;
|
||||
|
||||
try {
|
||||
const result = await TaskAPI.batchTaskStatus({ task_ids: ids });
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const statusMap = new Map(
|
||||
result.items
|
||||
.filter((s): s is TaskStatusResponseBody => s !== null && s !== undefined)
|
||||
.map(s => [s.task_id, s]),
|
||||
);
|
||||
|
||||
let hasTerminal = false;
|
||||
|
||||
setMerged(prev => prev.map(task => {
|
||||
const st = statusMap.get(task.id);
|
||||
if (!st || st.status === task.status) return task;
|
||||
|
||||
const isNowTerminal = !POLLING_STATUSES.includes(st.status as TaskStatusString);
|
||||
if (isNowTerminal) hasTerminal = true;
|
||||
|
||||
return {
|
||||
...task,
|
||||
status: st.status,
|
||||
cost_cent: st.cost_cent ?? task.cost_cent,
|
||||
error_code: st.error_code ?? task.error_code,
|
||||
error_message: st.error_message ?? task.error_message,
|
||||
finished_at: st.finished_at ?? task.finished_at,
|
||||
};
|
||||
}));
|
||||
|
||||
if (hasTerminal) onTerminalRef.current?.();
|
||||
|
||||
// 检查合并后是否还有非终态任务(从 ref 读取最新值)
|
||||
const stillPending = mergedRef.current.some(
|
||||
t => POLLING_STATUSES.includes(t.status as TaskStatusString),
|
||||
);
|
||||
|
||||
if (stillPending && mountedRef.current) {
|
||||
const delay = Math.max(2000, Math.min(30000, result.next_poll_ms || 5000));
|
||||
timerRef.current = setTimeout(poll, delay);
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
// 出错后 10s 重试
|
||||
timerRef.current = setTimeout(poll, 10000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 首次轮询延迟 2s(给服务端缓冲时间)
|
||||
timerRef.current = setTimeout(poll, 2000);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [tasks]);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 文件上传
|
||||
// ============================================================
|
||||
|
||||
/** 上传接口路径(与后端 /api/v1/upload 对齐) */
|
||||
const UPLOAD_ACTION = '/api/v1/upload';
|
||||
|
||||
/**
|
||||
* 文件上传 Hook — 返回 antd Upload 组件兼容的 customRequest。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 复用 request.ts 的 upload() 函数(自动携带 Token、超时、错误处理)
|
||||
* - 通过 customRequest 集成到 antd Upload,而非直接设 action(避免 Token 缺失)
|
||||
* - 上传成功后 antd 自动将 response 写入 file.response,供表单提交时提取 URL
|
||||
*/
|
||||
export function useFileUpload() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const customRequest = useCallback((options: any) => {
|
||||
const file = options.file as File;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, options.filename || file.name);
|
||||
|
||||
upload<{ url: string }>(UPLOAD_ACTION, formData, (percent) => {
|
||||
options.onProgress?.({ percent });
|
||||
})
|
||||
.then((result) => {
|
||||
options.onSuccess?.(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
options.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { customRequest, uploadAction: UPLOAD_ACTION } as const;
|
||||
}
|
||||
|
||||
// ---------- 重新导出类型和工具 ----------
|
||||
|
||||
export {MODEL_CATEGORY_LABELS};
|
||||
|
||||
Reference in New Issue
Block a user