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:
47
CLAUDE.md
47
CLAUDE.md
@@ -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服务
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
setStoredRefreshToken,
|
||||
clearStoredRefreshToken,
|
||||
initRefreshTokenStore,
|
||||
scheduleProactiveRefresh,
|
||||
clearProactiveRefresh,
|
||||
} from '@/services/auth-token';
|
||||
import {initModelCache} from '@/services/cache/model-cache';
|
||||
|
||||
@@ -89,6 +91,8 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
const login = useCallback(async (auth: LoginResponseBody) => {
|
||||
await setToken(auth.access_token);
|
||||
await setStoredRefreshToken(auth.refresh_token);
|
||||
// 启动主动刷新定时器(在 access_token 过期前自动续期)
|
||||
scheduleProactiveRefresh(auth.access_token);
|
||||
setUser(auth);
|
||||
setIsLoggedIn(true);
|
||||
logger.info('auth', 'User logged in', {
|
||||
@@ -102,6 +106,7 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
logger.info('auth', 'User logged out');
|
||||
clearToken();
|
||||
clearStoredRefreshToken();
|
||||
clearProactiveRefresh();
|
||||
setUser(null);
|
||||
setIsLoggedIn(false);
|
||||
emit(EVENTS.LOGOUT);
|
||||
@@ -133,6 +138,7 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
if (aborted) return;
|
||||
await setToken(res.access_token);
|
||||
await setStoredRefreshToken(res.refresh_token);
|
||||
scheduleProactiveRefresh(res.access_token);
|
||||
setIsLoggedIn(true);
|
||||
logger.info('auth', 'Auto-login succeeded');
|
||||
// 补全用户信息
|
||||
|
||||
@@ -90,7 +90,12 @@ const PAGE_MAP: Record<string, PageConfig> = {
|
||||
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),
|
||||
},
|
||||
};
|
||||
|
||||
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
|
||||
|
||||
@@ -7,28 +7,13 @@ import {useEffect, useState, useCallback} from 'react';
|
||||
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
|
||||
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;
|
||||
|
||||
// ---------- 辅助 ----------
|
||||
|
||||
/** 公告类型 → 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) */
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
@@ -132,10 +117,10 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
|
||||
}
|
||||
extra={
|
||||
<Tag
|
||||
color={typeColorMap[item.type] || 'default'}
|
||||
color={EnumResolver.announcement.type.color(item.type as AnnouncementTypeValue)}
|
||||
className="m-0! text-xs"
|
||||
>
|
||||
{typeLabelMap[item.type] || item.type}
|
||||
{EnumResolver.announcement.type.label(item.type as AnnouncementTypeValue)}
|
||||
</Tag>
|
||||
}
|
||||
onClick={() => setDetail(item)}
|
||||
@@ -177,10 +162,10 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag
|
||||
color={typeColorMap[detail.type] || 'default'}
|
||||
color={EnumResolver.announcement.type.color(detail.type as AnnouncementTypeValue)}
|
||||
className="m-0! text-xs"
|
||||
>
|
||||
{typeLabelMap[detail.type] || detail.type}
|
||||
{EnumResolver.announcement.type.label(detail.type as AnnouncementTypeValue)}
|
||||
</Tag>
|
||||
<Text type="secondary" className="text-xs">
|
||||
{formatDate(detail.created_at)}
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
// - 用户可读错误信息(errorMessage)
|
||||
// ============================================================
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useMemo, useState, useRef, useEffect, useCallback} from 'react';
|
||||
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 { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||
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 {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
||||
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
|
||||
import { upload } from '@/services/request';
|
||||
|
||||
// ============================================================
|
||||
// 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};
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
BarChartOutlined,
|
||||
RightOutlined,
|
||||
} 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 { useAccountInfo } from '@/hooks/use-api';
|
||||
import { centToYuan, formatDate } from '@/utils/display';
|
||||
@@ -75,7 +75,7 @@ export function AccountInfo() {
|
||||
{userInfo.display_name || userInfo.real_name || userInfo.username || userInfo.id}
|
||||
{userInfo.account_type && (
|
||||
<Tag color="gold" style={{lineHeight: '18px', fontSize: 11}}>
|
||||
{UserAccountTypeLabelMap[userInfo.account_type]}
|
||||
{EnumResolver.auth.accountType.label(userInfo.account_type)}
|
||||
</Tag>
|
||||
)}
|
||||
</Title>
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
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';
|
||||
|
||||
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() {
|
||||
const {token} = antTheme.useToken();
|
||||
@@ -163,9 +140,9 @@ function LedgerTable() {
|
||||
key: 'entry_type',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (type: string) => (
|
||||
<Tag color={entryTypeColor(type)} style={{fontSize: 11, lineHeight: '18px'}}>
|
||||
{resolveEntryLabel(type)}
|
||||
render: (type: EntryTypeValue) => (
|
||||
<Tag color={EnumResolver.billing.entryType.color(type)} style={{fontSize: 11, lineHeight: '18px'}}>
|
||||
{EnumResolver.billing.entryType.label(type)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
||||
// ============================================================
|
||||
|
||||
import { useState, useLayoutEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useLayoutEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Space,
|
||||
theme as antTheme,
|
||||
message,
|
||||
Modal,
|
||||
} from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
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;
|
||||
|
||||
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
||||
|
||||
const fields: UIFieldConfig[] = useModelUI(
|
||||
@@ -190,15 +201,20 @@ export function ModelInputForm() {
|
||||
|
||||
// ======== 提交 ========
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isLoggedIn) {
|
||||
message.warning('请先登录后再提交任务');
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* 实际提交逻辑(在防抖检查和二次确认之后执行)。
|
||||
* 用 useCallback 稳定化,避免 handleSubmit 因闭包过期而读到旧 fields/values。
|
||||
*/
|
||||
const doSubmit = useCallback(async () => {
|
||||
if (!selectedModel) return;
|
||||
|
||||
let values: Record<string, unknown>;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
// 表单校验失败 → antd 自动展示字段错误,静默返回
|
||||
return;
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
@@ -226,18 +242,55 @@ export function ModelInputForm() {
|
||||
params[field.name] = String(value);
|
||||
}
|
||||
|
||||
await submitTask({
|
||||
// useAsyncMutation.execute 内部 catch 所有错误,返回 undefined 表示失败
|
||||
const result = await submitTask({
|
||||
model_id: selectedModel.id,
|
||||
params,
|
||||
...(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;
|
||||
}
|
||||
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]);
|
||||
|
||||
// ======== 动态预估消费金额(必须在所有提前返回之前) ========
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as a
|
||||
import { AppstoreOutlined } from '@ant-design/icons';
|
||||
|
||||
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;
|
||||
|
||||
@@ -132,7 +133,7 @@ export function ModelSelector({ models, loading, error, groupedModels, refetch }
|
||||
seenCategories.add(cat);
|
||||
items.push({
|
||||
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) {
|
||||
items.push({
|
||||
key: category,
|
||||
label: `${resolveCategoryLabel(category)} (${groupModels.length})`,
|
||||
label: `${EnumResolver.model.category.label(category as ModelCategoryStringEnum)} (${groupModels.length})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 使用 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 {
|
||||
EyeOutlined,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
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;
|
||||
|
||||
@@ -50,7 +50,30 @@ export function OutputPreview() {
|
||||
const { selectedTask } = useHomeContext();
|
||||
|
||||
// 数据获取(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 提取图片/视频
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
|
||||
<Spin size="large" />
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ColumnsType, TableProps } from 'antd/es/table';
|
||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
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 { useAppContext } from '@/components/AppProvider';
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
@@ -286,6 +286,22 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 从任务数据中提取用户列表(企业版筛选用)
|
||||
@@ -719,7 +735,7 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
|
||||
<Table<TaskInfoItemResponseBody>
|
||||
className="task-table"
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
dataSource={polledTasks}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Upload } from 'antd';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
@@ -16,6 +17,8 @@ export function AudioListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
const maxCount = (config.maxFiles as number) || 10;
|
||||
const filters = config.fileFilter as string[] | undefined;
|
||||
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
return (
|
||||
<Dragger
|
||||
multiple
|
||||
@@ -25,9 +28,10 @@ export function AudioListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
: '.wav,.mp3,.m4a,.flac'
|
||||
}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||
disabled={disabled}
|
||||
beforeUpload={() => false}
|
||||
beforeUpload={() => true}
|
||||
maxCount={maxCount}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
|
||||
@@ -19,6 +19,7 @@ import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { createImageBeforeUpload } from './uploadValidation';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -36,6 +37,9 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
||||
|
||||
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
// 有 enable_switch 时默认禁用,需手动开启
|
||||
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
|
||||
const isUploadDisabled = disabled || !switchOn;
|
||||
@@ -75,6 +79,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
maxCount={1}
|
||||
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => {
|
||||
// 仅当开关开启时上报文件变化
|
||||
if (switchOn || !hasEnableSwitch) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InboxOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { createImageBeforeUpload } from './uploadValidation';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
@@ -17,6 +18,8 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
const maxCount = (config.maxFiles as number) || 10;
|
||||
const filters = config.fileFilter as string[] | undefined;
|
||||
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
return (
|
||||
<Dragger
|
||||
multiple
|
||||
@@ -27,6 +30,7 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
: 'image/png,image/jpg,image/jpeg,image/webp'
|
||||
}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||
disabled={disabled}
|
||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||
|
||||
@@ -21,6 +21,6 @@ export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return false;
|
||||
return true; // 校验通过,交由 customRequest 执行实际上传
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// ============================================================
|
||||
// auth-token — Token 生命周期管理(存取 refresh_token + 注册刷新回调)
|
||||
// auth-token — Token 生命周期管理(存取 refresh_token + 刷新回调 + 主动续期)
|
||||
//
|
||||
// 职责:
|
||||
// - 持久化 / 清除 refresh_token(通过 safeStorage 加密)
|
||||
// - 向 request.ts 注册 handle401 触发的 token 刷新回调
|
||||
// - 主动刷新定时器:在 access_token 过期前自动续期,避免被动 401
|
||||
// - request.ts 不依赖此模块(通过 setTokenRefreshHandler 解耦)
|
||||
//
|
||||
// 刷新失败策略(关键设计):
|
||||
// - 永久失败(refresh_token 已过期/无效)→ THROW → handle401 清 token + 弹登录
|
||||
// - 临时失败(网络超时/无连接) → return null → handle401 仅拒绝当前请求,不登出
|
||||
//
|
||||
// 安全:
|
||||
// - refresh_token 由 Electron safeStorage 加密后存入 localStorage
|
||||
// - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用)
|
||||
@@ -23,7 +28,24 @@ import {
|
||||
|
||||
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:从加密存储解密到内存缓存。
|
||||
@@ -33,7 +55,9 @@ export async function initRefreshTokenStore(): Promise<void> {
|
||||
await initSafeStore(REFRESH_KEY);
|
||||
}
|
||||
|
||||
// ---------- 存取 ----------
|
||||
// ============================================================
|
||||
// 存取
|
||||
// ============================================================
|
||||
|
||||
/** 获取当前内存缓存中的 refresh_token(同步,需先调用 initRefreshTokenStore) */
|
||||
export function getStoredRefreshToken(): string | null {
|
||||
@@ -50,19 +74,92 @@ export function clearStoredRefreshToken(): void {
|
||||
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_token(JWT 格式)
|
||||
*/
|
||||
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 刷新回调。
|
||||
* 当任意请求收到 401 时,request.ts 会调用此回调尝试换新 token,
|
||||
* 成功后自动重试原请求,失败则 emit AUTH_REQUIRED。
|
||||
*
|
||||
* 应在 AppProvider 挂载时调用。
|
||||
* 返回约定(由 handle401 解释):
|
||||
* - 成功返回新 access_token → handle401 重试原请求
|
||||
* - return null → 临时失败(网络问题),handle401 拒绝当前请求但不登出
|
||||
* - throw RefreshTokenExpiredError → 永久失败,handle401 清 token + 弹登录
|
||||
*
|
||||
* 应在 AppProvider 挂载时调用一次。
|
||||
*/
|
||||
export function initAuthRefresh(): void {
|
||||
setTokenRefreshHandler(async (): Promise<string | null> => {
|
||||
const refreshToken = getStoredRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
if (!refreshToken) {
|
||||
throw new RefreshTokenExpiredError();
|
||||
}
|
||||
|
||||
try {
|
||||
const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken });
|
||||
@@ -71,26 +168,29 @@ export function initAuthRefresh(): void {
|
||||
await setToken(res.access_token);
|
||||
await setStoredRefreshToken(res.refresh_token);
|
||||
|
||||
// 主动刷新:为新 token 安排下次续期
|
||||
scheduleProactiveRefresh(res.access_token);
|
||||
|
||||
return res.access_token;
|
||||
} catch (err) {
|
||||
// 区分错误类型:
|
||||
// - RefreshError → refresh_token 已过期/无效 → 清除
|
||||
// - 网络错误(NetworkError/Timeout)→ 保留 refresh_token,等网络恢复后重试
|
||||
// - 其他认证错误 → 清除
|
||||
// ---- 永久失败:refresh_token 已过期/无效 ----
|
||||
if (isRefreshError(err)) {
|
||||
clearStoredRefreshToken();
|
||||
} else if (err instanceof RequestError) {
|
||||
clearProactiveRefresh();
|
||||
throw new RefreshTokenExpiredError();
|
||||
}
|
||||
|
||||
// ---- 临时失败:网络超时/无连接 → 保留 token,下次再试 ----
|
||||
if (err instanceof RequestError) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,3 +55,37 @@ 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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
// ============================================================
|
||||
@@ -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>);
|
||||
|
||||
@@ -145,21 +145,23 @@ function handle401(config: InternalAxiosRequestConfig): Promise<AxiosResponse<Ap
|
||||
refreshPromise!
|
||||
.then((newToken) => {
|
||||
if (newToken) {
|
||||
// 刷新成功 → 用新 token 重试队列和当前请求
|
||||
processQueue(null, newToken);
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
resolve(instance(config));
|
||||
} else {
|
||||
const err = new RefreshError();
|
||||
processQueue(err, null);
|
||||
clearToken();
|
||||
emit(EVENTS.AUTH_REQUIRED);
|
||||
reject(err);
|
||||
// null = 临时失败(网络超时/无连接)
|
||||
// → 保留 token,仅拒绝当前请求;下次 401 再试(不弹登录)
|
||||
processQueue(new RefreshError(), null);
|
||||
reject(new RefreshError());
|
||||
}
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
})
|
||||
.catch((err) => {
|
||||
processQueue(err, null);
|
||||
.catch((_err) => {
|
||||
// throw = 永久失败(refresh_token 已过期/无效)
|
||||
// → 清 token + 弹登录
|
||||
processQueue(_err, null);
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
clearToken();
|
||||
|
||||
153
src/utils/enum-resolver.ts
Normal file
153
src/utils/enum-resolver.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user