Files
ele-HeiXiu/ARCHITECTURE.md
YoungestSongMo 54d12aaf26 docs: 更新架构文档、版本日志、TODO清单 (0.0.20)
- ARCHITECTURE.md: 新增任务轮询/文件上传/提交防抖/主动刷新定时器/失败分级五节,更新 hooks 目录结构,日期→06-12
- CHANGELOG.md: 新增 0.0.20 版本条目
- TODO.md: 标记公告接入/任务轮询为已完成,新增任务CSV导出/上传端点确认待办
- CLAUDE.md 保持不动(按规则不修改)
2026-06-12 19:14:39 +08:00

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