feat: 数据获取架构重构 + Mac指纹采集 + Token刷新容错 + 账单页面 (0.0.19)
## 架构重构
- 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 不安全)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 844000,
|
||||
"pid": 77548,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
||||
"startedAt": 1781055291389
|
||||
"startedAt": 1781164466085
|
||||
}
|
||||
|
||||
130
ARCHITECTURE.md
130
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<BalanceResponseBody> {
|
||||
return get<BalanceResponseBody>(url, undefined, { signal });
|
||||
}
|
||||
|
||||
// Hook 层 — signal 由 useAsyncData 自动注入
|
||||
export function useBillingBalance() {
|
||||
return useAsyncData<BalanceResponseBody>(
|
||||
(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<XxxResponse>(
|
||||
(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<string, PageConfig> = {
|
||||
'/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()` 跨平台统一接口,自动选择最优采集方式
|
||||
|
||||
49
CHANGELOG.md
49
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<T>` → `(signal: AbortSignal) => Promise<T>`
|
||||
- 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)
|
||||
|
||||
**前后端联调 — 任务创建 + 账户信息 + 预估花费**
|
||||
|
||||
23
CLAUDE.md
23
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 仍走标签替换路径,过渡无效
|
||||
22
TODO.md
22
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,纯前端限制对脚本攻击无效)
|
||||
|
||||
@@ -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<Component, ComponentQuery> = {
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Mac 查询配置
|
||||
// ============================================================
|
||||
|
||||
interface MacComponentQuery {
|
||||
command: string[];
|
||||
/** 从命令 stdout 中提取目标值 */
|
||||
extract: (output: string) => string;
|
||||
}
|
||||
|
||||
const MAC_COMPONENT_QUERIES: Record<Component, MacComponentQuery> = {
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, PageConfig> = {
|
||||
},
|
||||
'/vip': {label: '开会员/激活'},
|
||||
'/recharge': {label: '充值积分'},
|
||||
'/billing': {label: '消费记录'},
|
||||
'/orders': {label: '订单明细'},
|
||||
'/billing': {label: '账单记录',
|
||||
component: BillingInfo,
|
||||
width: 960,
|
||||
height: 680,},
|
||||
'/orders': {
|
||||
label: '订单明细',
|
||||
},
|
||||
'/projects': {label: '项目管理'},
|
||||
'/quota': {label: '查配额'},
|
||||
};
|
||||
|
||||
@@ -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<Banner[]>(
|
||||
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<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
|
||||
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<TaskInfoItemResponseBody>(
|
||||
() => 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<BalanceResponseBody>(
|
||||
(signal) => BillingAPI.getBalance(signal),
|
||||
[],
|
||||
{ enabled: isLoggedIn, label: 'billing-balance' },
|
||||
);
|
||||
}
|
||||
|
||||
/** 账单明细 Hook(支持分页参数) */
|
||||
export function useBillingLedger(params: LedgerReqeustParam) {
|
||||
const { isLoggedIn } = useAppContext();
|
||||
return useAsyncData<LedgerItemResponseBody[]>(
|
||||
(signal) => BillingAPI.getLedger(params, signal),
|
||||
[params],
|
||||
{ enabled: isLoggedIn, label: 'billing-ledger' },
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 提交任务
|
||||
// ============================================================
|
||||
|
||||
@@ -82,7 +82,7 @@ export interface UseAsyncDataReturn<T> {
|
||||
* - `enabled: false` 时不发起请求(适用于需登录后请求的场景)
|
||||
*/
|
||||
export function useAsyncData<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
fetcher: (signal: AbortSignal) => Promise<T>,
|
||||
deps: unknown[],
|
||||
options: UseAsyncDataOptions = {},
|
||||
): UseAsyncDataReturn<T> {
|
||||
@@ -117,16 +117,19 @@ export function useAsyncData<T>(
|
||||
|
||||
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<T>(
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
abortController.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, refreshTick, ...deps]);
|
||||
|
||||
@@ -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<UserInfoBody | null>(null);
|
||||
const [vipsInfo, setVipsInfo] = useState<VipLevelsItem[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Result
|
||||
status="error"
|
||||
title="加载失败"
|
||||
subTitle={error || '未能获取账户信息'}
|
||||
subTitle={errorMessage || '未能获取账户信息'}
|
||||
extra={
|
||||
<Button type="primary" onClick={load}>
|
||||
<Button type="primary" onClick={refetch}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
281
src/pages/headers/BillingInfo.tsx
Normal file
281
src/pages/headers/BillingInfo.tsx
Normal file
@@ -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 (
|
||||
<Card size="small" className="rounded-md!">
|
||||
<Skeleton active paragraph={{rows: 3}} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ======== 错误态 ========
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card size="small" className="rounded-md!">
|
||||
<Result
|
||||
status="error"
|
||||
title="余额加载失败"
|
||||
subTitle={errorMessage}
|
||||
extra={
|
||||
<Button type="primary" size="small" onClick={refetch}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ======== 正常展示 ========
|
||||
const totalBalance = (data.balance_cent ?? 0) + (data.gift_balance_cent ?? 0);
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
styles={{body: {padding: '16px 20px'}}}
|
||||
className="rounded-md!"
|
||||
>
|
||||
<Text type="secondary" style={{fontSize: 12, marginBottom: 8, display: 'block'}}>
|
||||
<WalletOutlined style={{marginRight: 6}} />
|
||||
账户总积分
|
||||
</Text>
|
||||
<Title level={3} style={{margin: '0 0 12px 0'}}>
|
||||
{centToYuan(totalBalance)}
|
||||
<Text type="secondary" style={{fontSize: 14, marginLeft: 4}}>
|
||||
{data.currency}
|
||||
</Text>
|
||||
</Title>
|
||||
<Space size={32}>
|
||||
<div>
|
||||
<Text type="secondary" style={{fontSize: 12}}>主积分</Text>
|
||||
<br />
|
||||
<Text strong>{centToYuan(data.balance_cent ?? 0)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{fontSize: 12}}>赠送积分</Text>
|
||||
<br />
|
||||
<Text strong>{centToYuan(data.gift_balance_cent ?? 0)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Space size={4}>
|
||||
<RiseOutlined style={{color: token.colorWarning}} />
|
||||
<Text type="secondary" style={{fontSize: 12}}>今日消耗</Text>
|
||||
</Space>
|
||||
<br />
|
||||
<Text strong>{centToYuan(data.today_spent_cent)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Space size={4}>
|
||||
<ApiOutlined style={{color: token.colorPrimary}} />
|
||||
<Text type="secondary" style={{fontSize: 12}}>今日调用</Text>
|
||||
</Space>
|
||||
<br />
|
||||
<Text strong>{data.today_calls} 次</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 账单表格
|
||||
// ============================================================
|
||||
|
||||
/** 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();
|
||||
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) => (
|
||||
<Text style={{fontSize: 12, whiteSpace: 'nowrap'}}>
|
||||
{formatDate(record.created_at)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'entry_type',
|
||||
key: 'entry_type',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (type: string) => (
|
||||
<Tag color={entryTypeColor(type)} style={{fontSize: 11, lineHeight: '18px'}}>
|
||||
{resolveEntryLabel(type)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_cent',
|
||||
key: 'amount_cent',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (amount: number): React.ReactNode => {
|
||||
const isNegative = amount < 0;
|
||||
return (
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: isNegative ? token.colorError : token.colorSuccess,
|
||||
}}
|
||||
>
|
||||
{isNegative ? '-' : '+'}
|
||||
{centToYuan(Math.abs(amount))}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '余额',
|
||||
dataIndex: 'balance_after_cent',
|
||||
key: 'balance_after_cent',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (val: number) => (
|
||||
<Text style={{fontSize: 12}}>{centToYuan(val)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
align: 'left' as const,
|
||||
render: (desc: string) => (
|
||||
<Text style={{fontSize: 12}}>{desc || '—'}</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ======== 错误态 ========
|
||||
if (error) {
|
||||
return (
|
||||
<Card size="small" className="rounded-md!">
|
||||
<Result
|
||||
status="error"
|
||||
title="账单加载失败"
|
||||
subTitle={errorMessage}
|
||||
extra={
|
||||
<Button type="primary" size="small" onClick={refetch}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ======== 正常 / 加载态 ========
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
styles={{body: {padding: 0}}}
|
||||
className="rounded-md!"
|
||||
>
|
||||
<Table<LedgerItemResponseBody>
|
||||
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: '暂无消费记录'}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 页面入口
|
||||
// ============================================================
|
||||
|
||||
export function BillingInfo() {
|
||||
return (
|
||||
<div style={{padding: '8px 0', width: '100%'}}>
|
||||
<div className="flex flex-col gap-4" style={{width: '100%'}}>
|
||||
<BalanceCard />
|
||||
<LedgerTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import {Component} from "react";
|
||||
|
||||
export class Test1 extends Component {
|
||||
render() {
|
||||
return (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -714,7 +714,7 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
|
||||
</div>
|
||||
|
||||
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
|
||||
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: Math.min(window.outerHeight, window.outerHeight, 630) }}>
|
||||
<style>{tableCss}</style>
|
||||
<Table<TaskInfoItemResponseBody>
|
||||
className="task-table"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export const UserRoleTypeLabelMap: Record<UserRoleTypeValue, string> = {
|
||||
[UserRoleTypeEnum.User]: "用户账户",
|
||||
[UserRoleTypeEnum.Admin]: "管理账户",
|
||||
[UserRoleTypeEnum.SuperAdmin]: "超管账户"
|
||||
}
|
||||
};
|
||||
|
||||
export const UserAccountTypeEnum = {
|
||||
Individual: 'individual',
|
||||
@@ -197,8 +197,8 @@ export class AuthAPI {
|
||||
}
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
static async getUserInfo(): Promise<UserInfoBody> {
|
||||
return await get<UserInfoBody>(AuthUrlObj.userInfo);
|
||||
static async getUserInfo(signal?: AbortSignal): Promise<UserInfoBody> {
|
||||
return await get<UserInfoBody>(AuthUrlObj.userInfo, undefined, { signal });
|
||||
}
|
||||
|
||||
/** 修改密码(已登录) */
|
||||
|
||||
@@ -38,6 +38,6 @@ export type BannerListResponse = Banner[];
|
||||
* 获取 Banner 轮播图列表
|
||||
* GET /api/v1/banners
|
||||
*/
|
||||
export function fetchBanners(): Promise<BannerListResponse> {
|
||||
return get<BannerListResponse>(BannerUrlObj.list);
|
||||
export function fetchBanners(signal?: AbortSignal): Promise<BannerListResponse> {
|
||||
return get<BannerListResponse>(BannerUrlObj.list, undefined, { signal });
|
||||
}
|
||||
|
||||
57
src/services/modules/billing.ts
Normal file
57
src/services/modules/billing.ts
Normal file
@@ -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<number>,
|
||||
gift_balance_cent: Nullable<number>,
|
||||
currency: "CNY",
|
||||
today_spent_cent: number,
|
||||
today_calls: number
|
||||
}
|
||||
|
||||
|
||||
export interface LedgerReqeustParam {
|
||||
month: Nullable<Date>,
|
||||
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<string>
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export class BillingAPI {
|
||||
static getBalance(signal?: AbortSignal): Promise<BalanceResponseBody> {
|
||||
return get<BalanceResponseBody>(BillingUrlObj.getBalance, undefined, {signal});
|
||||
}
|
||||
|
||||
static getLedger(param: LedgerReqeustParam, signal?: AbortSignal): Promise<LedgerItemResponseBody[]> {
|
||||
return get<LedgerItemResponseBody[]>(BillingUrlObj.getLedger, param as unknown as Record<string, unknown>, {signal});
|
||||
}
|
||||
|
||||
static exportUrl = BillingUrlObj.ExportLedger;
|
||||
}
|
||||
@@ -144,8 +144,8 @@ export class ModelAPI {
|
||||
* 获取可用模型列表
|
||||
* GET /api/v1/models
|
||||
*/
|
||||
static fetchModels(params: ModelsListReqeustParams): Promise<ModelsListResponseBody[]> {
|
||||
return get<ModelsListResponseBody[]>(ModelsUrlObj.modelList, params as unknown as Record<string, unknown>);
|
||||
static fetchModels(params: ModelsListReqeustParams, signal?: AbortSignal): Promise<ModelsListResponseBody[]> {
|
||||
return get<ModelsListResponseBody[]>(ModelsUrlObj.modelList, params as unknown as Record<string, unknown>, { signal });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<TaskInfoDataResponseBody> {
|
||||
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>);
|
||||
static getTaskList(params: TaskListRequestParams, signal?: AbortSignal): Promise<TaskInfoDataResponseBody> {
|
||||
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>, { signal });
|
||||
}
|
||||
|
||||
/** 提交新任务(device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */
|
||||
@@ -220,8 +220,8 @@ export class TaskAPI {
|
||||
}
|
||||
|
||||
/** 获取任务详情 */
|
||||
static getTaskInfo(task_id: string): Promise<TaskInfoItemResponseBody> {
|
||||
return get<TaskInfoItemResponseBody>(TaskUrlObj.taskInfo(task_id));
|
||||
static getTaskInfo(task_id: string, signal?: AbortSignal): Promise<TaskInfoItemResponseBody> {
|
||||
return get<TaskInfoItemResponseBody>(TaskUrlObj.taskInfo(task_id), undefined, { signal });
|
||||
}
|
||||
|
||||
/** 轻量轮询任务状态 */
|
||||
|
||||
@@ -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> = T | null;
|
||||
|
||||
// ============================================================
|
||||
// VipApiUrl — VIP 模块路由常量
|
||||
@@ -133,8 +133,8 @@ export class VipAPI {
|
||||
}
|
||||
|
||||
/** 获取 VIP 等级列表(已登录时按账户类型过滤) */
|
||||
static async getVipLevels(): Promise<VipLevelsItem[]> {
|
||||
return await get<VipLevelsItem[]>(VipApiUrl.GetVipLevelsList);
|
||||
static async getVipLevels(signal?: AbortSignal): Promise<VipLevelsItem[]> {
|
||||
return await get<VipLevelsItem[]>(VipApiUrl.GetVipLevelsList, undefined, {signal});
|
||||
}
|
||||
|
||||
/** 创建 VIP 套餐购买订单 */
|
||||
|
||||
@@ -4,32 +4,32 @@
|
||||
|
||||
/** 后端统一响应结构 */
|
||||
export interface ApiResponse<T = unknown> {
|
||||
/** 业务状态码: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<T> {
|
||||
/** 数据列表 */
|
||||
list: T[];
|
||||
/** 总条数 */
|
||||
total: number;
|
||||
/** 当前页码 */
|
||||
page: number;
|
||||
/** 每页条数 */
|
||||
pageSize: number;
|
||||
/** 数据列表 */
|
||||
list: T[];
|
||||
/** 总条数 */
|
||||
total: number;
|
||||
/** 当前页码 */
|
||||
page: number;
|
||||
/** 每页条数 */
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 分页响应包装 */
|
||||
@@ -37,39 +37,39 @@ export type PaginatedResponse<T> = ApiResponse<PaginatedData<T>>;
|
||||
|
||||
/** 请求错误类型 */
|
||||
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;
|
||||
}
|
||||
|
||||
16
src/utils/display.ts
Normal file
16
src/utils/display.ts
Normal file
@@ -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' });
|
||||
}
|
||||
1
src/utils/type.ts
Normal file
1
src/utils/type.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Nullable<T> =T | null;
|
||||
Reference in New Issue
Block a user