# 船长 · 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 { return get(url, undefined, { signal }); } // Hook 层 — signal 由 useAsyncData 自动注入 export function useBillingBalance() { return useAsyncData( (signal) => BillingAPI.getBalance(signal), [], { enabled: isLoggedIn, label: 'billing-balance' }, ); } ``` ### AbortController 生命周期 - 每次 effect 创建新 `AbortController` - cleanup 时调用 `abortController.abort()` → 真正中断飞行中的 HTTP 请求(不仅仅是丢弃响应) - `signal.aborted` 检查:取消的请求静默忽略,不设置 error 状态 - 解决 React StrictMode 开发模式下组件双重挂载导致的重复请求问题 ### 新增 Hook 规范 在 `use-api.ts` 中添加新 Hook 只需三行: ```typescript export function useXxx() { const { isLoggedIn } = useAppContext(); return useAsyncData( (signal) => XxxAPI.getXxx(signal), [], // deps — 为空表示只请求一次,refetch 手动触发 { enabled: isLoggedIn, label: 'xxx' }, ); } ``` --- ## 核心流程 ### 1. 登录 ``` 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 = { '/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 { 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(); └─ └─ 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 || '任务提交失败'); } ```