From 4237dcbaf6e556b86d2729ac77f5668ece3c642c Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Thu, 11 Jun 2026 20:08:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E9=87=8D=E6=9E=84=20+=20Mac=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=E9=87=87=E9=9B=86=20+=20Token=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E5=AE=B9=E9=94=99=20+=20=E8=B4=A6=E5=8D=95=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=20(0.0.19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 架构重构 - useAsyncData 集成 AbortController:cleanup 时真正中断 HTTP 请求, 不再仅丢弃响应(解决 StrictMode 双重请求问题) - API 模块统一 signal?: AbortSignal 参数,透传 axios config - use-api.ts 成为数据 Hook 集中管理中心,新增 useAccountInfo、 useBillingBalance、useBillingLedger - 页面组件改为纯渲染:const {data,loading,error,refetch} = useXxx() ## 新功能 - Mac 硬件指纹采集(electron/preload/device.ts): ioreg(UUID) + sysctl(CPU) + system_profiler(HDD三级回退) - 账单页面 BillingInfo(余额卡片 + 账单表格 + 分页) - PageDispatcher 注册 /billing 页面(FloatingPanel 960×680) - 共享工具函数 centToYuan / formatDate(src/utils/display.ts) ## Bug 修复 - Token 刷新容错:网络错误时保留 refresh_token,等网络恢复后重试; 仅 RefreshError(token 过期)时清除(auth-token.ts) - AppProvider 自动登录时 nullable 字段默认值(email→''、admin_permissions→[]) - AccountInfo 重构消除 ~40 行手写状态管理代码 ## 代码质量 - AccountInfo 消除 centToYuan/formatDate 重复定义 - BillingInfo 标题层级精简(移除冗余页面/Card标题) - antd Table align 字面量类型修正('left' as const) - billing.ts signal 参数位置修复(params→config) - modelFirstFetchDone 模块级变量移除(StrictMode 不安全) --- .codegraph/daemon.pid | 4 +- ARCHITECTURE.md | 130 ++++++++++ CHANGELOG.md | 49 ++++ CLAUDE.md | 23 -- TODO.md | 22 +- electron/preload/device.ts | 93 +++++++ src/components/AppProvider.tsx | 10 +- src/components/PageDispatcher.tsx | 11 +- src/hooks/use-api.ts | 58 ++++- src/hooks/use-async.ts | 8 +- src/pages/headers/AccountInfo.tsx | 50 +--- src/pages/headers/BillingInfo.tsx | 281 ++++++++++++++++++++++ src/pages/headers/Test1.tsx | 9 - src/pages/home/components/TaskHistory.tsx | 2 +- src/services/auth-token.ts | 24 +- src/services/modules/auth.ts | 6 +- src/services/modules/banner.ts | 4 +- src/services/modules/billing.ts | 57 +++++ src/services/modules/models.ts | 4 +- src/services/modules/task.ts | 10 +- src/services/modules/vip-api.ts | 6 +- src/services/types.ts | 102 ++++---- src/utils/display.ts | 16 ++ src/utils/type.ts | 1 + 24 files changed, 808 insertions(+), 172 deletions(-) create mode 100644 src/pages/headers/BillingInfo.tsx delete mode 100644 src/pages/headers/Test1.tsx create mode 100644 src/services/modules/billing.ts create mode 100644 src/utils/display.ts create mode 100644 src/utils/type.ts diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index 31a78d0..bd35f59 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 844000, + "pid": 77548, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea", - "startedAt": 1781055291389 + "startedAt": 1781164466085 } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 77fc777..c6b49ef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,6 +82,72 @@ src/ --- +## 数据获取架构 + +### 三层模式:API 模块 → use-api Hook → 页面组件 + +``` +Step 1 — API 模块 Step 2 — use-api.ts Step 3 — 页面组件 +┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ +│ BillingAPI │ → │ useBillingBalance │ → │ BillingInfo.tsx │ +│ .getBalance(signal) │ │ useAsyncData( │ │ const {data,loading,│ +│ .getLedger(param,s) │ │ (signal)=> │ │ error,refetch}= │ +│ │ │ BillingAPI... │ │ useBillingBalance() │ +│ signal?: AbortSignal │ │ ,[deps], │ │ // 纯渲染,不调 API │ +│ 透传 axios config │ │ {enabled:isLoggedIn} │ │ │ +│ │ │ ) │ │ │ +└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ +``` + +### 原则 + +- **API 模块**(`services/modules/*.ts`):`signal?: AbortSignal` 可选参数,透传给 axios config,向后兼容 +- **use-api.ts**:所有业务数据 Hook 集中于此,组件只 import Hook 不 import API 类 +- **页面组件**:纯渲染,通过 `{ data, loading, error, errorMessage, refetch }` 消费数据,不手写 `useState`/`useEffect`/`useRef` 状态管理 +- **`useAsyncData`**:自动处理竞态条件(raceId)、AbortController 取消、错误日志 + +### signal 参数规范 + +``` +// API 层 — signal 在 config 位置(第三个参数),不是 params +static getBalance(signal?: AbortSignal): Promise { + return get(url, undefined, { signal }); +} + +// Hook 层 — signal 由 useAsyncData 自动注入 +export function useBillingBalance() { + return useAsyncData( + (signal) => BillingAPI.getBalance(signal), + [], + { enabled: isLoggedIn, label: 'billing-balance' }, + ); +} +``` + +### AbortController 生命周期 + +- 每次 effect 创建新 `AbortController` +- cleanup 时调用 `abortController.abort()` → 真正中断飞行中的 HTTP 请求(不仅仅是丢弃响应) +- `signal.aborted` 检查:取消的请求静默忽略,不设置 error 状态 +- 解决 React StrictMode 开发模式下组件双重挂载导致的重复请求问题 + +### 新增 Hook 规范 + +在 `use-api.ts` 中添加新 Hook 只需三行: + +```typescript +export function useXxx() { + const { isLoggedIn } = useAppContext(); + return useAsyncData( + (signal) => XxxAPI.getXxx(signal), + [], // deps — 为空表示只请求一次,refetch 手动触发 + { enabled: isLoggedIn, label: 'xxx' }, + ); +} +``` + +--- + ## 核心流程 ### 1. 登录 @@ -134,6 +200,17 @@ App 启动 → AppProvider useEffect 请求 A 收到 401 → 开始 refresh 请求 B 同时收到 401 → 发现 isRefreshing=true → 加入队列等待 refresh 完成 → 新 token 广播给所有排队请求 → 全部重试 + +### 3.1 Token 刷新错误分类(auth-token.ts) + +| 错误类型 | 行为 | 原因 | +|---------|------|------| +| `RefreshError`(token 过期) | 清除 refresh_token | 无法再续期 | +| `NETWORK` / `TIMEOUT` | **保留** refresh_token | 网络恢复后可重试 | +| HTTP 5xx / Business 错误 | 清除 refresh_token | 保守处理 | + +> 网络错误时不清除 refresh_token 是关键——否则网络短暂中断会导致用户被迫手动重新登录。 +> 只有 RefreshError(refresh 接口返回 401)才确认 token 真的过期。 ``` ### 4. 退出登录 @@ -187,6 +264,37 @@ NavBar / 设置页 → AppProvider.logout() - SettingsPage — Modal,事件总线 `OPEN_SETTINGS` 触发 - AnnouncementDrawer — Drawer,NavBar 内部 state 控制 +**PageDispatcher 页面注册**: + +```typescript +const PAGE_MAP: Record = { + '/account': { + component: AccountInfo, // 页面组件(懒加载用 React.lazy) + label: '账户', // 容器标题栏显示 + width: () => Math.min(window.outerHeight * 0.65, 720), // 动态宽度 + height: () => Math.min(window.outerHeight * 0.65, 680), // 动态高度 + }, + '/billing': { + component: BillingInfo, // 账单页面(余额卡片 + 账单表格) + label: '账单记录', // FloatingPanel 标题栏显示 + width: 960, // 固定宽度(适配账单表格) + height: 680, // 固定高度 + }, + '/vip': { label: '开会员/激活' }, // 占位页:无 component → 自动渲染 "页面开发中" +}; +``` + +**容器类型选择**: + +| 类型 | 组件 | 场景 | +|------|------|------| +| `modal` | antd Modal | 居中模态,阻断交互(登录、VIP 开通) | +| `drawer` | antd Drawer | 侧滑抽屉(设置、公告) | +| `floating` | FloatingPanel | 非模态浮动面板,可拖拽(账户、账单) | + +- **全局互斥**:同一时刻只有一个面板打开,重复点击不替换 +- **FloatingPanel** 自带 `padding: 16` 和标题栏,内部组件避免过度内边距叠加 + **反模式警告**:叠加层绝不应通过路由跳转实现。`navigate('/xxx')` + `useLocation` 判断显隐会导致原有页面卸载,丢失所有组件状态。 ### API 模块规范 @@ -252,3 +360,25 @@ export class AuthAPI { |------------------|-------------------------|--------------------------|--------------------------| | `UPDATE_API_URL` | `http://127.0.0.1:8000` | `https://www.heixiu.com` | `https://www.heixiu.com` | | `EDITION` | `personal` | `personal` | `personal` | + +--- + +## 平台兼容 + +### 设备指纹(electron/preload/device.ts) + +| 平台 | 采集方式 | +|------|---------| +| Windows | WMIC / PowerShell — UUID、CPU、HDD 序列号 | +| macOS | `ioreg -d2 -c IOPlatformExpertDevice`(UUID)、`sysctl -n hw.model`(CPU 型号)、`system_profiler`(HDD 三级回退) | + +**Mac HDD 三级回退策略**: + +1. `system_profiler SPNVMeDataType` → NVMe 磁盘序列号(Apple Silicon / 新款 Intel) +2. `system_profiler SPSerialATADataType` → SATA 磁盘序列号(老款 Intel Mac) +3. `system_profiler SPHardwareDataType` → 系统序列号(最终兜底) + +- 通过 `platform() === 'darwin'` / `platform() === 'win32'` 区分平台 +- Mac 命令执行:`spawnSync` + 5s 超时,`ioreg`/`sysctl` 始终可用 +- 共享同一套 `SHA256(normalizeIdentifier(combined))` 管线 +- `getMachineCode()` 跨平台统一接口,自动选择最优采集方式 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2a3d1..5ae28b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,55 @@ > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 --- +## 0.0.19(2026-06-11) + +**数据获取架构重构 + Mac 指纹采集 + Token 刷新容错 + 账单页面** + +### 数据获取架构重构 + +- `useAsyncData` 集成 AbortController:cleanup 时真正中断 HTTP 请求,不再仅丢弃响应(解决 StrictMode 双重请求) +- fetcher 签名变更:`() => Promise` → `(signal: AbortSignal) => Promise` +- API 模块统一 `signal?: AbortSignal` 可选参数,透传 axios config(banner/auth/models/task/vip) +- `use-api.ts` 成为数据 Hook 集中管理中心: + - 新增 `useAccountInfo` — `Promise.all` 并行获取用户信息 + VIP 等级 + - 新增 `useBillingBalance` — 账户余额 + - 新增 `useBillingLedger` — 账单明细(支持分页参数) +- `AccountInfo.tsx` 重构:~40 行手写状态管理(`useRef`+`useEffect`+`cancelled`+`retryTick`)→ 1 行 `useAccountInfo()` +- 共享工具 `src/utils/display.ts` — `centToYuan` / `formatDate`,消除 AccountInfo/BillingInfo 重复定义 +- 移除 `modelFirstFetchDone` 模块级变量(StrictMode 不安全) + +### Mac 硬件指纹采集 + +- `electron/preload/device.ts` Mac 完整实现: + - UUID:`ioreg -d2 -c IOPlatformExpertDevice` → `IOPlatformUUID` + - CPU:`sysctl -n hw.model` → 机型标识 + - HDD:三级回退 `system_profiler SPNVMeDataType` → `SPSerialATADataType` → `SPHardwareDataType` +- `isMacOS()` / `runMacCommand()` / `getMacHddValue()` 辅助函数 +- `getComponentId()` / `fingerprintSha256()` Mac 早退分支,与 Windows 共享 SHA256 管线 + +### 账单页面 + +- `BillingInfo.tsx` — 余额卡片(BalanceCard)+ 账单表格(LedgerTable)+ 分页 +- `BillingAPI` — `getBalance` / `getLedger`,`signal` 参数透传 +- `PageDispatcher` 注册 `/billing` 页面(FloatingPanel 960×680) +- 修复 `billing.ts` signal 参数位置 bug:`get(url, {signal})` → `get(url, undefined, {signal})` +- BillingInfo 标题精简:移除冗余页面 Title 和 Card title,仅保留 FloatingPanel 标题栏 + +### Bug 修复 + +- **Token 刷新容错**(`auth-token.ts`):网络错误/超时时保留 refresh_token,仅 RefreshError(token 过期)时清除。 + 解决网络短暂中断后被迫手动重新登录的问题 +- **AppProvider 自动登录**:`email`/`admin_permissions` 等 nullable 字段默认值修复(`?? ''` / `?? []`) +- **antd Table align**:`align: 'left'` → `align: 'left' as const`,修复 TS2322 类型错误 +- **分页器位置**:显式添加 `position: ['bottomRight']` + +### 文档 + +- CLAUDE.md 精简为 AI 行为规则,项目技术内容移入 ARCHITECTURE.md +- ARCHITECTURE.md 新增:数据获取架构、页面调度详情、Token 刷新错误分类、设备指纹 + +--- + ## 0.0.18(2026-06-10) **前后端联调 — 任务创建 + 账户信息 + 预估花费** diff --git a/CLAUDE.md b/CLAUDE.md index 09fd954..d45b8b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,26 +4,3 @@ - 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录 - 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS。 - 有需要请自己调用MCP服务 - -# 主题系统架构 - -## 双轨颜色体系 - -1. **自定义 CSS 变量**(`globals.css`):`data-theme="light|dark"` → `var(--color-bg-base)` 等,用于 body 和自定义组件 -2. **antd 令牌**(`antd-theme.ts`):`ConfigProvider theme={algorithm}` → `useToken()` → inline style,用于 antd 组件 - -## 主题切换时序(关键) - -``` -ThemeProvider.useLayoutEffect([isDark]) - → applyHtmlTheme(isDark) ← data-theme 属性变更 - (与 ConfigProvider 的 useInsertionEffect 同帧执行) - → 浏览器绘制 ← 所有颜色变更在同一帧生效 -``` - -## 过渡策略 - -- **Electron/Chromium**:View Transitions API(`document.startViewTransition` + `flushSync`),GPU 合成器单次 cross-fade -- **其他浏览器**:CSS `transition: color/bg/border/shadow 0.15s linear`,精准覆盖 ~80 个 antd 容器类(不覆盖交互组件自有 - transition) -- **不启用 antd cssVar**:经实测 cssVar 模式下 CSS-in-JS 仍走标签替换路径,过渡无效 \ No newline at end of file diff --git a/TODO.md b/TODO.md index 8222de1..9d8d3a7 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,10 @@ --- +[//]: # (警告:(48, 14) ESLint: Fast refresh only works when a file only exports components. Move your React context(s) to a separate file. (react-refresh/only-export-components)) + +[//]: # (警告:(52, 17) ESLint: Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components. (react-refresh/only-export-components)) + ## 🟡 功能待完善(优先级中) ### 设置页面 @@ -60,12 +64,12 @@ ## ⚠️ 已知技术债务 -| 问题 | 说明 | 影响 | -|-----------------|----------------------------------------------------------------------------------------------------------------------|----------------| -| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | -| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | -| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | -| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 | +| 问题 | 说明 | 影响 | +|-----------------|---------------------------------------|----------------| +| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | +| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | +| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | +| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 | --- @@ -73,6 +77,8 @@ 1. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现) 2. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07) -3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 localStorage 持久层仍未实现) +3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 + localStorage 持久层仍未实现) 4. **轮播图关闭按钮 localStorage 持久化**(关闭按钮已实现,待做:localStorage 记录关闭时间戳,超过 N 天再显示) -5. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API,纯前端限制对脚本攻击无效) +5. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 + 60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API,纯前端限制对脚本攻击无效) diff --git a/electron/preload/device.ts b/electron/preload/device.ts index 20c15b4..f951f38 100644 --- a/electron/preload/device.ts +++ b/electron/preload/device.ts @@ -6,6 +6,7 @@ * * 适用环境: * - Node.js / Electron(Windows):通过 child_process 执行 wmic / powershell 采集硬件 ID + * - Node.js / Electron(macOS) :通过 ioreg / sysctl / system_profiler 采集硬件 ID * - 浏览器:仅提供 SHA256 哈希工具函数,硬件采集需由上层注入 */ @@ -60,6 +61,37 @@ const COMPONENT_QUERIES: Record = { }, }; +// ============================================================ +// Mac 查询配置 +// ============================================================ + +interface MacComponentQuery { + command: string[]; + /** 从命令 stdout 中提取目标值 */ + extract: (output: string) => string; +} + +const MAC_COMPONENT_QUERIES: Record = { + uuid: { + command: ['ioreg', '-d2', '-c', 'IOPlatformExpertDevice'], + extract: (output: string): string => { + const m = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); + return m?.[1] ?? ''; + }, + }, + cpu: { + command: ['sysctl', '-n', 'hw.model'], + extract: (output: string): string => output.trim(), + }, + hdd: { + command: ['system_profiler', 'SPNVMeDataType'], + extract: (output: string): string => { + const m = output.match(/Serial Number:\s*(.+)/i); + return m?.[1]?.trim() ?? ''; + }, + }, +}; + // ============================================================ // 工具函数 // ============================================================ @@ -105,6 +137,10 @@ function isWindows(): boolean { return platform() === "win32"; } +function isMacOS(): boolean { + return platform() === "darwin"; +} + function isWmicAvailable(): boolean { if (!isWindows()) return false; const result = spawnSync("where", ["wmic"], { @@ -163,6 +199,35 @@ function runWmic(args: readonly string[]): string { return runProcess([...args]); } +/** 执行 Mac 命令并返回 stdout(去除首尾空白) */ +function runMacCommand(args: string[], timeoutSec: number = 5.0): string { + if (!isMacOS()) return ""; + return runProcess(args, timeoutSec); +} + +/** + * Mac HDD 标识采集 — 三级回退保证覆盖率 + * ① NVMe 磁盘序列号(Apple Silicon / 新款 Intel) + * ② SATA 磁盘序列号(老款 Intel Mac) + * ③ 系统序列号(最终兜底,仍唯一) + */ +function getMacHddValue(): string { + // ① 尝试 NVMe + let output = runMacCommand(['system_profiler', 'SPNVMeDataType']); + let m = output.match(/Serial Number:\s*(.+)/i); + if (m?.[1]?.trim()) return m[1].trim(); + + // ② 回退:SATA + output = runMacCommand(['system_profiler', 'SPSerialATADataType']); + m = output.match(/Serial Number:\s*(.+)/i); + if (m?.[1]?.trim()) return m[1].trim(); + + // ③ 最终兜底:系统序列号 + output = runMacCommand(['system_profiler', 'SPHardwareDataType']); + m = output.match(/Serial Number[^:]*:\s*(.+)/i); + return m?.[1]?.trim() ?? ''; +} + // ============================================================ // DeviceFingerprint 类 // ============================================================ @@ -178,6 +243,18 @@ export class DeviceFingerprint { * 对应 Python: get_component_id(component, method="auto") -> str */ getComponentId(component: Component, method: QueryMethod = "auto"): string { + // Mac 路径:ioreg / sysctl / system_profiler + if (isMacOS()) { + if (component === 'hdd') { + return normalizeIdentifier(getMacHddValue()); + } + const macQuery = MAC_COMPONENT_QUERIES[component]; + if (!macQuery) return ''; + const output = runMacCommand(macQuery.command); + return normalizeIdentifier(macQuery.extract(output)); + } + + // Windows 路径:wmic / powershell const query = COMPONENT_QUERIES[component]; if (!query) return ""; @@ -225,6 +302,22 @@ export class DeviceFingerprint { * 对应 Python: fingerprint_sha256(method="auto") -> str | None */ fingerprintSha256(method: QueryMethod = "auto"): string | null { + // Mac 路径:ioreg / sysctl / system_profiler + if (isMacOS()) { + const uuidId = this.getComponentId("uuid", method); + const diskId = this.getComponentId("hdd", method); + const cpuId = this.getComponentId("cpu", method); + + if (!uuidId && !diskId && !cpuId) { + return null; + } + + const combined = `${uuidId}|${diskId}|${cpuId}`; + const normalized = normalizeIdentifier(combined); + return sha256HexUpper(normalized); + } + + // Windows 路径:wmic / powershell const resolvedMethod = resolveMethod(method); const wmicOk = isWmicAvailable(); const psOk = isPowershellAvailable(); diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 6478d8d..d30450a 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -147,15 +147,15 @@ export function AppProvider({ children }: AppProviderProps) { user_id: info.id, username: info.username, role: info.role, - email: info.email, + email: info.email ?? '', balance_cent: info.balance_cent, account_type: info.account_type, - display_name: info.display_name, - company_id: info.company_id, - real_name: info.real_name, + display_name: info.display_name ?? '', + company_id: info.company_id ?? '', + real_name: info.real_name ?? '', phone: info.phone, license_code: '', - admin_permissions: info.admin_permissions, + admin_permissions: info.admin_permissions ?? [], }; setUser(u); emit(EVENTS.LOGIN_SUCCESS, u); diff --git a/src/components/PageDispatcher.tsx b/src/components/PageDispatcher.tsx index 08837ea..c75dbb2 100644 --- a/src/components/PageDispatcher.tsx +++ b/src/components/PageDispatcher.tsx @@ -20,8 +20,10 @@ import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} f import {Drawer, Modal} from 'antd'; import {EVENTS, off, on} from '@/utils/event-bus'; import {FloatingPanel} from '@/components/ui/FloatingPanel'; + import {WechatWorkPage} from '@/pages/headers/WechatWorkPage'; import {AccountInfo} from '@/pages/headers/AccountInfo.tsx'; +import {BillingInfo} from "@/pages/headers/BillingInfo.tsx"; // ---------- 类型 ---------- @@ -80,8 +82,13 @@ const PAGE_MAP: Record = { }, '/vip': {label: '开会员/激活'}, '/recharge': {label: '充值积分'}, - '/billing': {label: '消费记录'}, - '/orders': {label: '订单明细'}, + '/billing': {label: '账单记录', + component: BillingInfo, + width: 960, + height: 680,}, + '/orders': { + label: '订单明细', + }, '/projects': {label: '项目管理'}, '/quota': {label: '查配额'}, }; diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts index 6b2ffeb..8c1a5fe 100644 --- a/src/hooks/use-api.ts +++ b/src/hooks/use-api.ts @@ -17,6 +17,9 @@ import {useAppContext} from '@/components/AppProvider'; import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } 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'; +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'; @@ -29,7 +32,7 @@ export function useBannerList() { const {isLoggedIn} = useAppContext(); return useAsyncData( - fetchBanners, + (signal) => fetchBanners(signal), [], {enabled: isLoggedIn, label: 'banner-list'}, ); @@ -39,9 +42,6 @@ export function useBannerList() { // 模型 // ============================================================ -/** 模块级:单次会话中是否已完成首次 API 拉取 */ -let modelFirstFetchDone = false; - /** 模型列表数据 Hook(缓存优先 + 后台刷新) * * 策略: @@ -61,11 +61,10 @@ export function useModelList() { ); const result: UseAsyncDataReturn = useAsyncData( - async () => { - const models = await ModelAPI.fetchModels({page: 1, page_size: 200}); + async (signal) => { + const models = await ModelAPI.fetchModels({page: 1, page_size: 200}, signal); // 异步写入加密缓存(best-effort,不影响数据流) setCachedModels(models).catch(() => {}); - modelFirstFetchDone = true; setInstantData(models); return models; }, @@ -123,7 +122,7 @@ export function useTaskList(params: UseTaskListParams) { const {requestParams, refreshSignal = 0} = params; return useAsyncData( - () => TaskAPI.getTaskList(requestParams), + (signal) => TaskAPI.getTaskList(requestParams, signal), [requestParams, refreshSignal], {enabled: isLoggedIn, label: 'task-list'}, ); @@ -138,12 +137,53 @@ export function useTaskDetail(taskId: string | null | undefined) { const {isLoggedIn} = useAppContext(); return useAsyncData( - () => TaskAPI.getTaskInfo(taskId!), + (signal) => TaskAPI.getTaskInfo(taskId!, signal), [taskId], {enabled: isLoggedIn && !!taskId, label: 'task-detail'}, ); } +// ============================================================ +// 账户信息(用户信息 + VIP 等级,并行请求) +// ============================================================ + +/** 账户信息页数据 Hook — 返回 [userInfo, vipsInfo] 元组 */ +export function useAccountInfo() { + const { isLoggedIn } = useAppContext(); + return useAsyncData<[UserInfoBody, VipLevelsItem[]]>( + (signal) => Promise.all([ + AuthAPI.getUserInfo(signal), + VipAPI.getVipLevels(signal), + ]), + [], + { enabled: isLoggedIn, label: 'account-info' }, + ); +} + +// ============================================================ +// 账单 +// ============================================================ + +/** 账户余额 Hook */ +export function useBillingBalance() { + const { isLoggedIn } = useAppContext(); + return useAsyncData( + (signal) => BillingAPI.getBalance(signal), + [], + { enabled: isLoggedIn, label: 'billing-balance' }, + ); +} + +/** 账单明细 Hook(支持分页参数) */ +export function useBillingLedger(params: LedgerReqeustParam) { + const { isLoggedIn } = useAppContext(); + return useAsyncData( + (signal) => BillingAPI.getLedger(params, signal), + [params], + { enabled: isLoggedIn, label: 'billing-ledger' }, + ); +} + // ============================================================ // 提交任务 // ============================================================ diff --git a/src/hooks/use-async.ts b/src/hooks/use-async.ts index bcc10c6..ac3e494 100644 --- a/src/hooks/use-async.ts +++ b/src/hooks/use-async.ts @@ -82,7 +82,7 @@ export interface UseAsyncDataReturn { * - `enabled: false` 时不发起请求(适用于需登录后请求的场景) */ export function useAsyncData( - fetcher: () => Promise, + fetcher: (signal: AbortSignal) => Promise, deps: unknown[], options: UseAsyncDataOptions = {}, ): UseAsyncDataReturn { @@ -117,16 +117,19 @@ export function useAsyncData( const raceId = ++raceRef.current; let cancelled = false; + const abortController = new AbortController(); const run = async () => { setLoading(true); setError(null); try { - const result = await fetcher(); + const result = await fetcher(abortController.signal); if (!cancelled && raceId === raceRef.current && mountedRef.current) { setData(result); } } catch (err) { + // 请求被 AbortController 显式取消 → 静默忽略,不设 error 状态 + if (abortController.signal.aborted) return; if (!cancelled && raceId === raceRef.current && mountedRef.current) { const normalized = err instanceof Error ? err : new Error(String(err)); setError(normalized); @@ -150,6 +153,7 @@ export function useAsyncData( return () => { cancelled = true; + abortController.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, refreshTick, ...deps]); diff --git a/src/pages/headers/AccountInfo.tsx b/src/pages/headers/AccountInfo.tsx index 053b112..37eb17b 100644 --- a/src/pages/headers/AccountInfo.tsx +++ b/src/pages/headers/AccountInfo.tsx @@ -1,4 +1,3 @@ -import {useEffect, useState, useCallback} from 'react'; import { Card, Descriptions, @@ -18,48 +17,16 @@ import { BarChartOutlined, RightOutlined, } from '@ant-design/icons'; -import {AuthAPI, type UserInfoBody, UserAccountTypeLabelMap} from '@/services/modules/auth'; -import {VipAPI, VipLevelsItem} from "@/services/modules/vip-api.ts"; +import { UserAccountTypeLabelMap } from '@/services/modules/auth'; +import type { VipLevelsItem } from '@/services/modules/vip-api'; +import { useAccountInfo } from '@/hooks/use-api'; +import { centToYuan, formatDate } from '@/utils/display'; const {Text, Title} = Typography; -/** 将分转为元,保留两位小数 */ -function centToYuan(cent: number): string { - return (cent / 100).toFixed(2); -} - -/** 格式化日期 */ -function formatDate(date: Date | string | undefined): string { - if (!date) return '—'; - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleDateString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit'}); -} - export function AccountInfo() { const {token} = antTheme.useToken(); - const [userInfo, setUserInfo] = useState(null); - const [vipsInfo, setVipsInfo] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const info: UserInfoBody = await AuthAPI.getUserInfo(); - const vips: VipLevelsItem[] = await VipAPI.getVipLevels(); - setVipsInfo(vips); - setUserInfo(info); - } catch (err) { - setError((err as Error).message || '加载账户信息失败'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); + const { data, loading, error, errorMessage, refetch } = useAccountInfo(); // ======== 加载态 ======== if (loading) { @@ -71,14 +38,14 @@ export function AccountInfo() { } // ======== 错误态 ======== - if (error || !userInfo) { + if (error || !data) { return ( + } @@ -87,6 +54,7 @@ export function AccountInfo() { } // ======== 数据展示 ======== + const [userInfo, vipsInfo] = data; const totalBalance: number = userInfo.balance_cent + userInfo.gift_balance_cent; // 匹配当前 VIP 等级(vip_level_id → VipLevelsItem) diff --git a/src/pages/headers/BillingInfo.tsx b/src/pages/headers/BillingInfo.tsx new file mode 100644 index 0000000..0f92ebd --- /dev/null +++ b/src/pages/headers/BillingInfo.tsx @@ -0,0 +1,281 @@ +import {useState, useMemo} from 'react'; +import { + Card, + Table, + Button, + Typography, + Skeleton, + Result, + Space, + Tag, + theme as antTheme, +} from 'antd'; +import { + WalletOutlined, + RiseOutlined, + ApiOutlined, +} from '@ant-design/icons'; +import {useBillingBalance, useBillingLedger} from '@/hooks/use-api'; +import type {LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/billing'; +import {centToYuan, formatDate} from '@/utils/display'; + +const {Text, Title} = Typography; + +// ============================================================ +// 余额卡片 +// ============================================================ + +function BalanceCard() { + const {token} = antTheme.useToken(); + const {data, loading, error, errorMessage, refetch} = useBillingBalance(); + + // ======== 加载态 ======== + if (loading) { + return ( + + + + ); + } + + // ======== 错误态 ======== + if (error || !data) { + return ( + + + 重试 + + } + /> + + ); + } + + // ======== 正常展示 ======== + const totalBalance = (data.balance_cent ?? 0) + (data.gift_balance_cent ?? 0); + + return ( + + + + 账户总积分 + + + {centToYuan(totalBalance)} + <Text type="secondary" style={{fontSize: 14, marginLeft: 4}}> + {data.currency} + </Text> + + +
+ 主积分 +
+ {centToYuan(data.balance_cent ?? 0)} +
+
+ 赠送积分 +
+ {centToYuan(data.gift_balance_cent ?? 0)} +
+
+ + + 今日消耗 + +
+ {centToYuan(data.today_spent_cent)} +
+
+ + + 今日调用 + +
+ {data.today_calls} 次 +
+
+
+ ); +} + +// ============================================================ +// 账单表格 +// ============================================================ + +/** entry_type → 中文标签映射(按需扩展) */ +const ENTRY_TYPE_LABELS: Record = { + 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(); + const [pagination, setPagination] = useState({page: 1, page_size: 10}); + + const params: LedgerReqeustParam = useMemo(() => ({ + month: null, + page: pagination.page, + page_size: pagination.page_size, + }), [pagination]); + + const {data, loading, error, errorMessage, refetch} = useBillingLedger(params); + + const columns = [ + { + title: '时间', + dataIndex: 'created_at', + key: 'created_at', + width: 120, + align: 'center' as const, + render: (_: unknown, record: LedgerItemResponseBody) => ( + + {formatDate(record.created_at)} + + ), + }, + { + title: '类型', + dataIndex: 'entry_type', + key: 'entry_type', + width: 100, + align: 'center' as const, + render: (type: string) => ( + + {resolveEntryLabel(type)} + + ), + }, + { + title: '金额', + dataIndex: 'amount_cent', + key: 'amount_cent', + width: 100, + align: 'center' as const, + render: (amount: number): React.ReactNode => { + const isNegative = amount < 0; + return ( + + {isNegative ? '-' : '+'} + {centToYuan(Math.abs(amount))} + + ); + }, + }, + { + title: '余额', + dataIndex: 'balance_after_cent', + key: 'balance_after_cent', + width: 100, + align: 'center' as const, + render: (val: number) => ( + {centToYuan(val)} + ), + }, + { + title: '描述', + dataIndex: 'description', + key: 'description', + ellipsis: true, + align: 'left' as const, + render: (desc: string) => ( + {desc || '—'} + ), + }, + ]; + + // ======== 错误态 ======== + if (error) { + return ( + + + 重试 + + } + /> + + ); + } + + // ======== 正常 / 加载态 ======== + return ( + + + columns={columns} + dataSource={data ?? []} + rowKey="id" + loading={loading} + size="small" + pagination={{ + current: pagination.page, + pageSize: pagination.page_size, + total: (data ?? []).length >= pagination.page_size + ? pagination.page * pagination.page_size + 1 + : pagination.page * pagination.page_size, + showSizeChanger: true, + pageSizeOptions: ['10', '20', '50'], + showTotal: (total: number, range: [number, number]) => + `${range[0]}-${range[1]},共 ${total}+ 条`, + onChange: (page: number, pageSize: number) => { + setPagination({page, page_size: pageSize}); + }, + position: ['bottomCenter'], + }} + locale={{emptyText: '暂无消费记录'}} + /> + + ); +} + +// ============================================================ +// 页面入口 +// ============================================================ + +export function BillingInfo() { + return ( +
+
+ + +
+
+ ); +} diff --git a/src/pages/headers/Test1.tsx b/src/pages/headers/Test1.tsx deleted file mode 100644 index b5264f0..0000000 --- a/src/pages/headers/Test1.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import {Component} from "react"; - -export class Test1 extends Component { - render() { - return ( - <> - ); - } -} \ No newline at end of file diff --git a/src/pages/home/components/TaskHistory.tsx b/src/pages/home/components/TaskHistory.tsx index 6bc8ce1..488f8e2 100644 --- a/src/pages/home/components/TaskHistory.tsx +++ b/src/pages/home/components/TaskHistory.tsx @@ -714,7 +714,7 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { {/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */} -
+
className="task-table" diff --git a/src/services/auth-token.ts b/src/services/auth-token.ts index 1e82f98..d98e07d 100644 --- a/src/services/auth-token.ts +++ b/src/services/auth-token.ts @@ -11,8 +11,9 @@ // - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用) // ============================================================ -import { setToken, setTokenRefreshHandler } from './request'; +import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from './request'; import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth'; +import { isRefreshError } from './types'; import { getSafeStore, setSafeStore, @@ -71,9 +72,24 @@ export function initAuthRefresh(): void { await setStoredRefreshToken(res.refresh_token); return res.access_token; - } catch { - // 刷新失败 → 清除残留 - clearStoredRefreshToken(); + } catch (err) { + // 区分错误类型: + // - RefreshError → refresh_token 已过期/无效 → 清除 + // - 网络错误(NetworkError/Timeout)→ 保留 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(); + } return null; } }); diff --git a/src/services/modules/auth.ts b/src/services/modules/auth.ts index 7c425a0..d57ab9e 100644 --- a/src/services/modules/auth.ts +++ b/src/services/modules/auth.ts @@ -32,7 +32,7 @@ export const UserRoleTypeLabelMap: Record = { [UserRoleTypeEnum.User]: "用户账户", [UserRoleTypeEnum.Admin]: "管理账户", [UserRoleTypeEnum.SuperAdmin]: "超管账户" -} +}; export const UserAccountTypeEnum = { Individual: 'individual', @@ -197,8 +197,8 @@ export class AuthAPI { } /** 获取当前用户信息 */ - static async getUserInfo(): Promise { - return await get(AuthUrlObj.userInfo); + static async getUserInfo(signal?: AbortSignal): Promise { + return await get(AuthUrlObj.userInfo, undefined, { signal }); } /** 修改密码(已登录) */ diff --git a/src/services/modules/banner.ts b/src/services/modules/banner.ts index 339fb74..9642cfc 100644 --- a/src/services/modules/banner.ts +++ b/src/services/modules/banner.ts @@ -38,6 +38,6 @@ export type BannerListResponse = Banner[]; * 获取 Banner 轮播图列表 * GET /api/v1/banners */ -export function fetchBanners(): Promise { - return get(BannerUrlObj.list); +export function fetchBanners(signal?: AbortSignal): Promise { + return get(BannerUrlObj.list, undefined, { signal }); } diff --git a/src/services/modules/billing.ts b/src/services/modules/billing.ts new file mode 100644 index 0000000..8c630d1 --- /dev/null +++ b/src/services/modules/billing.ts @@ -0,0 +1,57 @@ +import {type Nullable} from "@/utils/type"; +import {get} from "@/services/request.ts"; + +const BillingUrlObj = { + getBalance: '/api/v1/billing/balance', + getLedger: '/api/v1/billing/ledger', + ExportLedger: "/api/v1/billing/ledger/export" +} as const; + +export interface BalanceResponseBody { + user_id: string, + balance_cent: Nullable, + gift_balance_cent: Nullable, + currency: "CNY", + today_spent_cent: number, + today_calls: number +} + + +export interface LedgerReqeustParam { + month: Nullable, + page: number, + page_size: number +} + +export interface LedgerItemResponseBody { + id: string, + entry_type: string, + balance_cent: string, + amount_cent: number, + currency: string, + balance_after_cent: number, + task_id: string, + description: "string", + accounting_month: "string", + accounting_day: "string", + created_at: Date +} + +export interface ExportLedgerRequstParam { + start_time: Date, + end_time: Date, + entry_type: Nullable + debug?: boolean +} + +export class BillingAPI { + static getBalance(signal?: AbortSignal): Promise { + return get(BillingUrlObj.getBalance, undefined, {signal}); + } + + static getLedger(param: LedgerReqeustParam, signal?: AbortSignal): Promise { + return get(BillingUrlObj.getLedger, param as unknown as Record, {signal}); + } + + static exportUrl = BillingUrlObj.ExportLedger; +} \ No newline at end of file diff --git a/src/services/modules/models.ts b/src/services/modules/models.ts index af1f9a7..20cee99 100644 --- a/src/services/modules/models.ts +++ b/src/services/modules/models.ts @@ -144,8 +144,8 @@ export class ModelAPI { * 获取可用模型列表 * GET /api/v1/models */ - static fetchModels(params: ModelsListReqeustParams): Promise { - return get(ModelsUrlObj.modelList, params as unknown as Record); + static fetchModels(params: ModelsListReqeustParams, signal?: AbortSignal): Promise { + return get(ModelsUrlObj.modelList, params as unknown as Record, { signal }); } /** diff --git a/src/services/modules/task.ts b/src/services/modules/task.ts index e5d390b..e0703d9 100644 --- a/src/services/modules/task.ts +++ b/src/services/modules/task.ts @@ -11,7 +11,7 @@ // ============================================================ import {get, post} from '../request'; -import {getDeviceId} from '../../utils/device'; +import {getDeviceId} from '@/utils/device.ts'; // ============================================================ // 基础枚举 / 字面量类型 @@ -207,8 +207,8 @@ const TaskUrlObj = { export class TaskAPI { /** 获取任务列表(分页/游标) */ - static getTaskList(params: TaskListRequestParams): Promise { - return get(TaskUrlObj.task, params as Record); + static getTaskList(params: TaskListRequestParams, signal?: AbortSignal): Promise { + return get(TaskUrlObj.task, params as Record, { signal }); } /** 提交新任务(device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */ @@ -220,8 +220,8 @@ export class TaskAPI { } /** 获取任务详情 */ - static getTaskInfo(task_id: string): Promise { - return get(TaskUrlObj.taskInfo(task_id)); + static getTaskInfo(task_id: string, signal?: AbortSignal): Promise { + return get(TaskUrlObj.taskInfo(task_id), undefined, { signal }); } /** 轻量轮询任务状态 */ diff --git a/src/services/modules/vip-api.ts b/src/services/modules/vip-api.ts index 384788a..a5f065d 100644 --- a/src/services/modules/vip-api.ts +++ b/src/services/modules/vip-api.ts @@ -1,7 +1,7 @@ import {post, get} from '../request'; import {type UserInfoBody} from "@/services/modules/auth"; +import {type Nullable} from "@/utils/type"; -type Nullable = T | null; // ============================================================ // VipApiUrl — VIP 模块路由常量 @@ -133,8 +133,8 @@ export class VipAPI { } /** 获取 VIP 等级列表(已登录时按账户类型过滤) */ - static async getVipLevels(): Promise { - return await get(VipApiUrl.GetVipLevelsList); + static async getVipLevels(signal?: AbortSignal): Promise { + return await get(VipApiUrl.GetVipLevelsList, undefined, {signal}); } /** 创建 VIP 套餐购买订单 */ diff --git a/src/services/types.ts b/src/services/types.ts index 2a28db3..7eaad6d 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -4,32 +4,32 @@ /** 后端统一响应结构 */ export interface ApiResponse { - /** 业务状态码:0 表示成功 */ - code: number; - /** 响应数据 */ - data: T; - /** 提示信息 */ - message: string; - /** 请求追踪 ID(可选,用于排查问题) */ - traceId?: string; + /** 业务状态码:0 表示成功 */ + code: number; + /** 响应数据 */ + data: T; + /** 提示信息 */ + message: string; + /** 请求追踪 ID(可选,用于排查问题) */ + traceId?: string; } /** 分页请求参数 */ export interface PaginationParams { - page: number; - pageSize: number; + page: number; + pageSize: number; } /** 分页响应数据 */ export interface PaginatedData { - /** 数据列表 */ - list: T[]; - /** 总条数 */ - total: number; - /** 当前页码 */ - page: number; - /** 每页条数 */ - pageSize: number; + /** 数据列表 */ + list: T[]; + /** 总条数 */ + total: number; + /** 当前页码 */ + page: number; + /** 每页条数 */ + pageSize: number; } /** 分页响应包装 */ @@ -37,39 +37,39 @@ export type PaginatedResponse = ApiResponse>; /** 请求错误类型 */ export enum RequestErrorType { - /** 网络错误(断网等) */ - NETWORK = 'NETWORK', - /** 请求超时 */ - TIMEOUT = 'TIMEOUT', - /** HTTP 状态码异常(4xx / 5xx) */ - HTTP = 'HTTP', - /** 业务错误(code ≠ 0) */ - BUSINESS = 'BUSINESS', - /** 请求被取消 */ - CANCELLED = 'CANCELLED', - /** 认证过期(refresh_token 失效,需重新登录) */ - AUTH_EXPIRED = 'AUTH_EXPIRED', + /** 网络错误(断网等) */ + NETWORK = 'NETWORK', + /** 请求超时 */ + TIMEOUT = 'TIMEOUT', + /** HTTP 状态码异常(4xx / 5xx) */ + HTTP = 'HTTP', + /** 业务错误(code ≠ 0) */ + BUSINESS = 'BUSINESS', + /** 请求被取消 */ + CANCELLED = 'CANCELLED', + /** 认证过期(refresh_token 失效,需重新登录) */ + AUTH_EXPIRED = 'AUTH_EXPIRED', } /** 请求错误 */ export class RequestError extends Error { - type: RequestErrorType; - code?: number; - httpStatus?: number; - traceId?: string; + type: RequestErrorType; + code?: number; + httpStatus?: number; + traceId?: string; - constructor( - type: RequestErrorType, - message: string, - options?: { code?: number; httpStatus?: number; traceId?: string }, - ) { - super(message); - this.name = 'RequestError'; - this.type = type; - this.code = options?.code; - this.httpStatus = options?.httpStatus; - this.traceId = options?.traceId; - } + constructor( + type: RequestErrorType, + message: string, + options?: { code?: number; httpStatus?: number; traceId?: string }, + ) { + super(message); + this.name = 'RequestError'; + this.type = type; + this.code = options?.code; + this.httpStatus = options?.httpStatus; + this.traceId = options?.traceId; + } } // ---------- 自定义错误子类(全局统一捕获 → 事件总线触发)---------- @@ -81,13 +81,13 @@ export class RequestError extends Error { * 用法:throw new RefreshError('登录已过期,请重新登录'); */ export class RefreshError extends RequestError { - constructor(message = '登录已过期,请重新登录') { - super(RequestErrorType.AUTH_EXPIRED, message); - this.name = 'RefreshError'; - } + constructor(message = '登录已过期,请重新登录') { + super(RequestErrorType.AUTH_EXPIRED, message); + this.name = 'RefreshError'; + } } /** 判断一个错误是否为 RefreshError(支持 instanceof 或 type 判断) */ export function isRefreshError(err: unknown): err is RefreshError { - return err instanceof RefreshError || (err as RequestError)?.type === RequestErrorType.AUTH_EXPIRED; + return err instanceof RefreshError || (err as RequestError)?.type === RequestErrorType.AUTH_EXPIRED; } diff --git a/src/utils/display.ts b/src/utils/display.ts new file mode 100644 index 0000000..81370c5 --- /dev/null +++ b/src/utils/display.ts @@ -0,0 +1,16 @@ +// ============================================================ +// display — 显示格式化工具(金额、日期等) +// 各页面共用的纯函数,避免重复定义 +// ============================================================ + +/** 将分转为元,保留两位小数 */ +export function centToYuan(cent: number): string { + return (cent / 100).toFixed(2); +} + +/** 格式化日期为 zh-CN 本地字符串 */ +export function formatDate(date: Date | string | undefined): string { + if (!date) return '—'; + const d = typeof date === 'string' ? new Date(date) : date; + return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); +} diff --git a/src/utils/type.ts b/src/utils/type.ts new file mode 100644 index 0000000..94fd117 --- /dev/null +++ b/src/utils/type.ts @@ -0,0 +1 @@ +export type Nullable =T | null; \ No newline at end of file