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:
2026-06-12 19:03:36 +08:00
parent 4237dcbaf6
commit 9e8c5b7109
22 changed files with 787 additions and 149 deletions

View File

@@ -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};