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 补充架构约定文档
This commit is contained in:
2026-06-12 19:03:36 +08:00
parent 4237dcbaf6
commit 9e8c5b7109
22 changed files with 787 additions and 149 deletions

View File

@@ -15,8 +15,35 @@ const AnnouncementUrlObj = {
// ---------- 类型 ----------
/** 公告类型枚举 */
export type AnnouncementType = 'system' | 'feature' | 'activity' | 'maintenance' | string;
// ============================================================
// 公告类型常量
// ============================================================
export const AnnouncementTypeEnum = {
System: "system",
Feature: "feature",
Activity: "activity",
Maintenance: "maintenance",
} as const;
export type AnnouncementTypeValue = typeof AnnouncementTypeEnum[keyof typeof AnnouncementTypeEnum];
export const AnnouncementTypeLabelMap: Record<AnnouncementTypeValue, string> = {
[AnnouncementTypeEnum.System]: "系统公告",
[AnnouncementTypeEnum.Feature]: "功能更新",
[AnnouncementTypeEnum.Activity]: "活动通知",
[AnnouncementTypeEnum.Maintenance]: "维护公告",
};
export const AnnouncementTypeColorMap: Record<AnnouncementTypeValue, string> = {
[AnnouncementTypeEnum.System]: "blue",
[AnnouncementTypeEnum.Feature]: "purple",
[AnnouncementTypeEnum.Activity]: "orange",
[AnnouncementTypeEnum.Maintenance]: "red",
};
/** 公告类型(向后兼容:包含已知枚举 + string 宽类型) */
export type AnnouncementType = AnnouncementTypeValue | string;
/** 单条公告 */
export interface Announcement {

View File

@@ -13,6 +13,22 @@ const BannerUrlObj = {
// ---------- 类型 ----------
// ============================================================
// Banner 分类常量
// ============================================================
export const BannerCategoryEnum = {
Promotion: "promotion",
Banner: "banner",
} as const;
export type BannerCategoryValue = typeof BannerCategoryEnum[keyof typeof BannerCategoryEnum];
export const BannerCategoryLabelMap: Record<BannerCategoryValue, string> = {
[BannerCategoryEnum.Promotion]: "推广",
[BannerCategoryEnum.Banner]: "轮播",
};
/** 单条 Banner */
export interface Banner {
id: number;
@@ -20,7 +36,7 @@ export interface Banner {
content: "真人素材提示";
image_url: string;
link_url?: string;
category: "promotion" | "banner";
category: BannerCategoryValue;
vip_level_id?: number;
sort_order: number;
is_active: boolean;

View File

@@ -54,4 +54,38 @@ export class BillingAPI {
}
static exportUrl = BillingUrlObj.ExportLedger;
}
}
// ============================================================
// 账单条目类型常量
// ============================================================
export const EntryTypeEnum = {
TaskHold: "task_hold",
Recharge: "recharge",
Gift: "gift",
HoldRelease: "hold_release",
HoldSettle: "hold_settle",
} as const;
export type EntryTypeValue = typeof EntryTypeEnum[keyof typeof EntryTypeEnum];
export const EntryTypeLabelMap = {
[EntryTypeEnum.TaskHold]: "任务消耗",
[EntryTypeEnum.Recharge]: "充值",
[EntryTypeEnum.Gift]: "赠送",
[EntryTypeEnum.HoldRelease]: "退款",
[EntryTypeEnum.HoldSettle]: "结算",
} as const;
export const EntryTypeColorMap = {
[EntryTypeEnum.TaskHold]: "orange",
[EntryTypeEnum.Recharge]: "green",
[EntryTypeEnum.Gift]: "green",
[EntryTypeEnum.HoldRelease]: "blue",
[EntryTypeEnum.HoldSettle]: "orange",
} as const;
export type EntryUITypeValue = typeof EntryTypeLabelMap[keyof typeof EntryTypeLabelMap];
export type ColorValue = typeof EntryTypeColorMap[keyof typeof EntryTypeColorMap];

View File

@@ -12,6 +12,7 @@
import {get, post} from '../request';
import {getDeviceId} from '@/utils/device.ts';
import {Nullable} from "@/utils/type.ts";
// ============================================================
// 基础枚举 / 字面量类型
@@ -37,6 +38,9 @@ export enum TaskStatusMap {
export type TaskStatusString = keyof typeof TaskStatusMap;
/** 需要持续轮询的非终态状态 */
export const POLLING_STATUSES: TaskStatusString[] = ['pending', 'submitted', 'processing'];
// ============================================================
// 请求体 / 参数类型
// ============================================================
@@ -62,6 +66,23 @@ export interface CreatTaskRequestBody {
tags?: string[];
}
/**
* 批量任务轮询查询参数
*/
export interface BatchTaskStatusRequestParam {
task_ids: string[];
}
/**
* 批量任务状态轮询响应头
*/
export interface BatchTaskStatusResponseBody {
items: Nullable<TaskStatusResponseBody>[],
balance_cent: number,
next_poll_ms: number;
}
/** 导出 CSV 参数 */
export interface ExportTaskCSVParams {
start_date: Date;
@@ -183,11 +204,19 @@ export interface TaskInfoDataResponseBody {
export interface TaskStatusResponseBody {
task_id: string;
status: TaskStatusString;
progress: number;
progress?: number;
output_assets?: string[];
error_code?: string;
error_message?: string;
cost_cent?: number;
queue_position: number,
submitted_at: Date,
finished_at: Date,
external_task_id: string,
refund_cent: number,
refunded_at: Date,
refund_reason: string,
next_poll_ms: number
}
// ============================================================
@@ -199,6 +228,7 @@ const TaskUrlObj = {
taskInfo: (task_id: string) => `/api/v1/tasks/${task_id}`,
taskStatus: (task_id: string) => `/api/v1/tasks/${task_id}/status`,
tasksCSV: '/api/v1/tasks/export',
batchTaskStatus: "/api/v1/tasks/status/batch"
} as const;
// ============================================================
@@ -208,7 +238,7 @@ const TaskUrlObj = {
export class TaskAPI {
/** 获取任务列表(分页/游标) */
static getTaskList(params: TaskListRequestParams, signal?: AbortSignal): Promise<TaskInfoDataResponseBody> {
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>, { signal });
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>, {signal});
}
/** 提交新任务device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */
@@ -221,7 +251,7 @@ export class TaskAPI {
/** 获取任务详情 */
static getTaskInfo(task_id: string, signal?: AbortSignal): Promise<TaskInfoItemResponseBody> {
return get<TaskInfoItemResponseBody>(TaskUrlObj.taskInfo(task_id), undefined, { signal });
return get<TaskInfoItemResponseBody>(TaskUrlObj.taskInfo(task_id), undefined, {signal});
}
/** 轻量轮询任务状态 */
@@ -229,6 +259,11 @@ export class TaskAPI {
return get<TaskStatusResponseBody>(TaskUrlObj.taskStatus(task_id));
}
/** 批量查询任务状态 — GET + 逗号拼接 task_ids避免 axios 默认 bracket 序列化 task_ids[]=... */
static batchTaskStatus(body: BatchTaskStatusRequestParam): Promise<BatchTaskStatusResponseBody> {
return post<BatchTaskStatusResponseBody>(TaskUrlObj.batchTaskStatus, body);
}
/** 导出任务 CSV */
static exportTaskCSV(params: ExportTaskCSVParams): Promise<File> {
return get<File>(TaskUrlObj.tasksCSV, params as unknown as Record<string, unknown>);