Compare commits

...

3 Commits

Author SHA1 Message Date
bc33b81121 docs : 更新文档CLAUDE.md 2026-06-12 19:18:55 +08:00
54d12aaf26 docs: 更新架构文档、版本日志、TODO清单 (0.0.20)
- ARCHITECTURE.md: 新增任务轮询/文件上传/提交防抖/主动刷新定时器/失败分级五节,更新 hooks 目录结构,日期→06-12
- CHANGELOG.md: 新增 0.0.20 版本条目
- TODO.md: 标记公告接入/任务轮询为已完成,新增任务CSV导出/上传端点确认待办
- CLAUDE.md 保持不动(按规则不修改)
2026-06-12 19:14:39 +08:00
9e8c5b7109 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 补充架构约定文档
2026-06-12 19:03:36 +08:00
25 changed files with 985 additions and 156 deletions

View File

@@ -1,6 +1,6 @@
# 船长 · HeiXiu — 前端架构文档
> 最后更新2026-06-09
> 最后更新2026-06-12
## 目录结构
@@ -14,6 +14,8 @@ src/
├── contexts/ # React Context 定义 + Consumer Hook
│ └── app-context.ts # AppContextValue 类型 + useAppContext()
├── hooks/ # 通用 Hook
│ ├── use-async.ts # 通用异步数据 HookuseAsyncData / useAsyncMutation
│ ├── use-api.ts # 业务 API Hook 集中管理Banner/Model/Task/Auth/Billing
│ ├── use-theme.ts # 主题切换
│ └── use-updater.ts # 版本更新
├── pages/ # 页面
@@ -213,6 +215,49 @@ App 启动 → AppProvider useEffect
> 只有 RefreshErrorrefresh 接口返回 401才确认 token 真的过期。
```
### 3.2 主动刷新定时器v0.0.20
除被动 401 刷新外,系统通过 JWT `exp` 声明主动续期,避免长时间空闲后 access_token 过期:
```
登录/刷新成功
→ scheduleProactiveRefresh(access_token)
→ 解码 JWT exp 声明
→ setTimeout(delay = max(60s, remaining * 0.75))
→ 到期触发 → getStoredRefreshToken()
→ AuthAPI.refreshToken()
✅ 成功 → 更新双 token → 递归安排下一次刷新
❌ 失败 → 静默(等下次 401 走被动刷新)
```
- 主动刷新用 JWT 解码(`atob(token.split('.')[1])`)获取 `exp`,不依赖服务端 `expires_in`
- 在 75% 过期时间点触发,确保在被动 401 之前完成续期
- 至少间隔 60s防止 token 极短时频繁刷新)
- 退出登录时 `clearProactiveRefresh()` 清除定时器
### 3.3 刷新失败分级处理v0.0.20
`refreshHandler` 通过 Promise 状态区分失败级别,`handle401` 据此决定是否登出:
| 失败类型 | refreshHandler 行为 | handle401 响应 |
|---------|-------------------|---------------|
| refresh_token 过期/无效 | **throw** `RefreshTokenExpiredError` | 清 token + emit AUTH_REQUIRED → 弹登录 |
| 网络超时/无连接 | **return null** | 仅拒绝当前请求,保留 token → 下次再试 |
**关键改进**:临时网络故障不再误判为"需要重新登录"。用户在网络恢复后重试操作即可,无需手动登录。
```
handle401 内部:
refreshHandler()
.then(newToken =>
newToken → ✅ 用新 token 重试
null → ⚠️ 临时失败拒绝但不登出processQueue 失败,但 clearToken 不调用)
)
.catch(_err →
❌ 永久失败 → clearToken + emit(AUTH_REQUIRED) + 弹登录
)
```
### 4. 退出登录
```
@@ -382,3 +427,120 @@ export class AuthAPI {
- Mac 命令执行:`spawnSync` + 5s 超时,`ioreg`/`sysctl` 始终可用
- 共享同一套 `SHA256(normalizeIdentifier(combined))` 管线
- `getMachineCode()` 跨平台统一接口,自动选择最优采集方式
---
## 任务状态轮询v0.0.20
### 架构:两层轮询 + Context 桥接
```
TaskHistory (列表页)
├─ useTaskPolling(tasks, onTerminal) ← 批量轮询 batchTaskStatus
│ └─ 仅轮询 POLLING_STATUSES 中的非终态任务
│ └─ 轮询结果合并到 polledTasks → dataSource
│ └─ 某任务变为终态 → onTerminal → setRefreshTick(+1) → 完整列表刷新
└─ useEffect: polledTasks 变化 → 同步 selectedTask 到 Context
OutputPreview (详情面板)
└─ useEffect: 监听 selectedTask.status 变化
└─ 非终态→终态 → useTaskDetail.refetch() ← 仅触发详情刷新,不独立轮询
```
**设计原则**
- 批量轮询负责广度(列表视图状态更新),详情刷新负责深度(终态后获取 output_assets
- OutputPreview **不独立调用** `getTaskStatus`,避免与批量轮询重复请求
- 两者通过 `HomeContext.selectedTask` 桥接,不直接通信
### 轮询实现细节
| 特性 | 实现 |
|------|------|
| 调度方式 | 递归 `setTimeout`(非 `setInterval`),避免请求堆积 |
| 间隔控制 | 服务端 `next_poll_ms`,本地 clamp 2s~30s |
| 错误重试 | 请求失败后 10s 重试 |
| 自动停止 | 所有非终态任务变为终态后停止轮询 |
| 闭包安全 | `mergedRef.current` 确保递归回调读到最新任务列表 |
| 非终态常量 | `POLLING_STATUSES = ['pending', 'submitted', 'processing']` 统一管理 |
### GET 数组参数序列化
axios 默认 `paramsSerializer` 对数组使用 `key[]=v1&key[]=v2`PHP/Rack bracket 风格),后端不识别。修复方案:
```typescript
// ❌ 错误axios 默认序列化为 task_ids[]=v1&task_ids[]=v2
get(url, { task_ids: ['id1', 'id2'] })
// ✅ 正确:逗号拼接为 task_ids=id1,id2跨框架兼容
get(url, { task_ids: params.task_ids.join(',') })
```
---
## 文件上传v0.0.20
### customRequest 集成模式
antd Upload 组件通过 `action` 属性使用自有的 XMLHttpRequest不走 axios 拦截器,导致 Token 缺失。解决方案:
```
useFileUpload() hook
└─ 返回 { customRequest } — 兼容 antd Upload.customRequest 签名
└─ 内部调用 request.upload(url, formData, onProgress)
└─ 复用 axios 实例Token 注入、超时、错误处理、业务码校验
```
```
ImageInput / ImageListInput / AudioListInput
└─ const { customRequest } = useFileUpload();
└─ <Upload customRequest={customRequest} beforeUpload={...} />
└─ beforeUpload 校验通过 → 返回 true → 放行至 customRequest
└─ customRequest 调用 request.upload() → 上传到 /api/v1/upload
└─ 成功后 antd 将 response 写入 file.response
└─ ModelInputForm 提交时读取 f.response?.url
```
### beforeUpload 语义
| 返回值 | 含义 |
|-------|------|
| `true` | 校验通过,交由 customRequest 实际上传 |
| `false` | 阻止上传(文件仅加入列表,不发起请求) |
| `Upload.LIST_IGNORE` | 校验失败,文件不加入列表 |
---
## 表单提交防抖v0.0.20
### 两级防护
```
handleSubmit()
├─ 1. 防抖节流2s
│ └─ lastClickTimeRef: now - last < 2s → "你点击的太快了"
├─ 2. 二次确认
│ └─ submitCountRef > 0 → Modal.confirm("您已提交,确认要再次提交吗?")
│ └─ 确认 → lastClickTimeRef = 0重置防抖→ doSubmit()
└─ 3. 执行提交
└─ doSubmit(): form.validateFields() → submitTask() → 检查返回值
```
### useRef 而非 useState
`lastClickTimeRef``submitCountRef` 使用 `useRef` 而非 `useState`,避免每次点击触发不必要的组件重渲染。
### useAsyncMutation 返回值判空
`useAsyncMutation.execute()` 内部 catch 所有错误并返回 `undefined`(不 throw。调用方应检查返回值而非 `try/catch`
```typescript
const result = await submitTask({...});
if (result !== undefined) {
submitCountRef.current += 1; // 成功
} else {
message.error(errorMessageRef.current || '任务提交失败');
}
```

View File

@@ -3,6 +3,44 @@
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
---
## 0.0.202026-06-12
**任务状态轮询 + Token 刷新容错增强 + 提交防抖 + 文件上传 Hook**
### 任务状态轮询
- 新增 `useTaskPolling` hook批量轮询非终态任务状态递归 `setTimeout` + `next_poll_ms` 自适应间隔
- `TaskHistory` 集成批量轮询,`OutputPreview` 改为监听终态转换Context 桥接)触发 `refetch()`,消除重复请求
- `POLLING_STATUSES` 统一管理非终态状态,所有轮询逻辑引用此常量
- `batchTaskStatus` 使用 GET + 逗号拼接 `task_ids=a,b,c`,修复 axios 默认 bracket 序列化
### Token 刷新容错增强
- **主动刷新**JWT `exp` 解码驱动定时器,在 75% 过期点通过 `setTimeout` 提前续期
- **失败分级**`refreshHandler` 永久失败refresh_token 过期)→ **throw** → 清 token 弹登录;临时失败(网络超时)→ **return null** → 仅拒绝当前请求不登出
- 新增 `RefreshTokenExpiredError` 类区分失败类型
- `AppProvider` 登录/自动登录/退出 启停主动刷新定时器
### 提交按钮防抖
- 两级防护2s 节流防抖("你点击的太快了"+ 二次确认弹窗("您已提交,确认要再次提交吗?"
- `doSubmit` 抽取为 `useCallback``handleSubmit` 负责门控
- 修复 `useAsyncMutation.execute` 返回值判空(原 `try/catch` 为死代码)
### 文件上传 Hook
- 新增 `useFileUpload` hook封装 `request.upload()` 为 antd Upload 兼容 `customRequest`,复用全局拦截器
- `ImageInput` / `ImageListInput` / `AudioListInput` 集成 `customRequest`
- `uploadValidation.beforeUpload` 返回值修正:`false``true`(校验通过后放行至 `customRequest`
### 其他
- `AnnouncementDrawer` 接入公告 API + `EnumResolver`
- billing / announcement / banner 服务模块完善
- 文档ARCHITECTURE.md 新增轮询/Token刷新/上传/防抖章节
---
## 0.0.192026-06-11
**数据获取架构重构 + Mac 指纹采集 + Token 刷新容错 + 账单页面**

View File

@@ -1,6 +1,25 @@
# 回答语言
# 基本交代
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明;
- 有需要请自行调用MCP服务
# 业务设计思路
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录;
- 进行系统级别的操作时需要考虑系统差异并处理兼容性。如WINDOWS、MAC OS
- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获;
- 所有设计思路、代码实现均需要考虑以上三个条件。
# 相关文档说明
- 根目录下的文档包括:
- README.mdgit中的README文档
- WORKLOG.md工作日志文档
- UpdateA.md热更新相关的文档
- TODO.md未完善、未来要做的文档或者是在开发中遇到需要优化的地方
- PROJECT.md项目文档
- CHANGELOG.md版本更新文档
- CLAUDE.md
- 永远不要修改CLAUDE.md文档
- 在各个文档中的更新需要保持精简。且职责划分保持严谨除了架构、项目文档已完成的事需要从TODO.md文档中移除。
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录
- 进行系统级别的操作时需要考虑系统差异并处理兼容性。如WINDOWS、MAC OS。
- 有需要请自己调用MCP服务

21
TODO.md
View File

@@ -1,6 +1,6 @@
# TODO — 船长·HeiXiu 待办与优化清单
> 最后更新2026-06-09
> 最后更新2026-06-12
---
@@ -30,10 +30,17 @@
### 公告
- [x] 公告数据获取与展示AnnouncementDrawer + fetchAnnouncements API
- [ ] 公告已读/未读状态
- [ ] 公告红点/角标提醒(有新公告时导航栏图标变化)
- [ ] 公告缓存(减少重复请求)
### 任务
- [x] 任务状态轮询(批量轮询 + Context 桥接 + 自适应间隔)
- [ ] 任务列表导出 CSV 功能完善
- [ ] 任务重试/重新提交
---
## 🟢 工程优化(优先级低)
@@ -75,10 +82,12 @@
## 📋 下次开发计划
1. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现
2. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07
3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但
1. **任务列表导出 CSV**`task.ts` 已定义 `exportTaskCSV` API前端未对接下载按钮
2. **文件上传端点确认**`/api/v1/upload` 待后端确认,当前 useFileUpload 已完成前端集成
3. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现)
4. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07
5. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但
localStorage 持久层仍未实现)
4. **轮播图关闭按钮 localStorage 持久化**关闭按钮已实现待做localStorage 记录关闭时间戳,超过 N 天再显示)
5. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠
6. **轮播图关闭按钮 localStorage 持久化**关闭按钮已实现待做localStorage 记录关闭时间戳,超过 N 天再显示)
7. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠
60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API纯前端限制对脚本攻击无效

View File

@@ -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');
// 补全用户信息

View File

@@ -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),
},
};
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定)

View File

@@ -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)}

View File

@@ -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};

View File

@@ -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>

View File

@@ -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>
),
},

View File

@@ -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;
// ======== useModelUIparam_schema → UIFieldConfig[] ========
const fields: UIFieldConfig[] = useModelUI(
@@ -190,54 +201,96 @@ export function ModelInputForm() {
// ======== 提交 ========
const handleSubmit = async () => {
/**
* 实际提交逻辑(在防抖检查和二次确认之后执行)。
* 用 useCallback 稳定化,避免 handleSubmit 因闭包过期而读到旧 fields/values。
*/
const doSubmit = useCallback(async () => {
if (!selectedModel) return;
let values: Record<string, unknown>;
try {
values = await form.validateFields();
} catch {
// 表单校验失败 → antd 自动展示字段错误,静默返回
return;
}
const params: Record<string, string> = {};
for (const field of fields) {
const value = values[field.name];
if (value === undefined || value === null) continue;
if (field.isFileWidget) {
const fileList = value as UploadFile[];
const urls = fileList
.filter((f) => f.status === 'done')
.map((f) => f.response?.url || f.url || f.name)
.filter(Boolean) as string[];
if (urls.length > 0) {
params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
}
continue;
}
if (typeof value === 'boolean') {
params[field.name] = String(value);
continue;
}
params[field.name] = String(value);
}
// useAsyncMutation.execute 内部 catch 所有错误,返回 undefined 表示失败
const result = await submitTask({
model_id: selectedModel.id,
params,
...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}),
});
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;
}
if (!selectedModel) return;
try {
const values = await form.validateFields();
const params: Record<string, string> = {};
for (const field of fields) {
const value = values[field.name];
if (value === undefined || value === null) continue;
if (field.isFileWidget) {
const fileList = value as UploadFile[];
const urls = fileList
.filter((f) => f.status === 'done')
.map((f) => f.response?.url || f.url || f.name)
.filter(Boolean) as string[];
if (urls.length > 0) {
params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
}
continue;
}
if (typeof value === 'boolean') {
params[field.name] = String(value);
continue;
}
params[field.name] = String(value);
}
await submitTask({
model_id: selectedModel.id,
params,
...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}),
});
} catch (err) {
if (err && typeof err === 'object' && 'errorFields' in err) {
return;
}
message.error(errorMessage || '任务提交失败,请重试');
// ---- 防抖节流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 { 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})`,
});
}
});

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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)}

View File

@@ -21,6 +21,6 @@ export function createImageBeforeUpload(maxSizeMb: number = 10) {
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
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 加密)
// - 向 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_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 刷新回调。
* 当任意请求收到 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) {
if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) {
// 网络暂时不可用,保留 refresh_token等下次 401 时再试
return null;
}
// HTTP/Business 错误(如 5xx→ 也保留,可能是服务端临时故障
clearStoredRefreshToken();
} else {
// 未知错误 → 保守清除
clearStoredRefreshToken();
clearProactiveRefresh();
throw new RefreshTokenExpiredError();
}
return null;
// ---- 临时失败:网络超时/无连接 → 保留 token下次再试 ----
if (err instanceof RequestError) {
if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) {
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 {

View File

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

View File

@@ -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];

View File

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

View File

@@ -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
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,
},
},
};