// ============================================================ // 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( (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(() => getCachedModels(), ); const result: UseAsyncDataReturn = useAsyncData( 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 = useMemo(() => { const groups = new Map(); 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( (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( (signal) => BillingAPI.getBalance(signal), [], { enabled: isLoggedIn, label: 'billing-balance' }, ); } /** 账单明细 Hook(支持分页参数) */ export function useBillingLedger(params: LedgerReqeustParam) { const { isLoggedIn } = useAppContext(); return useAsyncData( (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]>( (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(tasks); const mergedRef = useRef(merged); const timerRef = useRef | 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};