## 架构重构
- useAsyncData 集成 AbortController:cleanup 时真正中断 HTTP 请求,
不再仅丢弃响应(解决 StrictMode 双重请求问题)
- API 模块统一 signal?: AbortSignal 参数,透传 axios config
- use-api.ts 成为数据 Hook 集中管理中心,新增 useAccountInfo、
useBillingBalance、useBillingLedger
- 页面组件改为纯渲染:const {data,loading,error,refetch} = useXxx()
## 新功能
- Mac 硬件指纹采集(electron/preload/device.ts):
ioreg(UUID) + sysctl(CPU) + system_profiler(HDD三级回退)
- 账单页面 BillingInfo(余额卡片 + 账单表格 + 分页)
- PageDispatcher 注册 /billing 页面(FloatingPanel 960×680)
- 共享工具函数 centToYuan / formatDate(src/utils/display.ts)
## Bug 修复
- Token 刷新容错:网络错误时保留 refresh_token,等网络恢复后重试;
仅 RefreshError(token 过期)时清除(auth-token.ts)
- AppProvider 自动登录时 nullable 字段默认值(email→''、admin_permissions→[])
- AccountInfo 重构消除 ~40 行手写状态管理代码
## 代码质量
- AccountInfo 消除 centToYuan/formatDate 重复定义
- BillingInfo 标题层级精简(移除冗余页面/Card标题)
- antd Table align 字面量类型修正('left' as const)
- billing.ts signal 参数位置修复(params→config)
- modelFirstFetchDone 模块级变量移除(StrictMode 不安全)
237 lines
7.0 KiB
TypeScript
237 lines
7.0 KiB
TypeScript
// ============================================================
|
||
// 任务模块 — 任务记录 CRUD API
|
||
// GET /api/v1/tasks → 任务列表(分页/游标)
|
||
// POST /api/v1/tasks → 提交任务
|
||
// GET /api/v1/tasks/:id → 任务详情
|
||
// GET /api/v1/tasks/:id/status → 轻量轮询状态
|
||
// GET /api/v1/tasks/export → 导出 CSV
|
||
//
|
||
// TaskAPI 的静态方法直接返回 get/post 的 Promise<T>,
|
||
// 不做额外的 async/await 包装,由调用方决定是否 await。
|
||
// ============================================================
|
||
|
||
import {get, post} from '../request';
|
||
import {getDeviceId} from '@/utils/device.ts';
|
||
|
||
// ============================================================
|
||
// 基础枚举 / 字面量类型
|
||
// ============================================================
|
||
|
||
/** 输出类型映射 */
|
||
export enum OutputTypeMap {
|
||
image = '图片',
|
||
video = '视频',
|
||
audio = '音频',
|
||
}
|
||
|
||
export type OutputTypeString = keyof typeof OutputTypeMap;
|
||
|
||
/** 任务状态 */
|
||
export enum TaskStatusMap {
|
||
pending = '待处理',
|
||
submitted = '已提交',
|
||
processing = '处理中',
|
||
success = '成功',
|
||
failed = '失败',
|
||
}
|
||
|
||
export type TaskStatusString = keyof typeof TaskStatusMap;
|
||
|
||
// ============================================================
|
||
// 请求体 / 参数类型
|
||
// ============================================================
|
||
|
||
/** 任务列表请求参数 */
|
||
export interface TaskListRequestParams {
|
||
status?: TaskStatusString;
|
||
user_ids?: string[];
|
||
model_ids?: string[];
|
||
/** 游标分页:上次最后一条的 created_at(ISO 8601) */
|
||
cursor?: string;
|
||
/** 页码(OFFSET 分页模式),default: 1 */
|
||
page?: number;
|
||
/** 每页条数,default: 20 */
|
||
page_size?: number;
|
||
}
|
||
|
||
/** 创建任务请求体 */
|
||
export interface CreatTaskRequestBody {
|
||
model_id: string;
|
||
params: Record<string, string>;
|
||
device_id: string;
|
||
tags?: string[];
|
||
}
|
||
|
||
/** 导出 CSV 参数 */
|
||
export interface ExportTaskCSVParams {
|
||
start_date: Date;
|
||
end_date: Date;
|
||
status?: TaskStatusString;
|
||
user_ids?: string[];
|
||
model_ids?: string[];
|
||
}
|
||
|
||
// ============================================================
|
||
// 内容项 — 带判别标签的联合类型
|
||
// ============================================================
|
||
|
||
interface BaseContentItem {
|
||
type: string;
|
||
}
|
||
|
||
interface TextContent extends BaseContentItem {
|
||
type: 'text';
|
||
text: string;
|
||
}
|
||
|
||
interface ImageUrlContent extends BaseContentItem {
|
||
type: 'image_url';
|
||
role: 'reference_image';
|
||
image_url: { url: string };
|
||
}
|
||
|
||
interface VideoUrlContent extends BaseContentItem {
|
||
type: 'video_url';
|
||
role: 'reference_video';
|
||
video_url: { url: string };
|
||
}
|
||
|
||
interface AudioContent extends BaseContentItem {
|
||
type: 'audio_url';
|
||
role: 'reference_audio';
|
||
audio_url: { url: string };
|
||
}
|
||
|
||
export type ContentItem =
|
||
| TextContent
|
||
| ImageUrlContent
|
||
| VideoUrlContent
|
||
| AudioContent;
|
||
|
||
// ============================================================
|
||
// 各输出类型的 UI 参数
|
||
// ============================================================
|
||
|
||
export interface ImageUiParams {
|
||
imageUrls?: string[];
|
||
prompt: string;
|
||
aspectRatio: string;
|
||
resolution?: string;
|
||
}
|
||
|
||
export interface VideoUiParams {
|
||
resolution?: string;
|
||
ratio?: string;
|
||
model?: string;
|
||
generate_audio: boolean;
|
||
duration: number;
|
||
content: ContentItem[];
|
||
}
|
||
|
||
export interface AudioUiParams {
|
||
format?: string;
|
||
sampleRate?: number;
|
||
bitrate?: number;
|
||
}
|
||
|
||
// ============================================================
|
||
// 响应体 — 用判别联合按 output_type 分发
|
||
// ============================================================
|
||
|
||
/** 任务条目基类(不含 output_type 特有字段) */
|
||
interface BaseTaskItemResponseBody {
|
||
id: string;
|
||
user_id: string;
|
||
model_id: string;
|
||
model_name: string;
|
||
provider_name: string;
|
||
status: TaskStatusString;
|
||
external_task_id: string;
|
||
cost_cent: number;
|
||
estimated_cost_cent: number;
|
||
error_code: string;
|
||
poll_attempts: number;
|
||
is_favorite: boolean;
|
||
submitted_at: Date;
|
||
finished_at: Date;
|
||
created_at: Date;
|
||
updated_at: Date;
|
||
prompt?: string;
|
||
output_assets?: Array<{ url: string }>;
|
||
tags: Array<string | null>;
|
||
error_message: string;
|
||
user_display_name: string | null;
|
||
}
|
||
|
||
/** 判别联合:根据 output_type 收窄 ui_params */
|
||
export type TaskInfoItemResponseBody =
|
||
| (BaseTaskItemResponseBody & { output_type: 'image'; ui_params: ImageUiParams })
|
||
| (BaseTaskItemResponseBody & { output_type: 'video'; ui_params: VideoUiParams })
|
||
| (BaseTaskItemResponseBody & { output_type: 'audio'; ui_params: AudioUiParams });
|
||
|
||
/** 任务列表分页响应 */
|
||
export interface TaskInfoDataResponseBody {
|
||
has_more: boolean;
|
||
next_cursor: string;
|
||
page: number;
|
||
page_size: number;
|
||
total: number;
|
||
items: TaskInfoItemResponseBody[];
|
||
}
|
||
|
||
/** 轻量轮询状态响应 */
|
||
export interface TaskStatusResponseBody {
|
||
task_id: string;
|
||
status: TaskStatusString;
|
||
progress: number;
|
||
output_assets?: string[];
|
||
error_code?: string;
|
||
error_message?: string;
|
||
cost_cent?: number;
|
||
}
|
||
|
||
// ============================================================
|
||
// 路由常量
|
||
// ============================================================
|
||
|
||
const TaskUrlObj = {
|
||
task: '/api/v1/tasks',
|
||
taskInfo: (task_id: string) => `/api/v1/tasks/${task_id}`,
|
||
taskStatus: (task_id: string) => `/api/v1/tasks/${task_id}/status`,
|
||
tasksCSV: '/api/v1/tasks/export',
|
||
} as const;
|
||
|
||
// ============================================================
|
||
// TaskAPI — 静态方法直接返回 Promise<T>,不做二次 async 包装
|
||
// ============================================================
|
||
|
||
export class TaskAPI {
|
||
/** 获取任务列表(分页/游标) */
|
||
static getTaskList(params: TaskListRequestParams, signal?: AbortSignal): Promise<TaskInfoDataResponseBody> {
|
||
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>, { signal });
|
||
}
|
||
|
||
/** 提交新任务(device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */
|
||
static submitTask(body: Omit<CreatTaskRequestBody, 'device_id'>): Promise<TaskInfoItemResponseBody> {
|
||
return post<TaskInfoItemResponseBody>(TaskUrlObj.task, {
|
||
...body,
|
||
device_id: getDeviceId(),
|
||
});
|
||
}
|
||
|
||
/** 获取任务详情 */
|
||
static getTaskInfo(task_id: string, signal?: AbortSignal): Promise<TaskInfoItemResponseBody> {
|
||
return get<TaskInfoItemResponseBody>(TaskUrlObj.taskInfo(task_id), undefined, { signal });
|
||
}
|
||
|
||
/** 轻量轮询任务状态 */
|
||
static getTaskStatus(task_id: string): Promise<TaskStatusResponseBody> {
|
||
return get<TaskStatusResponseBody>(TaskUrlObj.taskStatus(task_id));
|
||
}
|
||
|
||
/** 导出任务 CSV */
|
||
static exportTaskCSV(params: ExportTaskCSVParams): Promise<File> {
|
||
return get<File>(TaskUrlObj.tasksCSV, params as unknown as Record<string, unknown>);
|
||
}
|
||
}
|