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

@@ -1,6 +1,43 @@
# 回答语言 # 基本交代
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明;
- 有需要请自行调用MCP服务
# 业务设计思路
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录;
- 进行系统级别的操作时需要考虑系统差异并处理兼容性。如WINDOWS、MAC OS
- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获;
- 所有设计思路、代码实现均需要考虑以上三个条件。
# 架构约定
## 任务状态轮询
- **两层轮询 + Context 桥接**`useTaskPolling`(批量轮询 `batchTaskStatus`)负责广度覆盖,`OutputPreview` 通过 `selectedTask.status` 变化检测终态转换后触发 `refetch()` 获取详情——二者通过 Context 同步,避免重复请求。
- **非终态统一管理**`POLLING_STATUSES = ['pending', 'submitted', 'processing']` 定义在 `task.ts`,所有轮询逻辑引用此常量。
- **自适应间隔**:递归 `setTimeout`(非 `setInterval`+ 服务端 `next_poll_ms`,本地 clamp 2s~30s出错 10s 重试。
- **useRef 打破闭包陷阱**`mergedRef.current` 确保递归回调读到最新任务列表。
## Token 刷新
- **双轨刷新**:主动刷新(`scheduleProactiveRefresh`)在 JWT `exp` 的 75% 时间点通过 `setTimeout` 提前续期;被动刷新(`handle401``refreshHandler`)兜底。
- **失败分级处理**`refreshHandler` 永久失败(`RefreshTokenExpiredError`)→ throw → `handle401` 清 token + 弹登录;临时失败(网络超时)→ return null → 仅拒绝当前请求,不登出。
- **JWT 解码驱动定时器**:从 `atob(token.split('.')[1])``exp` 声明,无需额外持久化 `expires_in`
- **刷新接口隔离**`/api/v1/auth/refresh``request.ts` 中特殊处理(`isRefreshRequest`),防止 401→刷新→401 死循环。
## 文件上传
- **customRequest 集成模式**`useFileUpload` hook 封装 `request.upload()`,返回 antd Upload 兼容的 `customRequest`——避免直接设 `action` 导致 Token 缺失,复用全局拦截器的认证和错误处理。
- **beforeUpload 语义修正**:校验通过返回 `true`(放行至 `customRequest`),失败返回 `Upload.LIST_IGNORE`
## 表单提交
- **两级防护**防抖2s 节流,"你点击的太快了"+ 二次确认(`Modal.confirm`"您已提交,确认要再次提交吗?")。
- **useRef 存储时序状态**`lastClickTimeRef` / `submitCountRef` 避免不必要的重渲染。
- **useAsyncMutation 返回值判空**`execute()` 内部 catch 所有错误返回 `undefined`,调用方应检查返回值而非 try/catch。
## GET 数组参数
- **逗号拼接优于 bracket 序列化**`task_ids=a,b,c``task_ids[]=a&task_ids[]=b` 更具跨框架兼容性,避免 axios 默认 `paramsSerializer` 的 PHP/Rack 风格。
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录
- 进行系统级别的操作时需要考虑系统差异并处理兼容性。如WINDOWS、MAC OS。
- 有需要请自己调用MCP服务

View File

@@ -23,6 +23,8 @@ import {
setStoredRefreshToken, setStoredRefreshToken,
clearStoredRefreshToken, clearStoredRefreshToken,
initRefreshTokenStore, initRefreshTokenStore,
scheduleProactiveRefresh,
clearProactiveRefresh,
} from '@/services/auth-token'; } from '@/services/auth-token';
import {initModelCache} from '@/services/cache/model-cache'; import {initModelCache} from '@/services/cache/model-cache';
@@ -89,6 +91,8 @@ export function AppProvider({ children }: AppProviderProps) {
const login = useCallback(async (auth: LoginResponseBody) => { const login = useCallback(async (auth: LoginResponseBody) => {
await setToken(auth.access_token); await setToken(auth.access_token);
await setStoredRefreshToken(auth.refresh_token); await setStoredRefreshToken(auth.refresh_token);
// 启动主动刷新定时器(在 access_token 过期前自动续期)
scheduleProactiveRefresh(auth.access_token);
setUser(auth); setUser(auth);
setIsLoggedIn(true); setIsLoggedIn(true);
logger.info('auth', 'User logged in', { logger.info('auth', 'User logged in', {
@@ -102,6 +106,7 @@ export function AppProvider({ children }: AppProviderProps) {
logger.info('auth', 'User logged out'); logger.info('auth', 'User logged out');
clearToken(); clearToken();
clearStoredRefreshToken(); clearStoredRefreshToken();
clearProactiveRefresh();
setUser(null); setUser(null);
setIsLoggedIn(false); setIsLoggedIn(false);
emit(EVENTS.LOGOUT); emit(EVENTS.LOGOUT);
@@ -133,6 +138,7 @@ export function AppProvider({ children }: AppProviderProps) {
if (aborted) return; if (aborted) return;
await setToken(res.access_token); await setToken(res.access_token);
await setStoredRefreshToken(res.refresh_token); await setStoredRefreshToken(res.refresh_token);
scheduleProactiveRefresh(res.access_token);
setIsLoggedIn(true); setIsLoggedIn(true);
logger.info('auth', 'Auto-login succeeded'); logger.info('auth', 'Auto-login succeeded');
// 补全用户信息 // 补全用户信息

View File

@@ -90,7 +90,12 @@ const PAGE_MAP: Record<string, PageConfig> = {
label: '订单明细', label: '订单明细',
}, },
'/projects': {label: '项目管理'}, '/projects': {label: '项目管理'},
'/quota': {label: '查配额'}, '/quota': {
component: AccountInfo,
label: '配额',
width: () => Math.min(window.outerHeight * 0.65, 720),
height: () => Math.min(window.outerHeight * 0.65, 680),
},
}; };
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定) // 预计算所有占位组件(模块加载时执行一次,引用永久稳定)

View File

@@ -7,28 +7,13 @@ import {useEffect, useState, useCallback} from 'react';
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd'; import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
import {NotificationOutlined} from '@ant-design/icons'; import {NotificationOutlined} from '@ant-design/icons';
import { fetchAnnouncements, type Announcement, type AnnouncementListResponse } from '@/services/modules/announcement'; import { fetchAnnouncements, type Announcement, type AnnouncementListResponse, type AnnouncementTypeValue } from '@/services/modules/announcement';
import { EnumResolver } from '@/utils/enum-resolver';
const {Text, Paragraph} = Typography; const {Text, Paragraph} = Typography;
// ---------- 辅助 ---------- // ---------- 辅助 ----------
/** 公告类型 → Tag 颜色映射 */
const typeColorMap: Record<string, string> = {
system: 'blue',
feature: 'purple',
activity: 'orange',
maintenance: 'red',
};
/** 公告类型 → 中文标签 */
const typeLabelMap: Record<string, string> = {
system: '系统',
feature: '功能',
activity: '活动',
maintenance: '维护',
};
/** 格式化日期YYYY-MM-DD HH:mm */ /** 格式化日期YYYY-MM-DD HH:mm */
function formatDate(iso: string): string { function formatDate(iso: string): string {
try { try {
@@ -132,10 +117,10 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
} }
extra={ extra={
<Tag <Tag
color={typeColorMap[item.type] || 'default'} color={EnumResolver.announcement.type.color(item.type as AnnouncementTypeValue)}
className="m-0! text-xs" className="m-0! text-xs"
> >
{typeLabelMap[item.type] || item.type} {EnumResolver.announcement.type.label(item.type as AnnouncementTypeValue)}
</Tag> </Tag>
} }
onClick={() => setDetail(item)} onClick={() => setDetail(item)}
@@ -177,10 +162,10 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tag <Tag
color={typeColorMap[detail.type] || 'default'} color={EnumResolver.announcement.type.color(detail.type as AnnouncementTypeValue)}
className="m-0! text-xs" className="m-0! text-xs"
> >
{typeLabelMap[detail.type] || detail.type} {EnumResolver.announcement.type.label(detail.type as AnnouncementTypeValue)}
</Tag> </Tag>
<Text type="secondary" className="text-xs"> <Text type="secondary" className="text-xs">
{formatDate(detail.created_at)} {formatDate(detail.created_at)}

View File

@@ -12,9 +12,9 @@
// - 用户可读错误信息errorMessage // - 用户可读错误信息errorMessage
// ============================================================ // ============================================================
import {useMemo, useState} from 'react'; import {useMemo, useState, useRef, useEffect, useCallback} from 'react';
import {useAppContext} from '@/components/AppProvider'; import {useAppContext} from '@/components/AppProvider';
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task'; 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 { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
import { fetchBanners, type Banner } from '@/services/modules/banner'; import { fetchBanners, type Banner } from '@/services/modules/banner';
import { AuthAPI, type UserInfoBody } from '@/services/modules/auth'; import { AuthAPI, type UserInfoBody } from '@/services/modules/auth';
@@ -22,6 +22,7 @@ import { VipAPI, type VipLevelsItem } from '@/services/modules/vip-api';
import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/services/modules/billing'; import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/services/modules/billing';
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async'; import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache'; import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
import { upload } from '@/services/request';
// ============================================================ // ============================================================
// Banner // Banner
@@ -203,6 +204,160 @@ export function useSubmitTask(options?: {
); );
} }
// ============================================================
// 任务状态轮询
// ============================================================
/**
* 批量轮询非终态任务状态,返回合并后的任务列表。
*
* - 自动识别 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 {MODEL_CATEGORY_LABELS};

View File

@@ -17,7 +17,7 @@ import {
BarChartOutlined, BarChartOutlined,
RightOutlined, RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { UserAccountTypeLabelMap } from '@/services/modules/auth'; import {EnumResolver} from '@/utils/enum-resolver';
import type { VipLevelsItem } from '@/services/modules/vip-api'; import type { VipLevelsItem } from '@/services/modules/vip-api';
import { useAccountInfo } from '@/hooks/use-api'; import { useAccountInfo } from '@/hooks/use-api';
import { centToYuan, formatDate } from '@/utils/display'; import { centToYuan, formatDate } from '@/utils/display';
@@ -75,7 +75,7 @@ export function AccountInfo() {
{userInfo.display_name || userInfo.real_name || userInfo.username || userInfo.id} {userInfo.display_name || userInfo.real_name || userInfo.username || userInfo.id}
{userInfo.account_type && ( {userInfo.account_type && (
<Tag color="gold" style={{lineHeight: '18px', fontSize: 11}}> <Tag color="gold" style={{lineHeight: '18px', fontSize: 11}}>
{UserAccountTypeLabelMap[userInfo.account_type]} {EnumResolver.auth.accountType.label(userInfo.account_type)}
</Tag> </Tag>
)} )}
</Title> </Title>

View File

@@ -16,7 +16,8 @@ import {
ApiOutlined, ApiOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import {useBillingBalance, useBillingLedger} from '@/hooks/use-api'; import {useBillingBalance, useBillingLedger} from '@/hooks/use-api';
import type {LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/billing'; import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/billing';
import {EnumResolver} from '@/utils/enum-resolver';
import {centToYuan, formatDate} from '@/utils/display'; import {centToYuan, formatDate} from '@/utils/display';
const {Text, Title} = Typography; const {Text, Title} = Typography;
@@ -107,30 +108,6 @@ function BalanceCard() {
); );
} }
// ============================================================
// 账单表格
// ============================================================
/** entry_type → 中文标签映射(按需扩展) */
const ENTRY_TYPE_LABELS: Record<string, string> = {
task_cost: '任务消耗',
recharge: '充值',
gift: '赠送',
refund: '退款',
vip_purchase: '套餐购买',
};
function resolveEntryLabel(type: string): string {
return ENTRY_TYPE_LABELS[type] || type;
}
/** entry_type → Tag 颜色映射 */
function entryTypeColor(type: string): string {
if (type === 'task_cost' || type === 'vip_purchase') return 'orange';
if (type === 'recharge' || type === 'gift') return 'green';
if (type === 'refund') return 'blue';
return 'default';
}
function LedgerTable() { function LedgerTable() {
const {token} = antTheme.useToken(); const {token} = antTheme.useToken();
@@ -163,9 +140,9 @@ function LedgerTable() {
key: 'entry_type', key: 'entry_type',
width: 100, width: 100,
align: 'center' as const, align: 'center' as const,
render: (type: string) => ( render: (type: EntryTypeValue) => (
<Tag color={entryTypeColor(type)} style={{fontSize: 11, lineHeight: '18px'}}> <Tag color={EnumResolver.billing.entryType.color(type)} style={{fontSize: 11, lineHeight: '18px'}}>
{resolveEntryLabel(type)} {EnumResolver.billing.entryType.label(type)}
</Tag> </Tag>
), ),
}, },

View File

@@ -10,7 +10,7 @@
// - 本组件只负责布局、Form 容器、提交逻辑、依赖禁用 // - 本组件只负责布局、Form 容器、提交逻辑、依赖禁用
// ============================================================ // ============================================================
import { useState, useLayoutEffect, useMemo, useCallback } from 'react'; import { useState, useLayoutEffect, useMemo, useCallback, useRef } from 'react';
import { import {
Form, Form,
Input, Input,
@@ -21,6 +21,7 @@ import {
Space, Space,
theme as antTheme, theme as antTheme,
message, message,
Modal,
} from 'antd'; } from 'antd';
import type { FormInstance } from 'antd'; import type { FormInstance } from 'antd';
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons'; import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
@@ -147,6 +148,16 @@ export function ModelInputForm() {
}, []), }, []),
}); });
// -------- 提交防抖 & 二次确认 Ref --------
/** 上次点击提交按钮的时间戳(毫秒),用于 2s 防抖节流 */
const lastClickTimeRef = useRef(0);
/** 已成功提交次数,用于判断是否需要"再次提交确认"弹窗 */
const submitCountRef = useRef(0);
/** 最新错误信息的 ref避免 doSubmit useCallback 闭包过期) */
const errorMessageRef = useRef(errorMessage);
errorMessageRef.current = errorMessage;
// ======== useModelUIparam_schema → UIFieldConfig[] ======== // ======== useModelUIparam_schema → UIFieldConfig[] ========
const fields: UIFieldConfig[] = useModelUI( const fields: UIFieldConfig[] = useModelUI(
@@ -190,15 +201,20 @@ export function ModelInputForm() {
// ======== 提交 ======== // ======== 提交 ========
const handleSubmit = async () => { /**
if (!isLoggedIn) { * 实际提交逻辑(在防抖检查和二次确认之后执行)。
message.warning('请先登录后再提交任务'); * 用 useCallback 稳定化,避免 handleSubmit 因闭包过期而读到旧 fields/values。
return; */
} const doSubmit = useCallback(async () => {
if (!selectedModel) return; if (!selectedModel) return;
let values: Record<string, unknown>;
try { try {
const values = await form.validateFields(); values = await form.validateFields();
} catch {
// 表单校验失败 → antd 自动展示字段错误,静默返回
return;
}
const params: Record<string, string> = {}; const params: Record<string, string> = {};
@@ -226,18 +242,55 @@ export function ModelInputForm() {
params[field.name] = String(value); params[field.name] = String(value);
} }
await submitTask({ // useAsyncMutation.execute 内部 catch 所有错误,返回 undefined 表示失败
const result = await submitTask({
model_id: selectedModel.id, model_id: selectedModel.id,
params, params,
...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}), ...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}),
}); });
} catch (err) {
if (err && typeof err === 'object' && 'errorFields' in err) { if (result !== undefined) {
submitCountRef.current += 1;
} else {
// 使用 ref 读取最新错误信息(避免闭包过期)
message.error(errorMessageRef.current || '任务提交失败,请重试');
}
}, [selectedModel, form, fields, taskTag, submitTask]);
/** 提交按钮点击入口:登录校验 → 防抖节流 → 二次确认 → 执行提交 */
const handleSubmit = useCallback(() => {
if (!isLoggedIn) {
message.warning('请先登录后再提交任务');
return; return;
} }
message.error(errorMessage || '任务提交失败,请重试'); if (!selectedModel) return;
// ---- 防抖节流2s 内重复点击 ----
const now = Date.now();
if (now - lastClickTimeRef.current < 2000) {
message.warning('你点击的太快了');
return;
} }
}; lastClickTimeRef.current = now;
// ---- 已提交过 → 二次确认弹窗 ----
if (submitCountRef.current > 0) {
Modal.confirm({
title: '确认再次提交',
content: '您已提交,确认要再次提交吗?',
okText: '确认提交',
cancelText: '取消',
onOk: () => {
// 弹窗确认后重置节流时间戳,确保确认提交不被防抖拦截
lastClickTimeRef.current = 0;
void doSubmit();
},
});
return;
}
void doSubmit();
}, [isLoggedIn, selectedModel, doSubmit]);
// ======== 动态预估消费金额(必须在所有提前返回之前) ======== // ======== 动态预估消费金额(必须在所有提前返回之前) ========

View File

@@ -8,7 +8,8 @@ import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as a
import { AppstoreOutlined } from '@ant-design/icons'; import { AppstoreOutlined } from '@ant-design/icons';
import { useHomeContext } from '@/pages/home/HomeContent'; import { useHomeContext } from '@/pages/home/HomeContent';
import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models'; import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
import { EnumResolver } from '@/utils/enum-resolver';
const { Text } = Typography; const { Text } = Typography;
@@ -132,7 +133,7 @@ export function ModelSelector({ models, loading, error, groupedModels, refetch }
seenCategories.add(cat); seenCategories.add(cat);
items.push({ items.push({
key: cat, key: cat,
label: `${resolveCategoryLabel(cat)} (${groupModels.length})`, label: `${EnumResolver.model.category.label(cat)} (${groupModels.length})`,
}); });
} }
}); });
@@ -142,7 +143,7 @@ export function ModelSelector({ models, loading, error, groupedModels, refetch }
if (!seenCategories.has(category) && groupModels.length > 0) { if (!seenCategories.has(category) && groupModels.length > 0) {
items.push({ items.push({
key: category, key: category,
label: `${resolveCategoryLabel(category)} (${groupModels.length})`, label: `${EnumResolver.model.category.label(category as ModelCategoryStringEnum)} (${groupModels.length})`,
}); });
} }
}); });

View File

@@ -3,7 +3,7 @@
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染 // 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
// ============================================================ // ============================================================
import { useMemo } from 'react'; import { useMemo, useRef, useEffect } from 'react';
import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd'; import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd';
import { import {
EyeOutlined, EyeOutlined,
@@ -13,7 +13,7 @@ import {
import { useHomeContext } from '@/pages/home/HomeContent'; import { useHomeContext } from '@/pages/home/HomeContent';
import { useTaskDetail } from '@/hooks/use-api'; import { useTaskDetail } from '@/hooks/use-api';
import type { TaskStatusString } from '@/services/modules/task'; import { POLLING_STATUSES, type TaskStatusString } from '@/services/modules/task';
const { Text } = Typography; const { Text } = Typography;
@@ -50,7 +50,30 @@ export function OutputPreview() {
const { selectedTask } = useHomeContext(); const { selectedTask } = useHomeContext();
// 数据获取Hook 自动处理taskId 为 null 时不请求、竞态、loading/error // 数据获取Hook 自动处理taskId 为 null 时不请求、竞态、loading/error
const { data: detail, loading } = useTaskDetail(selectedTask?.id); const { data: detail, loading, refetch } = useTaskDetail(selectedTask?.id);
// ======== 状态转换监听:非终态→终态时触发详情刷新 ========
// 不再独立轮询 getTaskStatus避免与 TaskHistory 的批量轮询重复请求),
// 而是依赖批量轮询通过 Context 同步过来的 selectedTask.status 变化。
const prevStatusRef = useRef<TaskStatusString | undefined>(undefined);
useEffect(() => {
const status = selectedTask?.status as TaskStatusString | undefined;
const prev = prevStatusRef.current;
// 从非终态变为终态 → 刷新详情获取完整数据output_assets 等)
if (
prev &&
POLLING_STATUSES.includes(prev) &&
status &&
!POLLING_STATUSES.includes(status)
) {
refetch();
}
prevStatusRef.current = status;
}, [selectedTask?.status, refetch]);
// 从 output_assets 提取图片/视频 // 从 output_assets 提取图片/视频
const { images, videos } = useMemo(() => { const { images, videos } = useMemo(() => {
@@ -75,7 +98,7 @@ export function OutputPreview() {
} }
// ---- 处理中 ---- // ---- 处理中 ----
if (status === 'pending' || status === 'submitted' || status === 'processing') { if (status && POLLING_STATUSES.includes(status)) {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
<Spin size="large" /> <Spin size="large" />

View File

@@ -17,7 +17,7 @@ import type { ColumnsType, TableProps } from 'antd/es/table';
import type { FilterValue, SorterResult } from 'antd/es/table/interface'; import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'; import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
import { useTaskList } from '@/hooks/use-api'; import { useTaskList, useTaskPolling } from '@/hooks/use-api';
import type { ModelsListResponseBody } from '@/services/modules/models'; import type { ModelsListResponseBody } from '@/services/modules/models';
import { useAppContext } from '@/components/AppProvider'; import { useAppContext } from '@/components/AppProvider';
import { useHomeContext } from '@/pages/home/HomeContent'; import { useHomeContext } from '@/pages/home/HomeContent';
@@ -286,6 +286,22 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
}); });
const tasks = taskData?.items ?? []; const tasks = taskData?.items ?? [];
// 批量轮询非终态任务状态
const handleTaskTerminal = useCallback(() => {
setRefreshTick((t) => t + 1);
}, []);
const polledTasks = useTaskPolling(tasks, handleTaskTerminal);
// 轮询更新后同步选中的任务到 Context
useEffect(() => {
if (!selectedTask || polledTasks.length === 0) return;
const updated = polledTasks.find(t => t.id === selectedTask.id);
if (updated && updated.status !== selectedTask.status) {
setSelectedTask(updated);
}
}, [polledTasks, selectedTask, setSelectedTask]);
const total = taskData?.total ?? 0; const total = taskData?.total ?? 0;
// 从任务数据中提取用户列表(企业版筛选用) // 从任务数据中提取用户列表(企业版筛选用)
@@ -719,7 +735,7 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
<Table<TaskInfoItemResponseBody> <Table<TaskInfoItemResponseBody>
className="task-table" className="task-table"
columns={columns} columns={columns}
dataSource={tasks} dataSource={polledTasks}
rowKey="id" rowKey="id"
size="small" size="small"
loading={loading} loading={loading}

View File

@@ -7,6 +7,7 @@ import { Upload } from 'antd';
import { InboxOutlined } from '@ant-design/icons'; import { InboxOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload'; import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types'; import type { WidgetProps } from './types';
import { useFileUpload } from '@/hooks/use-api';
const { Dragger } = Upload; const { Dragger } = Upload;
@@ -16,6 +17,8 @@ export function AudioListInput({ value, onChange, disabled, config }: WidgetProp
const maxCount = (config.maxFiles as number) || 10; const maxCount = (config.maxFiles as number) || 10;
const filters = config.fileFilter as string[] | undefined; const filters = config.fileFilter as string[] | undefined;
const { customRequest } = useFileUpload();
return ( return (
<Dragger <Dragger
multiple multiple
@@ -25,9 +28,10 @@ export function AudioListInput({ value, onChange, disabled, config }: WidgetProp
: '.wav,.mp3,.m4a,.flac' : '.wav,.mp3,.m4a,.flac'
} }
fileList={fileList} fileList={fileList}
customRequest={customRequest}
onChange={({ fileList: newList }) => onChange?.(newList)} onChange={({ fileList: newList }) => onChange?.(newList)}
disabled={disabled} disabled={disabled}
beforeUpload={() => false} beforeUpload={() => true}
maxCount={maxCount} maxCount={maxCount}
> >
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">

View File

@@ -19,6 +19,7 @@ import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload'; import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types'; import type { WidgetProps } from './types';
import { createImageBeforeUpload } from './uploadValidation'; import { createImageBeforeUpload } from './uploadValidation';
import { useFileUpload } from '@/hooks/use-api';
const { Text } = Typography; const { Text } = Typography;
@@ -36,6 +37,9 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
const maxSizeMb = (config.maxFileSizeMb as number) || 10; const maxSizeMb = (config.maxFileSizeMb as number) || 10;
const hasEnableSwitch = (config.enableSwitch as boolean) || false; const hasEnableSwitch = (config.enableSwitch as boolean) || false;
// 文件上传 Hook提供 customRequest → antd Upload 集成)
const { customRequest } = useFileUpload();
// 有 enable_switch 时默认禁用,需手动开启 // 有 enable_switch 时默认禁用,需手动开启
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch); const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
const isUploadDisabled = disabled || !switchOn; const isUploadDisabled = disabled || !switchOn;
@@ -75,6 +79,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
maxCount={1} maxCount={1}
accept={buildAccept(config.fileFilter as string[] | undefined)} accept={buildAccept(config.fileFilter as string[] | undefined)}
fileList={fileList} fileList={fileList}
customRequest={customRequest}
onChange={({ fileList: newList }) => { onChange={({ fileList: newList }) => {
// 仅当开关开启时上报文件变化 // 仅当开关开启时上报文件变化
if (switchOn || !hasEnableSwitch) { if (switchOn || !hasEnableSwitch) {

View File

@@ -8,6 +8,7 @@ import { InboxOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload'; import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types'; import type { WidgetProps } from './types';
import { createImageBeforeUpload } from './uploadValidation'; import { createImageBeforeUpload } from './uploadValidation';
import { useFileUpload } from '@/hooks/use-api';
const { Dragger } = Upload; const { Dragger } = Upload;
@@ -17,6 +18,8 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
const maxCount = (config.maxFiles as number) || 10; const maxCount = (config.maxFiles as number) || 10;
const filters = config.fileFilter as string[] | undefined; const filters = config.fileFilter as string[] | undefined;
const { customRequest } = useFileUpload();
return ( return (
<Dragger <Dragger
multiple multiple
@@ -27,6 +30,7 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
: 'image/png,image/jpg,image/jpeg,image/webp' : 'image/png,image/jpg,image/jpeg,image/webp'
} }
fileList={fileList} fileList={fileList}
customRequest={customRequest}
onChange={({ fileList: newList }) => onChange?.(newList)} onChange={({ fileList: newList }) => onChange?.(newList)}
disabled={disabled} disabled={disabled}
beforeUpload={createImageBeforeUpload(maxSizeMb)} beforeUpload={createImageBeforeUpload(maxSizeMb)}

View File

@@ -21,6 +21,6 @@ export function createImageBeforeUpload(maxSizeMb: number = 10) {
message.error(`图片大小不能超过 ${maxSizeMb}MB`); message.error(`图片大小不能超过 ${maxSizeMb}MB`);
return Upload.LIST_IGNORE; return Upload.LIST_IGNORE;
} }
return false; return true; // 校验通过,交由 customRequest 执行实际上传
}; };
} }

View File

@@ -1,11 +1,16 @@
// ============================================================ // ============================================================
// auth-token — Token 生命周期管理(存取 refresh_token + 注册刷新回调) // auth-token — Token 生命周期管理(存取 refresh_token + 刷新回调 + 主动续期
// //
// 职责: // 职责:
// - 持久化 / 清除 refresh_token通过 safeStorage 加密) // - 持久化 / 清除 refresh_token通过 safeStorage 加密)
// - 向 request.ts 注册 handle401 触发的 token 刷新回调 // - 向 request.ts 注册 handle401 触发的 token 刷新回调
// - 主动刷新定时器:在 access_token 过期前自动续期,避免被动 401
// - request.ts 不依赖此模块(通过 setTokenRefreshHandler 解耦) // - request.ts 不依赖此模块(通过 setTokenRefreshHandler 解耦)
// //
// 刷新失败策略(关键设计):
// - 永久失败refresh_token 已过期/无效)→ THROW → handle401 清 token + 弹登录
// - 临时失败(网络超时/无连接) → return null → handle401 仅拒绝当前请求,不登出
//
// 安全: // 安全:
// - refresh_token 由 Electron safeStorage 加密后存入 localStorage // - refresh_token 由 Electron safeStorage 加密后存入 localStorage
// - 内存缓存供同步读取setTokenRefreshHandler 回调内使用) // - 内存缓存供同步读取setTokenRefreshHandler 回调内使用)
@@ -23,7 +28,24 @@ import {
const REFRESH_KEY = 'ele-heixiu-refresh-token'; const REFRESH_KEY = 'ele-heixiu-refresh-token';
// ---------- 初始化 ---------- // ============================================================
// 自定义错误refresh_token 永久失效
// ============================================================
/**
* 当 refresh_token 已过期或被服务端拒绝时抛出。
* handle401 据此区分"需要重新登录"和"网络暂时不通"。
*/
export class RefreshTokenExpiredError extends Error {
constructor(message = '登录已过期,请重新登录') {
super(message);
this.name = 'RefreshTokenExpiredError';
}
}
// ============================================================
// 初始化
// ============================================================
/** /**
* 启动时初始化 refresh_token从加密存储解密到内存缓存。 * 启动时初始化 refresh_token从加密存储解密到内存缓存。
@@ -33,7 +55,9 @@ export async function initRefreshTokenStore(): Promise<void> {
await initSafeStore(REFRESH_KEY); await initSafeStore(REFRESH_KEY);
} }
// ---------- 存取 ---------- // ============================================================
// 存取
// ============================================================
/** 获取当前内存缓存中的 refresh_token同步需先调用 initRefreshTokenStore */ /** 获取当前内存缓存中的 refresh_token同步需先调用 initRefreshTokenStore */
export function getStoredRefreshToken(): string | null { export function getStoredRefreshToken(): string | null {
@@ -50,19 +74,92 @@ export function clearStoredRefreshToken(): void {
clearSafeStore(REFRESH_KEY); clearSafeStore(REFRESH_KEY);
} }
// ---------- 初始化App 启动时调用一次) ---------- // ============================================================
// JWT 解码(用于主动刷新定时器)
// ============================================================
/** 从 JWT access_token 中提取过期时间(毫秒时间戳),解码失败返回 0 */
function getTokenExpiryMs(token: string): number {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return (payload.exp as number) * 1000;
} catch {
return 0;
}
}
// ============================================================
// 主动刷新定时器
// ============================================================
/** 主动刷新定时器句柄(模块级单例) */
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
/**
* 安排在 access_token 过期前自动刷新。
* 在 75% 过期时间点触发,确保在被动 401 之前完成续期。
*
* @param accessToken - 当前有效的 access_tokenJWT 格式)
*/
export function scheduleProactiveRefresh(accessToken: string): void {
// 先清除旧定时器
clearProactiveRefresh();
const expiryMs = getTokenExpiryMs(accessToken);
if (!expiryMs) return;
const remainingMs = expiryMs - Date.now();
if (remainingMs <= 0) return; // 已经过期,等被动刷新
// 在 75% 过期时间点触发
const delayMs = Math.max(60_000, remainingMs * 0.75); // 至少 60s 后再刷新
refreshTimer = setTimeout(async () => {
refreshTimer = null;
try {
const refreshToken = getStoredRefreshToken();
if (!refreshToken) return;
const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken });
await setToken(res.access_token);
await setStoredRefreshToken(res.refresh_token);
// 成功 → 安排下一次刷新
scheduleProactiveRefresh(res.access_token);
} catch {
// 主动刷新失败 → 不弹登录,等请求 401 时走被动刷新
}
}, delayMs);
}
/** 清除主动刷新定时器(退出登录时调用) */
export function clearProactiveRefresh(): void {
if (refreshTimer !== null) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
}
// ============================================================
// 向 request.ts 注册 401 刷新回调(被动刷新)
// ============================================================
/** /**
* 向 request.ts 注册 token 刷新回调。 * 向 request.ts 注册 token 刷新回调。
* 当任意请求收到 401 时request.ts 会调用此回调尝试换新 token
* 成功后自动重试原请求,失败则 emit AUTH_REQUIRED。
* *
* 应在 AppProvider 挂载时调用。 * 返回约定(由 handle401 解释):
* - 成功返回新 access_token → handle401 重试原请求
* - return null → 临时失败网络问题handle401 拒绝当前请求但不登出
* - throw RefreshTokenExpiredError → 永久失败handle401 清 token + 弹登录
*
* 应在 AppProvider 挂载时调用一次。
*/ */
export function initAuthRefresh(): void { export function initAuthRefresh(): void {
setTokenRefreshHandler(async (): Promise<string | null> => { setTokenRefreshHandler(async (): Promise<string | null> => {
const refreshToken = getStoredRefreshToken(); const refreshToken = getStoredRefreshToken();
if (!refreshToken) return null; if (!refreshToken) {
throw new RefreshTokenExpiredError();
}
try { try {
const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken }); const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken });
@@ -71,26 +168,29 @@ export function initAuthRefresh(): void {
await setToken(res.access_token); await setToken(res.access_token);
await setStoredRefreshToken(res.refresh_token); await setStoredRefreshToken(res.refresh_token);
// 主动刷新:为新 token 安排下次续期
scheduleProactiveRefresh(res.access_token);
return res.access_token; return res.access_token;
} catch (err) { } catch (err) {
// 区分错误类型: // ---- 永久失败refresh_token 已过期/无效 ----
// - RefreshError → refresh_token 已过期/无效 → 清除
// - 网络错误NetworkError/Timeout→ 保留 refresh_token等网络恢复后重试
// - 其他认证错误 → 清除
if (isRefreshError(err)) { if (isRefreshError(err)) {
clearStoredRefreshToken(); clearStoredRefreshToken();
} else if (err instanceof RequestError) { clearProactiveRefresh();
throw new RefreshTokenExpiredError();
}
// ---- 临时失败:网络超时/无连接 → 保留 token下次再试 ----
if (err instanceof RequestError) {
if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) { if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) {
// 网络暂时不可用,保留 refresh_token等下次 401 时再试 return null; // 临时失败,不登出
return null;
} }
// HTTP/Business 错误(如 5xx→ 也保留,可能是服务端临时故障
clearStoredRefreshToken();
} else {
// 未知错误 → 保守清除
clearStoredRefreshToken();
} }
return null;
// ---- HTTP/Business 错误(如 5xx→ 保守清除 ----
clearStoredRefreshToken();
clearProactiveRefresh();
throw new RefreshTokenExpiredError();
} }
}); });
} }

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 { 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 */ /** 单条 Banner */
export interface Banner { export interface Banner {
id: number; id: number;
@@ -20,7 +36,7 @@ export interface Banner {
content: "真人素材提示"; content: "真人素材提示";
image_url: string; image_url: string;
link_url?: string; link_url?: string;
category: "promotion" | "banner"; category: BannerCategoryValue;
vip_level_id?: number; vip_level_id?: number;
sort_order: number; sort_order: number;
is_active: boolean; is_active: boolean;

View File

@@ -55,3 +55,37 @@ export class BillingAPI {
static exportUrl = BillingUrlObj.ExportLedger; 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 {get, post} from '../request';
import {getDeviceId} from '@/utils/device.ts'; 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 type TaskStatusString = keyof typeof TaskStatusMap;
/** 需要持续轮询的非终态状态 */
export const POLLING_STATUSES: TaskStatusString[] = ['pending', 'submitted', 'processing'];
// ============================================================ // ============================================================
// 请求体 / 参数类型 // 请求体 / 参数类型
// ============================================================ // ============================================================
@@ -62,6 +66,23 @@ export interface CreatTaskRequestBody {
tags?: string[]; tags?: string[];
} }
/**
* 批量任务轮询查询参数
*/
export interface BatchTaskStatusRequestParam {
task_ids: string[];
}
/**
* 批量任务状态轮询响应头
*/
export interface BatchTaskStatusResponseBody {
items: Nullable<TaskStatusResponseBody>[],
balance_cent: number,
next_poll_ms: number;
}
/** 导出 CSV 参数 */ /** 导出 CSV 参数 */
export interface ExportTaskCSVParams { export interface ExportTaskCSVParams {
start_date: Date; start_date: Date;
@@ -183,11 +204,19 @@ export interface TaskInfoDataResponseBody {
export interface TaskStatusResponseBody { export interface TaskStatusResponseBody {
task_id: string; task_id: string;
status: TaskStatusString; status: TaskStatusString;
progress: number; progress?: number;
output_assets?: string[]; output_assets?: string[];
error_code?: string; error_code?: string;
error_message?: string; error_message?: string;
cost_cent?: number; 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}`, taskInfo: (task_id: string) => `/api/v1/tasks/${task_id}`,
taskStatus: (task_id: string) => `/api/v1/tasks/${task_id}/status`, taskStatus: (task_id: string) => `/api/v1/tasks/${task_id}/status`,
tasksCSV: '/api/v1/tasks/export', tasksCSV: '/api/v1/tasks/export',
batchTaskStatus: "/api/v1/tasks/status/batch"
} as const; } as const;
// ============================================================ // ============================================================
@@ -229,6 +259,11 @@ export class TaskAPI {
return get<TaskStatusResponseBody>(TaskUrlObj.taskStatus(task_id)); 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 */ /** 导出任务 CSV */
static exportTaskCSV(params: ExportTaskCSVParams): Promise<File> { static exportTaskCSV(params: ExportTaskCSVParams): Promise<File> {
return get<File>(TaskUrlObj.tasksCSV, params as unknown as Record<string, unknown>); return get<File>(TaskUrlObj.tasksCSV, params as unknown as Record<string, unknown>);

View File

@@ -145,21 +145,23 @@ function handle401(config: InternalAxiosRequestConfig): Promise<AxiosResponse<Ap
refreshPromise! refreshPromise!
.then((newToken) => { .then((newToken) => {
if (newToken) { if (newToken) {
// 刷新成功 → 用新 token 重试队列和当前请求
processQueue(null, newToken); processQueue(null, newToken);
config.headers.Authorization = `Bearer ${newToken}`; config.headers.Authorization = `Bearer ${newToken}`;
resolve(instance(config)); resolve(instance(config));
} else { } else {
const err = new RefreshError(); // null = 临时失败(网络超时/无连接)
processQueue(err, null); // → 保留 token仅拒绝当前请求下次 401 再试(不弹登录)
clearToken(); processQueue(new RefreshError(), null);
emit(EVENTS.AUTH_REQUIRED); reject(new RefreshError());
reject(err);
} }
isRefreshing = false; isRefreshing = false;
refreshPromise = null; refreshPromise = null;
}) })
.catch((err) => { .catch((_err) => {
processQueue(err, null); // throw = 永久失败refresh_token 已过期/无效)
// → 清 token + 弹登录
processQueue(_err, null);
isRefreshing = false; isRefreshing = false;
refreshPromise = null; refreshPromise = null;
clearToken(); clearToken();

153
src/utils/enum-resolver.ts Normal file
View File

@@ -0,0 +1,153 @@
// ============================================================
// EnumResolver — 统一枚举解析器Facade Pattern
//
// 聚合所有业务模块的 LabelMap/ColorMap提供类型安全的
// 字典查询 + 运行时兜底。
//
// 设计意图:
// - 单一入口:组件只需 import EnumResolver无需分别导入各模块
// - 类型安全label/color 参数为具体枚举字面量,编译时阻止拼写错误
// - 运行时兜底:通过 || value || '默认文案' 处理后端新增枚举值
//
// 设计模式:
// Facade外观 — 屏蔽 7 个模块的复杂性,暴露统一 API
// Namespace Object — 按业务域二级分组
// Strategy — 每个 leaf 是可替换的解析策略
//
// 使用示例:
// EnumResolver.billing.entryType.label('task_hold') // "任务消耗"
// EnumResolver.auth.userRole.label('admin') // "管理账户"
// EnumResolver.announcement.type.color('feature') // "purple"
// ============================================================
import {
EntryTypeLabelMap,
EntryTypeColorMap,
type EntryTypeValue,
} from '@/services/modules/billing';
import {
UserRoleTypeLabelMap,
UserAccountTypeLabelMap,
type UserRoleTypeValue,
type UserAccountTypeValue,
} from '@/services/modules/auth';
import {
VipOrderStatusLabelMap,
TargetAccountTypeLabelMap,
type VipOrderStatusValue,
type TargetAccountTypeValue,
} from '@/services/modules/vip-api';
import {
OutputTypeMap,
TaskStatusMap,
type OutputTypeString,
type TaskStatusString,
} from '@/services/modules/task';
import {
BannerCategoryLabelMap,
type BannerCategoryValue,
} from '@/services/modules/banner';
import {
AnnouncementTypeLabelMap,
AnnouncementTypeColorMap,
type AnnouncementTypeValue,
} from '@/services/modules/announcement';
import {
MODEL_CATEGORY_LABELS,
CATEGORY_SLUG_MAP,
type ModelCategoryStringEnum,
} from '@/services/modules/models';
// ============================================================
// 模型分类 slug → 前端标识反向映射
// ============================================================
const SLUG_TO_CATEGORY: Record<string, string> = Object.fromEntries(
Object.entries(CATEGORY_SLUG_MAP).map(([cat, slug]) => [slug as string, cat]),
);
// ============================================================
// EnumResolver
// ============================================================
export const EnumResolver = {
/** 账单 */
billing: {
entryType: {
label: (value: EntryTypeValue) =>
EntryTypeLabelMap[value] || value || '未知账单类型',
color: (value: EntryTypeValue) =>
EntryTypeColorMap[value] || 'default',
},
},
/** 用户身份 */
auth: {
userRole: {
label: (value: UserRoleTypeValue) =>
UserRoleTypeLabelMap[value] || value || '未知角色',
},
accountType: {
label: (value: UserAccountTypeValue) =>
UserAccountTypeLabelMap[value] || value || '未知账户类型',
},
},
/** 任务 */
task: {
outputType: {
label: (value: OutputTypeString) =>
OutputTypeMap[value] || value || '未知输出类型',
},
status: {
label: (value: TaskStatusString) =>
TaskStatusMap[value] || value || '未知任务状态',
},
},
/** VIP */
vip: {
orderStatus: {
label: (value: VipOrderStatusValue) =>
VipOrderStatusLabelMap[value] || value || '未知订单状态',
},
targetAccountType: {
label: (value: TargetAccountTypeValue) =>
TargetAccountTypeLabelMap[value] || value || '未知账户类型',
},
},
/** Banner */
banner: {
category: {
label: (value: BannerCategoryValue) =>
BannerCategoryLabelMap[value] || value || '未知分类',
},
},
/** 公告 */
announcement: {
type: {
label: (value: AnnouncementTypeValue) =>
AnnouncementTypeLabelMap[value] || value || '未知公告类型',
color: (value: AnnouncementTypeValue) =>
AnnouncementTypeColorMap[value] || 'default',
},
},
/** 模型分类(两步解析:前端字面量 > 后端 slug 反向映射 > 兜底) */
model: {
category: {
label: (value: ModelCategoryStringEnum) =>
MODEL_CATEGORY_LABELS[value] ||
MODEL_CATEGORY_LABELS[SLUG_TO_CATEGORY[value] as ModelCategoryStringEnum] ||
value,
},
},
};