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

17 KiB
Raw Blame History

船长 · 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/*.tssignal?: 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 是关键——否则网络短暂中断会导致用户被迫手动重新登录。
> 只有 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 页面注册

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()

示例:

// 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 stringUUID 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.tsgetApiBaseUrl() 的 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 IOPlatformExpertDeviceUUIDsysctl -n hw.modelCPU 型号)、system_profilerHDD 三级回退)

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() 跨平台统一接口,自动选择最优采集方式