### 任务状态轮询 - 新增 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 补充架构约定文档
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
// ============================================================
|
||
// use-api.ts — 业务 API Hook 集合
|
||
//
|
||
// 基于 useAsyncData / useAsyncMutation 封装各模块 API,
|
||
// 组件只需调用 Hook 获取 { data, loading, error } 三元组,
|
||
// 不再需要在组件内手写 useEffect + cancelled flag + useState。
|
||
//
|
||
// 每个 Hook 自动处理:
|
||
// - 登录状态门控(enabled: isLoggedIn)
|
||
// - 竞态条件
|
||
// - 错误日志
|
||
// - 用户可读错误信息(errorMessage)
|
||
// ============================================================
|
||
|
||
import {useMemo, useState, useRef, useEffect, useCallback} from 'react';
|
||
import {useAppContext} from '@/components/AppProvider';
|
||
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';
|
||
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
|
||
// ============================================================
|
||
|
||
/** 首页轮播图数据 Hook */
|
||
export function useBannerList() {
|
||
const {isLoggedIn} = useAppContext();
|
||
|
||
return useAsyncData<Banner[]>(
|
||
(signal) => fetchBanners(signal),
|
||
[],
|
||
{enabled: isLoggedIn, label: 'banner-list'},
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 模型
|
||
// ============================================================
|
||
|
||
/** 模型列表数据 Hook(缓存优先 + 后台刷新)
|
||
*
|
||
* 策略:
|
||
* 1. 启动时同步读取加密缓存 → 立即展示(避免每次打开都 loading)
|
||
* 2. 异步拉取最新模型列表 → 覆盖缓存数据
|
||
* 3. 拉取成功后将最新数据加密写入 localStorage
|
||
*
|
||
* 因后端 category 查询参数实际不可用(传参返回空列表),
|
||
* 改为不带分类参数一次拉取全部,再由客户端根据响应中的
|
||
* category_name 字段手动分组。 */
|
||
export function useModelList() {
|
||
const {isLoggedIn} = useAppContext();
|
||
|
||
// 缓存即时数据(首次 render 同步读取,后续 API 返回后覆盖)
|
||
const [instantData, setInstantData] = useState<ModelsListResponseBody[] | null>(() =>
|
||
getCachedModels(),
|
||
);
|
||
|
||
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
|
||
async (signal) => {
|
||
const models = await ModelAPI.fetchModels({page: 1, page_size: 200}, signal);
|
||
// 异步写入加密缓存(best-effort,不影响数据流)
|
||
setCachedModels(models).catch(() => {});
|
||
setInstantData(models);
|
||
return models;
|
||
},
|
||
[],
|
||
{enabled: isLoggedIn, label: 'model-list'},
|
||
);
|
||
|
||
// 合并策略:API 数据 > 缓存即时数据
|
||
const mergedData = result.data ?? instantData;
|
||
|
||
// loading 仅在无任何数据时展示(缓存命中时不闪烁 loading)
|
||
const loading = result.loading && mergedData === null;
|
||
|
||
// 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img)
|
||
const groupedModels: Map<string, ModelsListResponseBody[]> = useMemo(() => {
|
||
const groups = new Map<string, ModelsListResponseBody[]>();
|
||
mergedData?.forEach((model: ModelsListResponseBody) => {
|
||
const cat: ModelCategoryStringEnum = model.model_type;
|
||
if (!groups.has(cat)) groups.set(cat, []);
|
||
groups.get(cat)!.push(model);
|
||
});
|
||
return groups;
|
||
}, [mergedData]);
|
||
|
||
return {
|
||
...result,
|
||
/** 合并后的模型数据(缓存优先) */
|
||
data: mergedData,
|
||
/** 仅在无缓存且正在加载时为 true */
|
||
loading,
|
||
/** 按 model_type 分组后的模型(key 为后端 slug) */
|
||
groupedModels,
|
||
};
|
||
}
|
||
|
||
// ============================================================
|
||
// 任务列表
|
||
// ============================================================
|
||
|
||
export interface UseTaskListParams {
|
||
/** 完整的 API 请求参数(由调用方根据 antd Table onChange 构建) */
|
||
requestParams: TaskListRequestParams;
|
||
/** 外部传入的刷新信号(任意变化触发重新请求) */
|
||
refreshSignal?: number;
|
||
}
|
||
|
||
/**
|
||
* 任务列表 Hook(分页 + 筛选 + 排序)
|
||
*
|
||
* 调用方负责构建 TaskListRequestParams(包括 edition-aware 的 user_ids 逻辑),
|
||
* 本 Hook 仅负责数据获取、竞态控制、错误日志。
|
||
*/
|
||
export function useTaskList(params: UseTaskListParams) {
|
||
const {isLoggedIn} = useAppContext();
|
||
const {requestParams, refreshSignal = 0} = params;
|
||
|
||
return useAsyncData(
|
||
(signal) => TaskAPI.getTaskList(requestParams, signal),
|
||
[requestParams, refreshSignal],
|
||
{enabled: isLoggedIn, label: 'task-list'},
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 任务详情
|
||
// ============================================================
|
||
|
||
/** 任务详情 Hook(taskId 为 null 时不请求) */
|
||
export function useTaskDetail(taskId: string | null | undefined) {
|
||
const {isLoggedIn} = useAppContext();
|
||
|
||
return useAsyncData<TaskInfoItemResponseBody>(
|
||
(signal) => TaskAPI.getTaskInfo(taskId!, signal),
|
||
[taskId],
|
||
{enabled: isLoggedIn && !!taskId, label: 'task-detail'},
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 账户信息(用户信息 + VIP 等级,并行请求)
|
||
// ============================================================
|
||
|
||
/** 账户信息页数据 Hook — 返回 [userInfo, vipsInfo] 元组 */
|
||
export function useAccountInfo() {
|
||
const { isLoggedIn } = useAppContext();
|
||
return useAsyncData<[UserInfoBody, VipLevelsItem[]]>(
|
||
(signal) => Promise.all([
|
||
AuthAPI.getUserInfo(signal),
|
||
VipAPI.getVipLevels(signal),
|
||
]),
|
||
[],
|
||
{ enabled: isLoggedIn, label: 'account-info' },
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 账单
|
||
// ============================================================
|
||
|
||
/** 账户余额 Hook */
|
||
export function useBillingBalance() {
|
||
const { isLoggedIn } = useAppContext();
|
||
return useAsyncData<BalanceResponseBody>(
|
||
(signal) => BillingAPI.getBalance(signal),
|
||
[],
|
||
{ enabled: isLoggedIn, label: 'billing-balance' },
|
||
);
|
||
}
|
||
|
||
/** 账单明细 Hook(支持分页参数) */
|
||
export function useBillingLedger(params: LedgerReqeustParam) {
|
||
const { isLoggedIn } = useAppContext();
|
||
return useAsyncData<LedgerItemResponseBody[]>(
|
||
(signal) => BillingAPI.getLedger(params, signal),
|
||
[params],
|
||
{ enabled: isLoggedIn, label: 'billing-ledger' },
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 提交任务
|
||
// ============================================================
|
||
|
||
/** 提交任务 Mutation Hook(device_id 由 API 层自动注入,调用方无需传入) */
|
||
export function useSubmitTask(options?: {
|
||
onSuccess?: (data: TaskInfoItemResponseBody) => void;
|
||
onError?: (err: Error) => void;
|
||
}) {
|
||
return useAsyncMutation<TaskInfoItemResponseBody, [Omit<CreatTaskRequestBody, 'device_id'>]>(
|
||
(body) => TaskAPI.submitTask(body),
|
||
{
|
||
label: 'submit-task',
|
||
onSuccess: options?.onSuccess,
|
||
onError: options?.onError,
|
||
},
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 任务状态轮询
|
||
// ============================================================
|
||
|
||
/**
|
||
* 批量轮询非终态任务状态,返回合并后的任务列表。
|
||
*
|
||
* - 自动识别 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};
|
||
export type {ModelCategoryStringEnum, ModelsListResponseBody, TaskInfoItemResponseBody, Banner};
|