Files
ele-HeiXiu/ARCHITECTURE.md
YoungestSongMo 4237dcbaf6 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 不安全)
2026-06-11 20:08:44 +08:00

385 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 船长 · HeiXiu — 前端架构文档
> 最后更新2026-06-09
## 目录结构
```
src/
├── components/ # 全局组件Provider、导航栏、通用组件
│ ├── AppProvider.tsx # 应用根 Provider登录态 + 平台/版本 + 自动登录
│ ├── ThemeProvider.tsx # 主题 Provider亮/暗切换 + Ant Design ConfigProvider
│ ├── navbar/ # 自定义导航栏(替代系统菜单)
│ └── common/ # 通用组件LegalTextModal 等)
├── contexts/ # React Context 定义 + Consumer Hook
│ └── app-context.ts # AppContextValue 类型 + useAppContext()
├── hooks/ # 通用 Hook
│ ├── use-theme.ts # 主题切换
│ └── use-updater.ts # 版本更新
├── pages/ # 页面
│ ├── login/ # 登录/注册/忘记密码/修改密码
│ │ ├── index.tsx # LoginPage — Modal 弹层
│ │ ├── components/ # LoginForm / RegisterForm / ForgotPasswordModal / ChangePasswordModal / FormField
│ │ ├── config/ # 字段配置 / 校验规则
│ │ ├── hooks/ # use-auth-state / use-sms-cooldown
│ │ └── types.ts # LoginFormValues / RegisterFormValues / FieldConfig
│ └── settings/ # 设置面板(主题 / 存储 / 账号安全 / 系统信息 / 更新)
│ ├── SettingsPage.tsx # 设置 Modal 主页面
│ ├── index.ts # barrel export
│ └── blocks/ # 设置子区块组件
│ ├── ThemeSetting.tsx
│ ├── StoragePathSetting.tsx
│ ├── SystemInfoSetting.tsx
│ ├── UpdateSetting.tsx
│ └── PasswordSetting.tsx
├── services/ # HTTP 层 + API 模块
│ ├── request.ts # Axios 封装(通用 HTTP 层,不知晓 auth
│ ├── auth-token.ts # Token 生命周期refresh_token 存取 + 401 刷新回调)
│ ├── types.ts # ApiResponse / RequestError / 分页类型
│ └── modules/ # API 模块(一个模块一个文件)
│ ├── index.ts # barrel 统一导出
│ ├── auth.ts # AuthAPI class + DTO 类型 + AuthUrlObj
│ └── announcement.ts
├── theme/ # Tailwind + Ant Design 主题
├── utils/ # 工具函数
│ ├── event-bus.ts # 发布-订阅事件总线
│ ├── device.ts # getDeviceId() — 设备 UUID
│ ├── platform.ts # 平台检测
│ └── ipc.ts # Electron IPC 安全封装
└── assets/ # 静态资源
```
---
## 分层架构
```
┌─────────────────────────────────────────────────┐
│ UI 层React 组件) │
│ AppProvider / LoginPage / NavBar / ... │
│ 职责:渲染 + 用户交互 + 消费 Context │
├─────────────────────────────────────────────────┤
│ 状态层Context + Hooks
│ AppContext / useAuthState / event-bus │
│ 职责:跨组件共享状态 + 跨模块事件通信 │
├─────────────────────────────────────────────────┤
│ 服务层API + Token
│ AuthAPI / auth-token / request.ts │
│ 职责HTTP 通信 + Token 生命周期 + 401 重试 │
├─────────────────────────────────────────────────┤
│ 存储层 │
│ localStorageaccess_token / refresh_token / │
│ device-id / auth 偏好) │
└─────────────────────────────────────────────────┘
```
### 层间依赖规则
- **UI 层** → 可以 import 状态层 + 服务层
- **状态层** → 可以 import 服务层
- **服务层** → 可以 import 工具层event-bus不 import UI 或状态层
- **request.ts** → 通用 HTTP 层,**不 import 任何 auth 模块**(通过 `setTokenRefreshHandler` 解耦)
---
## 数据获取架构
### 三层模式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. 登录
```
LoginForm.onSubmit(values)
→ LoginPage.handleLogin
→ AuthAPI.login({ username, password, device_id, user_ip })
→ AppProvider.login(auth)
├─ setToken(access_token) → localStorage
├─ setStoredRefreshToken(...) → localStorage
├─ setUser(auth) → state
├─ setIsLoggedIn(true) → state
└─ emit(LOGIN_SUCCESS, auth) → 事件总线
→ onClose() → 关闭登录弹窗
```
### 2. 自动登录(静默 refresh
```
App 启动 → AppProvider useEffect
→ getAutoLoginFlag() === true ?
→ 是 → getStoredRefreshToken()
→ AuthAPI.refreshToken({ refresh_token })
✅ 成功 → setToken + setUser + emit(LOGIN_SUCCESS) (无弹窗)
❌ 失败 → clearStoredRefreshToken + clearSavedAuth (下次需手动登录)
→ 否 → 跳过
```
> **StrictMode 清理**:该 useEffect 持有 `aborted` 标志cleanup 返回 `() => { aborted = true }`。
> 所有异步回调(`.then` / `.catch`)在操作 state 或发射事件前检查 `if (aborted) return`。
> 这防止 React StrictMode开发模式挂载→卸载→重新挂载时发起重复的 refreshToken / getUserInfo 请求。
### 3. 401 → 刷新 Token → 自动重试
```
任意请求发起
→ 服务端返回 401业务码 401 或 HTTP 401
→ request.ts handle401(config)
→ refreshHandler 已注册?
→ 是 → 调 refreshHandler()
→ auth-token.ts 回调
→ AuthAPI.refreshToken({ refresh_token })
✅ 成功 → 用新 token 重试原请求(用户完全无感)
❌ 失败 → clearToken + emit(AUTH_REQUIRED) → 弹出登录框
→ 否 → clearToken + emit(AUTH_REQUIRED) → 弹出登录框
并发 401 处理:
请求 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 是关键——否则网络短暂中断会导致用户被迫手动重新登录。
> 只有 RefreshErrorrefresh 接口返回 401才确认 token 真的过期。
```
### 4. 退出登录
```
NavBar / 设置页 → AppProvider.logout()
├─ clearToken()
├─ clearStoredRefreshToken()
├─ setUser(null)
├─ setIsLoggedIn(false)
└─ emit(LOGOUT)
```
---
## 关键模块说明
### request.ts通用 HTTP 层)
- 职责Axios 实例 + 拦截器 + `get/post/put/del` + 错误分类
- **不依赖**任何 auth 模块
- 暴露 `setTokenRefreshHandler(fn)` 供 auth-token.ts 注册 401 回调
- 暴露 `getToken()/setToken()/clearToken()` 供外部存取 access_token
- 401 拦截逻辑内置重试队列,支持并发请求去重
### auth-token.tsToken 生命周期)
- 职责refresh_token 的存取 + 注册 `handle401` 回调
- `initAuthRefresh()` 在 AppProvider 挂载时调用一次
- 回调内部调用 `AuthAPI.refreshToken()`,成则更新双 token败则清除
### event-bus.ts事件总线
- 纯发布-订阅,不依赖任何框架
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS` / `OPEN_PAGE`
### 页面叠加层模式QT 多窗口模型映射)
本项目是 Python QT 桌面应用的 Web 重构版。QT 应用以"新开窗口"为常态原窗口不因新窗口打开而销毁。Web 端对应原则:
| QT 原版 | Web 对应 | 实现方式 |
|---------------|----------------|-------------------------------|
| 新开窗口叠加在主窗口之上 | Modal / Drawer | 事件总线驱动,主页面不卸载 |
| Tab 切换 / 视图替换 | React Router | 跨页状态放 Provider 或 localStorage |
**当前叠加层**
- `PageDispatcher` — 统一页面调度器,监听 `OPEN_PAGE` 事件,根据 `modalType` 渲染 Modal/Drawer/FloatingPanel全局互斥同一时刻最多一个面板
- LoginPage — Modal事件总线 `SHOW_LOGIN` / `AUTH_REQUIRED` 触发
- SettingsPage — Modal事件总线 `OPEN_SETTINGS` 触发
- AnnouncementDrawer — DrawerNavBar 内部 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 模块规范
每个 `services/modules/*.ts` 遵循统一范式:
1. `XxxUrlObj` — 模块路由常量(不 export模块私有
2. DTO 类型 — 请求体/响应体 interface
3. `XxxAPI` class — 静态方法,调用方式 `XxxAPI.method()`
示例:
```ts
// auth.ts
const AuthUrlObj = {login: '/api/v1/auth/login', ...} as const;
export interface LoginRequestBody {}
export interface LoginResponseBody {}
export class AuthAPI {
static login(data: LoginRequestBody): Promise<LoginResponseBody> {
return post(AuthUrlObj.login, data);
}
}
```
---
## 数据存储localStorage
| Key | 类型 | 说明 |
|----------------------------|-------------------------------------|-------------------|
| `ele-heixiu-token` | `string` | JWT access_token |
| `ele-heixiu-refresh-token` | `string` | JWT refresh_token |
| `ele-heixiu-device-id` | `string`UUID v4 | 设备唯一标识,首次自动生成 |
| `ele-heixiu-auth` | `{ username, remember, autoLogin }` | 记住账号偏好(**不存密码** |
---
## 环境变量
### 主进程加载顺序(由高到低优先级)
1. **OS 环境变量**`set KEY=VALUE && .\app.exe`,不覆盖已有值
2. **`.env.local`** — 不进 git个人覆盖打包后连测试服务器等特殊场景
3. **`.env.{mode}`** — 开发:`.env.development`,构建:`.env.production`
4. **代码默认值**`updater.ts``getApiBaseUrl()` 的 fallback
> **安全说明**`UPDATE_API_URL` 是公开的 API 端点地址,不是密钥。反编译 ASAR 可看到但这不影响安全——应用本身就要连到这个地址。真正的敏感信息JWT
> token、密码是运行时获取的不写死在代码中。
### 渲染进程Vite 编译时注入)
| 变量 | 开发值 | 生产值 |
|---------------------|----------------------------|---------------------------|
| `VITE_API_BASE_URL` | `http://8.160.179.64:8000` | `https://www.heixiu.net` |
| `VITE_EDITION` | `personal` | `personal` / `enterprise` |
### 主进程Node.js 运行时读取)
| 变量 | 开发值(.env.development | 生产值(.env.production | 默认值 |
|------------------|-------------------------|--------------------------|--------------------------|
| `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()` 跨平台统一接口,自动选择最优采集方式