Files
ele-HeiXiu/src/hooks/use-api.ts
YoungestSongMo 9e8c5b7109 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 补充架构约定文档
2026-06-12 19:03:36 +08:00

365 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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'},
);
}
// ============================================================
// 任务详情
// ============================================================
/** 任务详情 HooktaskId 为 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 Hookdevice_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};