feat: 前后端联调 — 任务创建 + 账户信息 + 预估花费 (0.0.18)

- task: device_id 改为必填参数,API 层 Omit 封装 + getDeviceId() 自动注入
- pricing.ts: Python 后端 calculate_cost 完整移植,六种定价模式 + 6 个 Bug 修复
  (浮点冗余/维度匹配不一致/null 守卫/matrix 数据提取/unit_scale 等)
- ModelInputForm: 三层兜底预估消费展示 + React Hooks 顺序修复
- AccountInfo: VIP 等级列表匹配,套餐详情展示(说明/周期/席位/到期)
- 模型列表加密缓存 (safe-storage, stale-while-revalidate)
- VIP API 模块 (激活码/等级/订单/模拟支付)
- device_id 统一使用 UUID v4 持久化 (与 login/register 对齐)
This commit is contained in:
2026-06-10 19:38:26 +08:00
parent 848fdeca3d
commit 95019316b2
19 changed files with 1681 additions and 292 deletions

View File

@@ -12,12 +12,13 @@
// - 用户可读错误信息errorMessage
// ============================================================
import {useMemo} from 'react';
import {useMemo, useState} from 'react';
import {useAppContext} from '@/components/AppProvider';
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } 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 {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
// ============================================================
// Banner
@@ -38,37 +39,63 @@ export function useBannerList() {
// 模型
// ============================================================
/** 模型列表数据 Hook单次请求全量模型 + 客户端按 category_name 分组)
/** 模块级:单次会话中是否已完成首次 API 拉取 */
let modelFirstFetchDone = false;
/** 模型列表数据 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[]>(
() => ModelAPI.fetchModels({page: 1, page_size: 200}),
async () => {
const models = await ModelAPI.fetchModels({page: 1, page_size: 200});
// 异步写入加密缓存best-effort不影响数据流
setCachedModels(models).catch(() => {});
modelFirstFetchDone = true;
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[]>();
result.data?.forEach((model: ModelsListResponseBody) => {
mergedData?.forEach((model: ModelsListResponseBody) => {
const cat: ModelCategoryStringEnum = model.model_type;
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat)!.push(model);
});
// 每组内按优先级降序排列
// groups.forEach((models) => {
// models.sort((a, b) => b.priority - a.priority);
// });
return groups;
}, [result.data]);
}, [mergedData]);
return {
...result,
/** 合并后的模型数据(缓存优先) */
data: mergedData,
/** 仅在无缓存且正在加载时为 true */
loading,
/** 按 model_type 分组后的模型key 为后端 slug */
groupedModels,
};
@@ -121,12 +148,12 @@ export function useTaskDetail(taskId: string | null | undefined) {
// 提交任务
// ============================================================
/** 提交任务 Mutation Hook */
/** 提交任务 Mutation Hookdevice_id 由 API 层自动注入,调用方无需传入) */
export function useSubmitTask(options?: {
onSuccess?: (data: TaskInfoItemResponseBody) => void;
onError?: (err: Error) => void;
}) {
return useAsyncMutation<TaskInfoItemResponseBody, [CreatTaskRequestBody]>(
return useAsyncMutation<TaskInfoItemResponseBody, [Omit<CreatTaskRequestBody, 'device_id'>]>(
(body) => TaskAPI.submitTask(body),
{
label: 'submit-task',