- ARCHITECTURE.md: 新增任务轮询/文件上传/提交防抖/主动刷新定时器/失败分级五节,更新 hooks 目录结构,日期→06-12 - CHANGELOG.md: 新增 0.0.20 版本条目 - TODO.md: 标记公告接入/任务轮询为已完成,新增任务CSV导出/上传端点确认待办 - CLAUDE.md 保持不动(按规则不修改)
23 KiB
船长 · 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 只需三行:
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 页面注册:
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 遵循统一范式:
XxxUrlObj— 模块路由常量(不 export,模块私有)- DTO 类型 — 请求体/响应体 interface
XxxAPIclass — 静态方法,调用方式XxxAPI.method()
示例:
// 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 } |
记住账号偏好(不存密码) |
环境变量
主进程加载顺序(由高到低优先级)
- OS 环境变量 —
set KEY=VALUE && .\app.exe,不覆盖已有值 .env.local— 不进 git,个人覆盖(打包后连测试服务器等特殊场景).env.{mode}— 开发:.env.development,构建:.env.production- 代码默认值 —
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 三级回退策略:
system_profiler SPNVMeDataType→ NVMe 磁盘序列号(Apple Silicon / 新款 Intel)system_profiler SPSerialATADataType→ SATA 磁盘序列号(老款 Intel Mac)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 风格),后端不识别。修复方案:
// ❌ 错误: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:
const result = await submitTask({...});
if (result !== undefined) {
submitCountRef.current += 1; // 成功
} else {
message.error(errorMessageRef.current || '任务提交失败');
}