# 船长 · 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      # 通用异步数据 Hook（useAsyncData / 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 重试       │
├─────────────────────────────────────────────────┤
│  存储层                                          │
│  localStorage（access_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 是关键——否则网络短暂中断会导致用户被迫手动重新登录。
> 只有 RefreshError（refresh 接口返回 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.ts（Token 生命周期）

- 职责：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 — 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 模块规范

每个 `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 || '任务提交失败');
}
```
