Compare commits

..

6 Commits

Author SHA1 Message Date
6204c14b88 feat: 重构更新流程 — 无更新不抛异常、用户决定下载、更新详情展示(md文件中的)
【核心重构】
  - getLatestVersion() 不再 throw Error,无更新时返回 { version: APP_VERSION }
  - checkForUpdates() 先自行预检 → 无更新直发 IPC,有更新才交 electron-updater
  - 移除 NO_UPDATE_MESSAGE 常量及所有字符串匹配拦截代码
  - HeiXiuProvider 增加 500ms TTL 缓存,避免预检+electron-updater 重复请求 API

  【用户体验】
  - 检查到新版本后不再自动下载,展示更新内容供用户决定
  - 新增"下载更新"按钮(与"安装更新"分离为两步操作)
  - 设置页新增"查看详情"弹窗,Markdown 格式化渲染更新日志
  - available / downloading / downloaded 三个状态 UI 独立展示

  【Bug 修复】
  - 测试服务器版本排序:字符串序 → 语义版本序(_version_key),0.0.10 正确 > 0.0.9
  - 版本比较:!= → <(_version_key),防止高版本误判为有更新
  - get_best_release fallback 同样改为语义版本排序
  - Windows cmd set 命令尾部空格 → Invalid URL(.trim() 修复)
  - electron-updater 'error' 事件中拦截 NO_UPDATE_MESSAGE → UPDATE_NOT_AVAILABLE

  【文档更新】
  - CHANGELOG.md、PROJECT.md、UpdateA.md 同步更新架构图和流程说明
2026-06-04 18:31:52 +08:00
b55c2fdd2e feat: 日志模块 + netRequest 封装 + PROJECT.md 更新
日志模块(文件持久化 + 事件总线联动 + 脱敏)
    新增:
      - shared/types/logging.ts         — LogLevel / LogCategory / LogEntry 类型
      - electron/main/logger.ts         — 主进程日志核心(JSON Lines / 每日轮转 / 7天清理)
      - electron/main/log-ipc.ts        — 渲染进程日志 IPC 通道接收
      - src/utils/logger.ts             — 渲染进程日志器(IPC 转发 / DEV 控制台 / 事件总线钩子)
      - shared/utils/sanitize.ts        — 脱敏工具(ID哈希 / 邮箱 / 手机 / 名称 / Token)
    修改:
      - electron/main.ts                — initLogger + flushLogger + uncaughtException
      - src/main.tsx                    — renderer 全局错误捕获
      - src/utils/event-bus.ts          — setEventLogListener 钩子,emit 自动审计
      - src/services/request.ts         — 拦截器结构化错误分级记录
      - electron/main/updater.ts        — 14处 console → logger 迁移
      - src/components/AppProvider.tsx   — 登录/登出/自动登录 auth 事件日志
      - shared/constants/ipc-channels.ts — 新增 LOG_MESSAGE 通道

  netRequest 封装(Electron net.request 的 axios 风格 API)
    新增:
      - electron/main/net-request.ts     — netRequest.get/post/put/del 命名空间
    修改:
      - electron/main/updater.ts         — fetchUpdateInfo 改用 netRequest(22→12行)

  文档更新
    - PROJECT.md                       — 目录结构 / 状态管理 / 日志系统 / IPC 章节同步现状
    - TODO.md                          — 增量更新改造 + 日志模块待办
    - UpdateA.md                       — API blockmap 字段 / 数据库字段说明
2026-06-04 11:03:36 +08:00
ae733016bb feat: 日志模块 + netRequest 封装 + PROJECT.md 更新
日志模块(文件持久化 + 事件总线联动 + 脱敏)
    新增:
      - shared/types/logging.ts         — LogLevel / LogCategory / LogEntry 类型
      - electron/main/logger.ts         — 主进程日志核心(JSON Lines / 每日轮转 / 7天清理)
      - electron/main/log-ipc.ts        — 渲染进程日志 IPC 通道接收
      - src/utils/logger.ts             — 渲染进程日志器(IPC 转发 / DEV 控制台 / 事件总线钩子)
      - shared/utils/sanitize.ts        — 脱敏工具(ID哈希 / 邮箱 / 手机 / 名称 / Token)
    修改:
      - electron/main.ts                — initLogger + flushLogger + uncaughtException
      - src/main.tsx                    — renderer 全局错误捕获
      - src/utils/event-bus.ts          — setEventLogListener 钩子,emit 自动审计
      - src/services/request.ts         — 拦截器结构化错误分级记录
      - electron/main/updater.ts        — 14处 console → logger 迁移
      - src/components/AppProvider.tsx   — 登录/登出/自动登录 auth 事件日志
      - shared/constants/ipc-channels.ts — 新增 LOG_MESSAGE 通道

  netRequest 封装(Electron net.request 的 axios 风格 API)
    新增:
      - electron/main/net-request.ts     — netRequest.get/post/put/del 命名空间
    修改:
      - electron/main/updater.ts         — fetchUpdateInfo 改用 netRequest(22→12行)

  文档更新
    - PROJECT.md                       — 目录结构 / 状态管理 / 日志系统 / IPC 章节同步现状
    - TODO.md                          — 增量更新改造 + 日志模块待办
    - UpdateA.md                       — API blockmap 字段 / 数据库字段说明
2026-06-04 11:03:25 +08:00
337234443a chore: 从版本控制移除工作日志文件,改为仅本地保留 2026-06-03 19:44:46 +08:00
397d80c87b docs: 添加工作日志与待办清单(含安全加固计划) 2026-06-03 19:38:10 +08:00
3becdb85d4 feat: 实现设置页面 + 路由系统 + JWT认证体系 + 公告优化 + 错误类型系统
## 新增功能

### 设置页面(Router + Modal 叠加)
- HashRouter 路由系统,Electron file:// 协议兼容
- 设置页以居中 Modal 叠加首页,仅 X 按钮关闭
- 主题切换 Segmented 控件(与首页 Switch 共享 Context)
- 大模型产物存储仓库路径配置(原生文件夹选择对话框)
- 企业版额外:团队创作存储仓库路径
- 系统信息展示(平台/版本/版本类型/环境)
- 版本更新检查(状态机:idle→checking→no-update/available→downloading→downloaded→error)

### JWT 认证体系
- 双 Token 机制:access_token + refresh_token
- Axios 拦截器 401 自动刷新 + 并发请求去重队列
- 启动时 refresh_token 静默续期(自动登录)
- 记住密码:Base64 编码存储,表单自动回填
- setTokenRefreshHandler 解耦模式:request.ts 不知晓 auth

### 自定义错误类型系统
- RefreshError 继承 RequestError,type = AUTH_EXPIRED
- 全局拦截器捕获 RefreshError → 自动 clearToken + AUTH_REQUIRED
- 任何位置 throw new RefreshError() 即可触发登录 Modal

### 公告卡片优化
- 长文本 Tooltip 悬浮预览
- 点击卡片弹出详情 Modal(全文保留换行)

### SettingsContext(类似 Vue Pinia)
- 集中式设置状态管理
- localStorage 持久化:ele-heixiu-output-path / ele-heixiu-team-repo-path
- useSettings() 全局消费

## 架构变更
- App.tsx → HashRouter + AppRoutes + LoginPage 弹层
- Layout.tsx = NavBar + <Outlet /> 页面壳
- HomePage.tsx 从 App.tsx 提取
- NavBar 使用 useNavigate() 真实路由导航
- useUpdater 改为模块级单例 IPC 监听器(修复 MaxListenersExceededWarning)
- electron/main.ts 新增 FILE_DIALOG IPC handler

## 依赖
- 新增 react-router-dom
2026-06-03 19:25:15 +08:00
67 changed files with 5206 additions and 2358 deletions

17
.codegraph/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
codegraph-commands.md

6
.codegraph/daemon.pid Normal file
View File

@@ -0,0 +1,6 @@
{
"pid": 91784,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
"startedAt": 1780539931451
}

View File

@@ -1,16 +1,16 @@
# ============================================================ # ============================================================
# 开发环境 — 连接测试服务器 # 开发环境 — 仅在 npm run dev 时加载
# 环境变量模板 — 复制为 .env.development / .env.production 后填入实际值
# Vite 仅暴露 VITE_ 前缀的变量给客户端代码import.meta.env.VITE_xxx
# ============================================================ # ============================================================
# API 基础地址(测试服务器 # 渲染进程 API 地址(Vite 编译时注入,不可在主进程中使用
VITE_API_BASE_URL=http://8.160.179.64:8000 VITE_API_BASE_URL=http://8.160.179.64:8000
# 版本类型personal个人版| enterprise企业版 # 主进程 — 更新检查 API指向本地测试服务器
UPDATE_API_URL=http://127.0.0.1:8000
# 版本类型
VITE_EDITION=personal VITE_EDITION=personal
# VITE_EDITION=enterprise EDITION=personal
# 应用标题后缀(可选,覆盖默认构建标题) # 应用标题后缀
VITE_APP_TITLE=船长·HeiXiu VITE_APP_TITLE=船长·HeiXiu

View File

@@ -1,9 +1,18 @@
# ============================================================ # ============================================================
# 生产环境 # 生产环境 — 仅在 npm run build:xxx 打包时加载
# 打包后 .env.production 不在 ASAR 中 → 代码使用硬编码默认值
# 此文件仅在本地 build 时生效(如 CI/CD 构建流程)
# ============================================================ # ============================================================
# 生产服务器 API 地址 # 渲染进程 API 地址(生产服务器)
VITE_API_BASE_URL=https://www.heixiu.net VITE_API_BASE_URL=https://www.heixiu.net
# 生产版本(构建时可通过命令行 EDITION=enterprise 覆盖 # 主进程 — 更新检查 API生产服务器
UPDATE_API_URL=https://www.heixiu.net
# 版本类型
VITE_EDITION=personal VITE_EDITION=personal
EDITION=personal
# 应用标题后缀
VITE_APP_TITLE=船长·HeiXiu

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
*.py
*.pyi
WORKLOG.md

223
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,223 @@
# 船长 · HeiXiu — 前端架构文档
> 最后更新2026-06-03
## 目录结构
```
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 / FormField
│ ├── config/ # 字段配置 / 校验规则
│ ├── hooks/ # use-auth-state记住账号偏好
│ └── types.ts # LoginFormValues / RegisterFormValues
├── 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` 解耦)
---
## 核心流程
### 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 (下次需手动登录)
→ 否 → 跳过
```
### 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 广播给所有排队请求 → 全部重试
```
### 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`
### 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` |

View File

@@ -4,14 +4,16 @@
--- ---
## 0.0.12026-06-03 ## 0.0.122026-06-04
- 初始版本 - 重构更新检查流程无更新不再抛异常PreCheck → 直接 IPC 通知
- 蓝紫渐变主题(亮色 + 暗色) - 新增用户决定下载流程:检查到新版本后展示更新内容,用户确认后再下载
- 自定义 H5 导航栏 - 设置页新增"查看详情"弹窗Markdown 格式化展示更新日志
- 登录 / 注册 Modal - 新增"下载更新"按钮,与"安装更新"分离为两个独立操作
- 公告系统 - 修复测试服务器版本排序(字符串序 → 语义版本序0.0.10 正确排在 0.0.9 之后)
- 自动更新检查 - electron-updater Provider 增加 TTL 缓存,避免重复 API 请求
- 修复 Windows cmd `set` 命令空格导致的 Invalid URL 问题
- 修复 electron-updater 'error' 事件拦截,无更新不再爆红
--- ---

View File

@@ -1,3 +1,4 @@
# 回答语言 # 回答语言
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明 - 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录

View File

@@ -30,17 +30,21 @@
## 前端架构原则 ## 前端架构原则
- 组件化:以函数组件 + Hooks 为唯一组件形式UI 拆分为展示组件与容器组件(若无特殊需求,可使用 Hooks 直接连接 Redux - 组件化:以函数组件 + Hooks 为唯一组件形式UI 拆分为展示组件与容器组件。
- 单向数据流:全局状态使用 Redux Toolkit严格遵循 Action → Reducer → Store → View 单向流动。组件内部状态使用 - 单向数据流:全局状态使用 Context + Provider 模式AppContext / ThemeContext / SettingsContext
useState/useReducer不污染全局 Store 遵循 Provider → Context → Comsumer 单向流动。组件内部状态使用 useState/useReducer不污染全局 Context
- 事件驱动EDA - 事件驱动EDA
1. 主进程与渲染进程通信基于 Electron IPCipcMain/ipcRenderer定义明确的事件通道与载荷类型。 1. 主进程与渲染进程通信基于 Electron IPCipcMain/ipcRenderer定义明确的事件通道与载荷类型。
2. 渲染进程内跨组件通信优先使用 Redux;需要解耦的场景可借助自定义事件总线(如 mitt但严禁滥用。 2. 渲染进程内跨组件通信优先使用 Context;需要解耦的场景使用自定义事件总线(src/utils/event-bus.ts
所有 emit 调用自动记录到日志文件用于审计追踪。
3. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。 3. 自定义错误体系RequestError / RefreshError通过事件总线解耦请求拦截器抛出自定义错误 →
全局捕获 → emit 事件 → UI 层响应。
4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。
- 路由与导航:主窗口为 单页应用,使用 React Router v7+ 管理页面切换,支持嵌套路由。 - 路由与导航:主窗口为 单页应用,使用 React Router v7+ 管理页面切换,支持嵌套路由。
@@ -50,50 +54,62 @@
# 目录结构 # 目录结构
```md ```md
├── electron/ # Electron 主进程与预加载脚本 ├── electron/ # Electron 主进程
│ ├── main/ # 主进程代码 ├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期
├── index.ts # 入口,初始化应用 ├── preload.ts # 预加载脚本contextBridge
│ ├── windowManager.ts # 多窗口管理器(创建、销毁、聚焦) └── main/
│ ├── ipc/ # IPC 事件注册与处理 ├── updater.ts # 自定义更新API 版本检查 + OSS 下载 + NSIS 安装)
│ ├── menu/ # 系统菜单模板(按平台拆分 ├── logger.ts # 主进程日志核心JSON Lines 写入 / 轮转 / 清理
│ └── utils/ # 平台判断、路径处理等 ├── log-ipc.ts # 渲染进程日志 IPC 通道接收
└── preload/ # 预加载脚本 ├── net-request.ts # Electron net.request 的 axios 风格封装
├── index.ts # 主窗口预加载 ├── menu/index.ts # 系统菜单
└── subWindow.ts # 辅助窗口预加载 └── utils/ # 平台判断 / 图标路径
├── src/ # 渲染进程React 应用 ├── src/ # 渲染进程React 19 SPA
│ ├── main/ # 主窗口 SPA 入口 ├── main.tsx # React 挂载点 + 全局错误捕获
├── index.html # HTML 入口 ├── App.tsx # 根组件(路由 + 登录弹窗)
├── main.tsx # React 挂载点 ├── components/ # 通用 UI 组件 + Provider
│ └── App.tsx # 根组件(路由、主题 │ ├── AppProvider.tsx # 全局状态platform / edition / login
├── sub/ # 辅助窗口独立入口(一个窗口对应一个子目录 │ ├── ThemeProvider.tsx # 主题状态light / dark
├── settings/ # 设置窗口示例 ├── SettingsProvider.tsx # 设置状态(存储路径等)
│ ├── index.html ├── Layout.tsx # 页面壳NavBar + Outlet
│ └── main.tsx └── navbar/ # 导航栏 + 公告抽屉
│ └── preview/ # 预览窗口示例 ├── contexts/ # Context 定义app / settings / theme
│ ├── components/ # 通用 UI 组件 ├── hooks/ # 自定义 HooksuseUpdater / useTheme
│ ├── containers/ # 业务容器组件(连接 Redux ├── pages/ # 页面组件
├── store/ # Redux Toolkit 配置 │ ├── home/ # 首页仪表盘
│ ├── index.ts # Store 创建 │ ├── login/ # 登录 / 注册页
│ ├── slices/ # 按功能拆分 Slice │ └── settings/ # 设置面板(主题 / 存储 / 更新)
│ └── hooks.ts # 类型化 useAppSelector/useAppDispatch ├── router/index.tsx # HashRouter 路由配置
│ ├── hooks/ # 自定义 Hooks ├── services/ # HTTP 请求封装
├── theme/ # 主题适配器与定制 │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
│ ├── adapters/ # Ant Design 主题配置、Tailwind 扩展 │ ├── types.ts # 自定义错误类型RequestError / RefreshError
│ ├── tokens.ts # 设计令牌(颜色、圆角、阴影等) │ ├── auth-token.ts # refresh_token 管理
│ └── globals.css # Tailwind 指令 + 全局样式 │ └── modules/ # API 模块auth / announcement
│ ├── router/ # 路由配置(主窗口) ├── theme/ # 主题系统
├── utils/ # 纯函数工具 │ ├── tokens.ts # 设计令牌
│ └── platform.ts # 平台判定工具process.platform 封装) │ └── globals.css # Tailwind + 全局样式
│ └── types/ # 渲染进程专用类型 └── utils/ # 纯函数工具
│ ├── event-bus.ts # 事件总线on / off / emit + 日志钩子)
│ ├── logger.ts # 渲染进程日志器IPC 转发 / DEV 控制台)
│ ├── ipc.ts # 安全 IPC 守卫safeIpcOn / send / invoke
│ ├── platform.ts # 平台判定
│ └── device.ts # 设备 ID 生成
├── shared/ # 主进程 & 渲染进程共享 ├── shared/ # 主进程 & 渲染进程共享
│ ├── types/ # IPC 通道、载荷类型、通用接口 ├── types/ # 类型定义API / IPC / update / logging
│ ├── constants/ # 事件名称、配置常量 ├── constants/ # 常量IPC 通道 / version / app
│ └── utils/ # 通用逻辑(如版本比较) └── utils/ # 共享工具
├── package.json │ ├── index.ts # debounce / throttle / compareVersions
├── vite.config.ts # Vite 配置(多入口 │ └── sanitize.ts # 日志脱敏工具ID / 邮箱 / 手机 / 名称
├── tailwind.config.ts # Tailwind 主题扩展 ├── scripts/ # 构建脚本
│ ├── build.mjs # 构建总管
│ ├── clean.mjs # 清理产物
│ ├── upload-oss.mjs # OSS 上传
│ ├── publish-release.mjs # 发布到 API
│ └── generate-update-info.mjs # 生成更新元信息
├── package.json # 含 electron-builder 打包配置
├── vite.config.ts
├── tsconfig.json ├── tsconfig.json
└── electron-builder.yml # 打包配置(跨平台适配) └── tailwind.config.ts
``` ```
# 多窗口与平台判定 # 多窗口与平台判定
@@ -104,7 +120,7 @@
- src/utils/platform.ts 提供 isMacOS、isWindows 等函数(基于 navigator.userAgent 或 preload 暴露的 process.platform。在主进程的 - src/utils/platform.ts 提供 isMacOS、isWindows 等函数(基于 navigator.userAgent 或 preload 暴露的 process.platform。在主进程的
electron/main/utils/platform.ts 中直接使用 Node.js API。 electron/main/utils/platform.ts 中直接使用 Node.js API。
- 打包配置 (electron-builder.yml) 须针对平台设置不同的图标、签名策略、安装包格式。 - 打包配置 (package.json build 字段) 须针对平台设置不同的图标、签名策略、安装包格式。
# 代码风格 # 代码风格
@@ -131,13 +147,29 @@
- 使用 React.memo、useMemo、useCallback 优化性能,但不要过早优化。 - 使用 React.memo、useMemo、useCallback 优化性能,但不要过早优化。
## Redux ## 状态管理
- 使用 Redux Toolkit 的 createSlice 管理状态,异步逻辑使用 createAsyncThunk。 - 使用 React Context + Provider 模式管理全局状态按领域拆分AppContext平台/登录、ThemeContext主题
SettingsContext设置项。避免引入 Redux 等重量级方案。
- Slice 按业务领域划分,每个 Slice 文件导出 actions 与 reducer。 - Provider 组件在 main.tsx 中按层级嵌套挂载:
AppProvider → ThemeProvider → SettingsProvider → AntdApp → App
- 组件中通过自定义 Hooks (useAppSelector, useAppDispatch) 访问 Store避免直接使用 useDispatch/useSelector - 各模块通过自定义 Hook 消费(useAppContext / useTheme / useSettingsHook 内部做 null 检查
## 日志系统
- 双端日志主进程直接写文件electron/main/logger.ts渲染进程通过 IPC send 上报src/utils/logger.ts
两端暴露一致的 API`logger.debug / info / warn / error(category, message, error?, context?)`
- 文件存储JSON Lines 格式(每行一个 JSON路径 `{userData}/logs/app-YYYY-MM-DD.log`
每日轮转 + 7 天自动清理。
- 事件总线联动event-bus.ts 暴露 `setEventLogListener` 钩子logger 注册后所有 emit 自动记录 DEBUG 日志。
- 错误日志request.ts 拦截器根据错误类型自动分级记录AUTH_EXPIRED → debug5xx → error其余 → warn
- 脱敏shared/utils/sanitize.ts 在日志写入前自动处理 ID哈希、邮箱/手机/名称/Token 等字段。
# 样式与主题 # 样式与主题
@@ -153,11 +185,15 @@
# 事件与 IPC # 事件与 IPC
- 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,避免魔法字符串。 - 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,分为三类:
MAIN_TO_RENDERER / RENDERER_TO_MAIN / BIDIRECTIONAL。
- 渲染进程通过 src/utils/ipc.ts 中的安全守卫访问 IPChasIpc 检查 → safeIpcOn / send / invoke
非 Electron 环境下静默跳过,保证浏览器预览不崩溃。
- 主进程事件处理函数需做好错误捕获与日志记录,渲染进程调用 IPC 时使用 try-catch 并处理超时。 - 主进程事件处理函数需做好错误捕获与日志记录,渲染进程调用 IPC 时使用 try-catch 并处理超时。
- 窗口间通信走主进程转发:发送窗口 ipcRenderer.send主进程接收后定位目标窗口并 webContents.send。 - 窗口间通信走主进程转发:发送窗口 safeIpcSend主进程接收后定位目标窗口并 webContents.send。
# 开发与构建命令 # 开发与构建命令
@@ -169,6 +205,71 @@
- 类型检查npm run typechecktsc --noEmit - 类型检查npm run typechecktsc --noEmit
# 自动更新系统
## 架构
```
客户端 (Electron 主进程)
checkForUpdates()
├─ ① HeiXiuProvider.getLatestVersion() → 调用自有版本检查 API
│ └─ hasUpdate=false → 直接 IPC 通知 UI不经过 electron-updater
└─ ② hasUpdate=true → electron-updater.checkForUpdates()
├─ HeiXiuProvider.getLatestVersion() → TTL 缓存命中,不重复请求
├─ emit 'update-available'(含 releaseNotes→ UI 展示版本信息
└─ 用户点击"下载更新" → downloadUpdate()
├─ 差分下载(对比 .blockmap仅下载变更区块
├─ SHA512 校验
└─ 下载完成 → 用户点击"安装更新" → NSIS/DMG/AppImage 执行
```
| 模块 | 位置 | 职责 |
|------|------|------|
| `HeiXiuProvider` | `electron/main/updater.ts` | 自定义 Provider调用自有 API无更新返回 APP_VERSION |
| `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater |
| `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) |
| `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 |
| `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook单例 IPC 监听) |
| `UpdateSetting` | `src/pages/settings/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) |
| `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 |
## 环境变量加载机制(主进程)
加载顺序后者覆盖前者OS 环境变量始终优先):
```
① OS 环境变量set KEY=VALUE && .\app.exe ← 最高优先级
② .env.local不进 git个人覆盖用
③ .env.{mode}dev→.env.developmentbuild→.env.production
④ updater.ts 硬编码默认值https://www.heixiu.com
```
| 文件 | 何时生效 | 进 git | 用途 |
|------|---------|:------:|------|
| `.env.development` | `npm run dev` | ✅ | 本地开发API → 测试服务器) |
| `.env.production` | `npm run build:xxx` | ❌ | 构建时参考API → 生产服务器) |
| `.env.local` | 始终(后加载) | ❌ | 个人特殊配置(打包后连本地测试) |
> `.env.production` 和 `.env.local` 不打包进 ASAR。打包后无这些文件时自动使用代码默认值。
## 测试工作流
```powershell
# 1. 构建两个版本
npm run build:win # → release/0.0.3/
# 改 package.json version = "0.0.4"
npm run build:win # → release/0.0.4/
# 2. 启动测试服务器
cd test-server && python server.py --release-dir ../release --port 8000
# 3. 启动旧版本客户端
set UPDATE_API_URL=http://127.0.0.1:8000 && .\release\0.0.3\win-unpacked\船长·HeiXiu.exe
# → 5 秒后自动检测到 0.0.4 → 差分下载 → 提示安装
```
相关文档:[UpdateA.md](UpdateA.md)、[test-server/README.md](test-server/README.md)
# 与其他 AI 协作的补充说明 # 与其他 AI 协作的补充说明
- 所有生成的代码片段必须放在完整的文件上下文中,并注明所属文件路径。 - 所有生成的代码片段必须放在完整的文件上下文中,并注明所属文件路径。

View File

@@ -141,6 +141,8 @@ ele-heixiu/
## 环境变量 ## 环境变量
主进程加载顺序:`.env.{mode}``.env.local` → OS 环境变量 → 代码默认值。
| 变量 | 默认值 | 用途 | | 变量 | 默认值 | 用途 |
| ----------------------- | ------------------------------------------------------ | --------------- | | ----------------------- | ------------------------------------------------------ | --------------- |
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API | | `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API |
@@ -154,9 +156,14 @@ ele-heixiu/
| `CSC_KEY_PASSWORD` | — | 签名证书密码 | | `CSC_KEY_PASSWORD` | — | 签名证书密码 |
| `VITE_API_BASE_URL` | `http://8.160.179.64:8000`dev | 业务 API 基地址 | | `VITE_API_BASE_URL` | `http://8.160.179.64:8000`dev | 业务 API 基地址 |
> **`.env.local`**(不进 git用于个人特殊配置如打包后覆盖 `UPDATE_API_URL` 指向本地测试服务器。
--- ---
## 相关文档 ## 相关文档
- [发布手册](./UpdateA.md) — API 接口、发版流程、签名指南、数据库设计 | 文档 | 面向 | 内容 |
- [项目架构](./PROJECT.md) — 详细的技术方案与设计决策 |------|------|------|
| [PROJECT.md](./PROJECT.md) | 开发者 | 技术架构、更新系统、环境变量架构、测试流程 |
| [UpdateA.md](./UpdateA.md) | 前端发版 | 构建命令、发版流程、代码签名 |
| [test-server/README.md](./test-server/README.md) | 后端程序员 | API 规格、数据库表结构、实现清单 |

168
TODO.md Normal file
View File

@@ -0,0 +1,168 @@
# TODO — 船长·HeiXiu 待办与优化清单
> 最后更新2026-06-03
---
## 🔴 安全加固(优先级高)
### 1. 敏感数据加密存储
当前所有敏感数据存储在 `localStorage` 明文/Base64
| 数据 | 现状 | 目标方案 |
|------------------|-----------|----------------------------------------|
| `access_token` | 明文 | Electron `safeStorage.encryptString()` |
| `refresh_token` | 明文 | Electron `safeStorage.encryptString()` |
| `passwordBase64` | Base64 编码 | 移除密码存储,仅靠 refresh_token 自动登录 |
**方案 A推荐**:使用 Electron `safeStorage` API
- macOS → Keychain
- Windows → DPAPI
- Linux → libsecret
```ts
// preload.ts 暴露安全方法
contextBridge.exposeInMainWorld('safeStorage', {
encrypt: (plain: string) => ipcRenderer.invoke('safe-storage:encrypt', plain),
decrypt: (encrypted: Buffer) => ipcRenderer.invoke('safe-storage:decrypt', encrypted),
});
// 替代现有 setToken / setStoredRefreshToken
// 渲染进程不再直接操作 localStorage
```
**方案 B**:敏感操作下沉到主进程
- `access_token` / `refresh_token` 由主进程管理
- 渲染进程通过 IPC 发起请求时由主进程追加 Token
- 渲染进程永不持有明文 Token
### 2. 密码存储策略调整
- [ ] "记住密码" → 仅回填用户名,不再存储密码(已完成)
- [ ] 清理 `StoredAuth.passwordBase64` 字段
- [ ] "自动登录" 完全依赖 `refresh_token` 静默续期
---
## 🟡 功能待完善(优先级中)
### 设置页面
- [ ] 设置数据通过 IPC 同步到主进程(当前仅渲染进程 localStorage
- [ ] 存储路径有效性校验(检查路径是否存在)
- [ ] "恢复默认设置"按钮
### 路由与页面
- [ ] 合规素材库页面(`/compliance-assets`
- [ ] 项目管理页面(`/projects`,企业版)
- [ ] 账户/会员/充值/消费记录/订单明细页面
- [ ] 忘记密码流程
- [ ] 各页面暗色主题适配
### 导航栏
- [ ] 移除调试用的 `console.log`
- [ ] 导航配置中的 `action: 'external'` 统一管理(白名单校验)
- [ ] 未登录点击需登录项 → 登录成功后自动跳转到目标页
### 公告
- [ ] 公告已读/未读状态
- [ ] 公告红点/角标提醒(有新公告时导航栏图标变化)
- [ ] 公告缓存(减少重复请求)
---
## 🟢 工程优化(优先级低)
### 构建与开发
- [ ] CI/CD 流水线GitHub Actions / 自建)
- [ ] 代码签名自动化Windows Authenticode / macOS notarization
- [ ] E2E 测试用例编写Playwright
- [ ] 单元测试覆盖Vitest
- [ ] Bundle 体积分析优化rollup-plugin-visualizer 已有,定期检查)
### 增量更新改造
当前状态:
- `electron-builder` 已配置 `"differential": true`,打包时已生成 `.exe.blockmap` 文件 ✅
- `electron/main/updater.ts` 为**手写全量下载**`net.request`),未使用 `electron-updater` npm 包 ❌
- blockmap 文件生成了但从未被消费,每次更新都是完整下载
目标方案 — 自定义 Provider + electron-updater
```ts
// electron/main/updater-v2.ts
import { autoUpdater } from 'electron-updater';
import { Provider } from 'electron-updater/out/providers/Provider';
class HeiXiuProvider extends Provider<UpdateInfo> {
// 版本检查 → 走自定义 API保留灰度/强更控制)
async getLatestVersion(): Promise<UpdateInfo> {
const res = await fetch(`${API_BASE}/api/v1/update/check?...`);
const data = await res.json();
return {
version: data.latestVersion,
files: [{
url: data.downloadUrl,
sha512: data.sha512,
size: data.fileSize,
blockMapSize: data.blockMapSize, // 后端新增字段
}],
path: data.downloadUrl,
sha512: data.sha512,
releaseDate: data.releaseDate,
};
}
}
```
改造内容:
- [ ] 安装 `electron-updater` npm 包
- [ ] 重写 `electron/main/updater.ts` 为自定义 Provider 模式
- [ ] 发布脚本 `upload-oss.mjs` 增加 blockmap 文件上传
- [ ] `generate-update-info.mjs` 增加 blockmap 字段(`blockMapUrl``blockMapSize`
- [ ] 后端 API `/api/v1/update/check` 响应增加 `blockMapUrl``blockMapSize` 字段
- [ ] 后端 `releases` 表增加 `blockmap_url``blockmap_size`
- [ ] OSS 上传每次版本需同时上传 `.exe` + `.exe.blockmap` 两个文件
增量效果预估:日常更新下载量从完整安装包 → 仅变化的块(预计节省 60%~90% 流量)。
### 代码质量
- [ ] 移除未使用的 `shared/` 类型和常量
- [ ] 统一 IPC 通道注册/注销模式(避免重复监听)
- [ ] React 组件 lazy loadingReact.lazy + Suspense
- [ ] 错误边界组件ErrorBoundary
### 文档
- [ ] API 接口文档Swagger / 手动维护)
- [ ] 组件文档Storybook
- [ ] 用户操作手册
---
## ⚠️ 已知技术债务
| 问题 | 说明 | 影响 |
|-----------------|---------------------------------------|----------------|
| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 |
| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 |
| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 |
| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 |
---
## 📋 下次开发计划
1. **safeStorage 加密改造**(安全加固 #1
2. **合规素材库页面**(功能待完善 Route 页面)
3. **各页面 nav 导航补齐**

View File

@@ -13,20 +13,26 @@
│ 版本检查 ─────────┼────────▶│ /api/v1/update/ │ │ │ │ 版本检查 ─────────┼────────▶│ /api/v1/update/ │ │ │
│ │ │ check │ │ │ │ │ │ check │ │ │
│ │◀────────│ 返回 JSON │ │ │ │ │◀────────│ 返回 JSON │ │ │
│ │ │ (含 blockmap 信息) │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
下载安装包 ───────┼─────────┼──────────────────┼────────▶│ 静态文件托管 │ 增量下载(未来)────┼─────────┼──────────────────┼────────▶│ 静态文件托管 │
│◀────────┼──────────────────┼─────────│ .exe / .dmg 仅下载变化块 │◀────────┼──────────────────┼─────────│ .exe + .blockmap
│ │ │ │ │ │ │ │ │ │ │ │
安装 (spawn) │ │ │ │ │ 本地重建安装包 │ │ │ │ │
│ 校验 SHA512 │ │ │ │ │
│ NSIS /S 静默安装 │ │ │ │ │
│ → app.quit() │ │ │ │ │ │ → app.quit() │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘
``` ```
> **当前状态**:客户端已集成 `electron-updater` + 自定义 `HeiXiuProvider`,支持增量下载。`electron-builder` 打包已生成
`.exe.blockmap` 文件。主进程通过 `netRequest` 调用自有 API 获取版本信息electron-updater 自动处理差分下载和 SHA512 校验。
| 角色 | 技术 | 职责 | | 角色 | 技术 | 职责 |
| ------ | -------------------------- | ------------------------------------------ | |-----|-----------------------------|--------------------------------------------|
| 客户端 | Electron + `net.request()` | 调用 API → 下载安装包 → 校验 SHA512 → 安装 | | 客户端 | Electron + electron-updater | 调用 API → 下载 blockmap → 差分下载 → 重建 → 校验 → 安装 |
| ECS | 任意后端语言 | 提供版本检查 API | | ECS | 任意后端语言 | 提供版本检查 API(含 blockmap 信息) |
| OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage | | OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage + .blockmap |
--- ---
@@ -61,7 +67,7 @@ node scripts/publish-release.mjs <平台> <版本类型> [版本号] [--url <url
### `build.mjs` 操作组合 ### `build.mjs` 操作组合
| 操作 | 说明 | | 操作 | 说明 |
| ----------- | -------------------------------------------------------- | |-------------|------------------------------------------------------|
| `--build` | 构建tsc + vite + electron-builder + update-info.json | | `--build` | 构建tsc + vite + electron-builder + update-info.json |
| `--clean` | 清理旧产物 | | `--clean` | 清理旧产物 |
| `--sign` | 代码签名(配合 `--cert` 或环境变量) | | `--sign` | 代码签名(配合 `--cert` 或环境变量) |
@@ -87,9 +93,10 @@ npm run build:win && npm run build:win:enterprise
# 4. 产物在 release/0.1.0/ 下: # 4. 产物在 release/0.1.0/ 下:
# - xxx-Setup.exe安装包 # - xxx-Setup.exe安装包
# - xxx-Setup.exe.blockmap块映射增量更新用
# - xxx-update-info.json所有 API 字段downloadUrl 为预判 URL # - xxx-update-info.json所有 API 字段downloadUrl 为预判 URL
# 5. 手动上传 exe 到 OSS releases/0.1.0/ # 5. 手动上传 exe + .blockmap 到 OSS releases/0.1.0/
# 6. 将 update-info.json 内容录入数据库 releases 表 # 6. 将 update-info.json 内容录入数据库 releases 表
``` ```
@@ -128,7 +135,7 @@ node build.mjs --win --personal --upload --publish
### 证书获取 ### 证书获取
| 平台 | 格式 | 渠道 | 年费 | | 平台 | 格式 | 渠道 | 年费 |
| ------- | ----------------- | ------------------------------- | -------- | |---------|-------------------|---------------------------------|----------|
| Windows | `.pfx` / `.p12` | DigiCert / Sectigo / GlobalSign | $200-700 | | Windows | `.pfx` / `.p12` | DigiCert / Sectigo / GlobalSign | $200-700 |
| macOS | `.p12` / Keychain | Apple Developer Program | $99 | | macOS | `.p12` / Keychain | Apple Developer Program | $99 |
@@ -150,99 +157,58 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
## 五、API 接口 ## 五、API 接口
### `GET /api/v1/update/check` 客户端调用的两个端点,完整规格见 [test-server/README.md](test-server/README.md)(可直接发给后端程序员)。
**参数**`platform`win32/darwin/linux`version`(三段式 semver`edition`personal/enterprise | 端点 | 用途 |
|----------------------------|-------------------------------------|
**有更新** | `GET /api/v1/update/check` | 客户端版本检查,返回最新版本信息(含 blockmap 用于增量更新) |
| `POST /api/v1/releases` | 前端构建脚本发布新版本,需 Bearer Token 鉴权 |
```json
{
"code": 0,
"data": {
"hasUpdate": true,
"latestVersion": "0.1.0",
"downloadUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe",
"fileSize": 98765432,
"sha512": "Base64...",
"changelog": "更新日志...",
"releaseDate": "2026-06-03",
"forceUpdate": false,
"minimumVersion": "0.0.0"
}
}
```
**无更新**
```json
{ "code": 0, "data": { "hasUpdate": false } }
```
### `POST /api/v1/releases`(发布用)
```json
{
"platform": "win32",
"edition": "personal",
"latestVersion": "0.1.0",
"downloadUrl": "https://...",
"fileSize": 112910987,
"sha512": "Base64...",
"changelog": "...",
"releaseDate": "2026-06-03",
"forceUpdate": false,
"minimumVersion": "0.0.0"
}
```
> Header: `Authorization: Bearer <UPDATE_API_KEY>`
---
## 六、数据库
```sql
CREATE TABLE releases (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux',
edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise',
version VARCHAR(20) NOT NULL COMMENT '三段式 semver',
file_url VARCHAR(500) NOT NULL COMMENT 'OSS HTTPS 地址',
file_size BIGINT NOT NULL COMMENT '字节数',
sha512 VARCHAR(128) NOT NULL COMMENT 'Base64 编码',
changelog TEXT,
force_update TINYINT(1) DEFAULT 0,
min_version VARCHAR(20) DEFAULT '',
release_date DATE NOT NULL,
status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_edition_version (platform, edition, version),
INDEX idx_platform_edition_status (platform, edition, status)
);
```
--- ---
## 七、客户端行为 ## 七、客户端行为
| 场景 | 行为 | | 场景 | 行为 |
| -------- | ---------------------------------------------------- | |-------|------------------------------------------------|
| 启动 | 5 秒后静默检查更新(仅生产环境) | | 启动 | 5 秒后静默检查更新(仅检查,不自动下载) |
| 手动检查 | 点击按钮 → 调用 API → 反馈结果 | | 手动检查 | 点击按钮 → 调用 API → 展示结果 |
| 下载 | 流式写入临时目录SHA512 实时校验,每 250ms 推送进度 | | 发现新版本 | 展示版本号 + 更新日志 + "查看详情"弹窗 + "下载更新"按钮 |
| 安装 | 用户确认 → spawn NSIS /S → app.quit() | | 下载 | 用户点击"下载更新" → electron-updater 差分下载 → SHA512 校验 |
| 安装 | 下载完成后用户点击"安装更新" → quitAndInstall → NSIS /S |
| 强制更新 | `forceUpdate=true` 时不可跳过 | | 强制更新 | `forceUpdate=true` 时不可跳过 |
| 开发模式 | 跳过所有更新检查 | | 开发模式 | 跳过(已取消注释),通过 `.env.development` 指向测试服务器 |
### 环境变量加载顺序(主进程)
```
① OS 环境变量set UPDATE_API_URL=... ← 最高优先级,不覆盖
② .env.local不进 git个人覆盖用
③ .env.{mode}(开发→.env.development打包→.env.production
④ updater.ts 硬编码默认值https://www.heixiu.com
```
### 测试场景
```powershell
# 场景 1npm run dev 自动走 .env.development → http://127.0.0.1:8000
# 场景 2打包后连本地测试方式 A — .env.local
echo UPDATE_API_URL=http://127.0.0.1:8000 > .env.local
.\release\0.0.3\win-unpacked\船长·HeiXiu.exe
# 场景 3打包后连本地测试方式 B — OS 环境变量)
set UPDATE_API_URL=http://127.0.0.1:8000 && .\release\0.0.3\win-unpacked\船长·HeiXiu.exe
# 启动测试服务器
cd test-server && python server.py --release-dir ../release --port 8000
```
--- ---
## 八、环境变量速查 ## 八、环境变量速查
| 变量 | 默认值 | 用途 | | 变量 | 默认值 | 用途 |
| ----------------------- | ------------------------------------------------------ | ----------------------- | |-------------------------|--------------------------------------------------------|----------------------|
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API 基地址 | | `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API 基地址 |
| `UPDATE_API_KEY` | — | 发布 API 鉴权 Key | | `UPDATE_API_KEY` | — | 发布 API 鉴权 Key |
| `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | 预判 URL 基地址 | | `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | 预判 URL 基地址 |
@@ -256,6 +222,17 @@ CREATE TABLE releases (
| `UPDATE_FORCE` | `false` | 强制更新标记(打包时) | | `UPDATE_FORCE` | `false` | 强制更新标记(打包时) |
| `UPDATE_CHANGELOG` | 读取 CHANGELOG.md | 覆盖更新日志CI/CD | | `UPDATE_CHANGELOG` | 读取 CHANGELOG.md | 覆盖更新日志CI/CD |
### .env 文件说明
| 文件 | 何时加载 | 进 git | 用途 |
|--------------------|---------------------|:-----:|----------------------|
| `.env.development` | `npm run dev` | ✅ | 开发环境API 指向本地/测试服务器) |
| `.env.production` | `npm run build:xxx` | ❌ | 生产构建API 指向正式服务器) |
| `.env.local` | 始终(后加载,覆盖前者) | ❌ | 个人特殊配置(如打包后连本地测试) |
> **重要**`.env.production` 和 `.env.local` 不打包进 ASAR。打包后运行 `.exe` 时这些文件不存在,自动使用 `updater.ts`
> 硬编码默认值。如需覆盖,使用 OS 环境变量或 `.env.local` 放在 exe 同目录(需配合 `loadEnvFile()` 的路径查找)。
--- ---
## 九、文件结构 ## 九、文件结构
@@ -268,12 +245,13 @@ ele-heixiu/
├ package.json ← 仅保留简短的 npm 快捷命令 ├ package.json ← 仅保留简短的 npm 快捷命令
├ scripts/ ├ scripts/
│ ├ clean.mjs ← 清理产物 │ ├ clean.mjs ← 清理产物
│ ├ generate-update-info.mjs ← 生成 API 信息 JSON │ ├ generate-update-info.mjs ← 生成 API 信息 JSON(含 blockmap 信息)
│ ├ upload-oss.mjs ← 上传 OSS │ ├ upload-oss.mjs ← 上传 OSS(安装包 + blockmap
│ └ publish-release.mjs ← 发布到 APIaxios │ └ publish-release.mjs ← 发布到 APIaxios
├ release/ ← 构建产物目录(按版本分) ├ release/ ← 构建产物目录(按版本分)
│ └ 0.1.0/ │ └ 0.1.0/
│ ├ xxx-Setup.exe │ ├ xxx-Setup.exe
│ ├ xxx-Setup.exe.blockmap ← 差分更新块映射文件
│ └ xxx-update-info.json │ └ xxx-update-info.json
├ dist/ ← Vite 前端产物(构建输入) ├ dist/ ← Vite 前端产物(构建输入)
└ dist-electron/ ← Electron 主进程产物(构建输入) └ dist-electron/ ← Electron 主进程产物(构建输入)

View File

@@ -22,6 +22,11 @@ declare namespace NodeJS {
} }
// Used in Renderer process, expose in `preload.ts` // Used in Renderer process, expose in `preload.ts`
interface Window { declare interface Window {
ipcRenderer: import('electron').IpcRenderer; ipcRenderer: import('electron').IpcRenderer;
/** safeStorage 安全加密 API基于 OS 原生密钥链) */
safeStorage: {
encrypt(plaintext: string): Promise<string | null>;
decrypt(encryptedBase64: string): Promise<string | null>;
};
} }

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
@@ -6,14 +6,23 @@ import path from 'node:path';
import { getPlatform, isMacOS } from './main/utils/platform'; import { getPlatform, isMacOS } from './main/utils/platform';
import { getWindowIconPath } from './main/utils/logo'; import { getWindowIconPath } from './main/utils/logo';
import { buildWindowTitle, parseEdition } from '../shared/constants/app'; import { buildWindowTitle, parseEdition } from '../shared/constants/app';
import { loadEnvFile } from './main/load-env';
import { initUpdater, registerUpdateIpcHandlers } from './main/updater'; import { initUpdater, registerUpdateIpcHandlers } from './main/updater';
import { setupAppMenu } from './main/menu'; import { setupAppMenu } from './main/menu';
import { initLogger, flushLogger, logger } from './main/logger';
import { registerLogIpcHandlers } from './main/log-ipc';
import { registerSafeStorageIpcHandlers } from './main/safe-storage-ipc';
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
createRequire(import.meta.url); createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path.join(__dirname, '..'); process.env.APP_ROOT = path.join(__dirname, '..');
// 加载 .env 文件中的环境变量到 process.env
// 必须在任何读取 process.env.UPDATE_API_URL 的模块之前调用
loadEnvFile();
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']; export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'];
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron'); export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron');
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist'); export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
@@ -27,6 +36,27 @@ const currentPlatform = getPlatform();
const currentEdition = parseEdition(process.env.EDITION); const currentEdition = parseEdition(process.env.EDITION);
const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition); const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition);
// ============================================================
// 全局未捕获异常(主进程兜底记录)
// ============================================================
process.on('uncaughtException', (error) => {
try {
logger.error('app', 'Uncaught exception (main process)', error);
} catch {
/* logger 自身异常 — 最后防线 */
}
});
process.on('unhandledRejection', (reason) => {
try {
const error = reason instanceof Error ? reason : new Error(String(reason));
logger.error('app', 'Unhandled rejection (main process)', error);
} catch {
/* logger 自身异常 — 最后防线 */
}
});
// ============================================================ // ============================================================
// 窗口引用 // 窗口引用
// ============================================================ // ============================================================
@@ -111,14 +141,27 @@ app.on('activate', () => {
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
// 清理工作 flushLogger();
}); });
app.whenReady().then(() => { app.whenReady().then(() => {
initLogger();
registerLogIpcHandlers();
registerSafeStorageIpcHandlers();
setupAppMenu(); setupAppMenu();
app.setName(WINDOW_TITLE); app.setName(WINDOW_TITLE);
registerUpdateIpcHandlers(); registerUpdateIpcHandlers();
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
if (!mainWindow) return { canceled: true, filePaths: [] };
return dialog.showOpenDialog(mainWindow, {
title: options?.title || '选择文件夹',
defaultPath: options?.defaultPath || app.getPath('home'),
properties: options?.properties || ['openDirectory'],
});
});
// 始终打开主窗口 // 始终打开主窗口
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口 // 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
const win = createMainWindow(); const win = createMainWindow();

92
electron/main/load-env.ts Normal file
View File

@@ -0,0 +1,92 @@
// 主进程 .env 加载器:.env.{mode} → .env.local → OS 环境变量(后者覆盖前者)
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM 中 __dirname 不可用,手动计算
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* 解析单个 .env 文件,将 key=value 写入 process.env。
* 已存在的 process.env 值不会被覆盖OS 环境变量优先)。
* 返回成功加载的变量数。
*/
function parseEnvFile(filePath: string, fileName: string): number {
try {
const content = readFileSync(filePath, 'utf-8');
let count = 0;
for (const line of content.split('\n')) {
const trimmed = line.trim();
// 跳过空行和注释
if (!trimmed || trimmed.startsWith('#')) continue;
// 解析 key=value
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
// 去除引号(支持 "value" 和 'value'
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// 不覆盖已在 OS 环境变量中设置的值
if (key && !process.env[key]) {
process.env[key] = value;
count++;
}
}
if (count > 0) {
console.log(`[load-env] 从 ${fileName} 加载了 ${count} 个环境变量`);
}
return count;
} catch (err) {
// 文件不存在 — 静默跳过(打包后 .env.* 不在 ASAR 中,这是正常情况)
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return 0;
}
// 其他错误(权限、编码等)— 警告但不中断
console.warn(`[load-env] 读取 ${fileName} 失败:`, (err as Error).message);
return 0;
}
}
/**
* 加载环境变量文件到 process.env。
* 开发模式加载 .env.development生产模式加载 .env.production
* 之后再加载 .env.local 作为覆盖层。
*/
export function loadEnvFile(): void {
// 项目根目录
// APP_ROOT 在 main.ts 中设置dist-electron/.. = 项目根)
// fallbackdist-electron/main/load-env.js → __dirname/../.. = 项目根
const projectRoot = process.env.APP_ROOT || join(__dirname, '..', '..');
// 确定当前模式
const mode =
!process.env.NODE_ENV || process.env.NODE_ENV === 'development'
? 'development'
: 'production';
console.log(
`[load-env] 模式: ${mode}, projectRoot: ${projectRoot}`,
);
// ① 基础环境文件(按模式选择)
const baseFile = `.env.${mode}`;
parseEnvFile(join(projectRoot, baseFile), baseFile);
// ② 本地覆盖文件(不进 git用于打包后连测试服务器等特殊场景
const localFile = '.env.local';
parseEnvFile(join(projectRoot, localFile), localFile);
}

21
electron/main/log-ipc.ts Normal file
View File

@@ -0,0 +1,21 @@
// ============================================================
// 日志 IPC 通道注册 — 主进程接收渲染进程日志
// ============================================================
import { ipcMain } from 'electron';
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc-channels';
import { writeRendererLog } from './logger';
import type { LogEntry } from '../../shared/types/logging';
/**
* 注册日志 IPC 监听器
* 使用 ipcMain.onfire-and-forget而非 handle无需响应
* 在 app.whenReady() 中调用
*/
export function registerLogIpcHandlers(): void {
ipcMain.on(RENDERER_TO_MAIN.LOG_MESSAGE, (_event, entry: LogEntry) => {
// 基础校验:防御渲染进程异常数据
if (!entry || !entry.level || !entry.category || !entry.message) return;
writeRendererLog(entry);
});
}

251
electron/main/logger.ts Normal file
View File

@@ -0,0 +1,251 @@
// ============================================================
// 主进程日志核心 — 文件写入 / 轮转 / 清理
//
// 架构:
// - 日志写入 {userData}/logs/app-YYYY-MM-DD.logJSON Lines 格式)
// - 每日轮转:日期变化时自动创建新文件
// - 保留策略7 天前旧文件自动删除
// - 写入队列批量冲刷setImmediate 聚合同一 tick 的日志)
// - 惰性初始化app.getPath('userData') 在 ready 后才可用,
// initLogger() 在 whenReady 中显式调用预热
//
// 用法(主进程):
// import { logger, initLogger } from './main/logger';
// logger.info('updater', '检查更新', { version: '0.1.0' });
//
// 渲染进程日志通过 log-ipc.ts IPC 通道写入,调用 writeRendererLog()
// ============================================================
import { app } from 'electron';
import { createWriteStream, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import type { WriteStream } from 'node:fs';
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '../../shared/types/logging';
import { sanitizeLogEntry } from '../../shared/utils/sanitize';
// ---------- 内部状态 ----------
let writeStream: WriteStream | null = null;
let currentDate = '';
let logDir = '';
const writeQueue: string[] = [];
let isWriting = false;
const MAX_DAYS = 7;
// ---------- 辅助函数 ----------
function getDateStr(): string {
return new Date().toISOString().slice(0, 10);
}
function getLogFilePath(date: string): string {
return join(logDir, `app-${date}.log`);
}
/** 确保 logs/ 目录存在 */
function ensureDir(): void {
if (logDir) return;
try {
logDir = join(app.getPath('userData'), 'logs');
} catch {
// app.getPath 尚不可用app.ready 之前),静默跳过
return;
}
if (!logDir) return;
try {
mkdirSync(logDir, { recursive: true });
} catch {
/* 目录创建失败 — 后续写入静默丢弃 */
}
}
/** 日期轮转:日切时关闭旧流 → 创建新文件 → 清理过期文件 */
function rotateIfNeeded(): void {
const today = getDateStr();
if (currentDate === today && writeStream) return;
if (writeStream) {
try {
writeStream.end();
} catch {
/* ignore */
}
writeStream = null;
}
try {
writeStream = createWriteStream(getLogFilePath(today), { flags: 'a' });
currentDate = today;
} catch {
writeStream = null;
}
purgeOldLogs();
}
/** 清理超过 MAX_DAYS 天的旧日志文件 */
function purgeOldLogs(): void {
if (!logDir) return;
const now = Date.now();
const maxAge = MAX_DAYS * 24 * 60 * 60 * 1000;
try {
const files = readdirSync(logDir);
for (const file of files) {
if (!file.startsWith('app-') || !file.endsWith('.log')) continue;
try {
const stat = statSync(join(logDir, file));
if (now - stat.mtimeMs > maxAge) {
unlinkSync(join(logDir, file));
}
} catch {
/* skip inaccessible files */
}
}
} catch {
/* 目录读取失败 — 下次轮转再试 */
}
}
/** 批量冲刷写入队列 */
function processQueue(): void {
if (isWriting || writeQueue.length === 0) return;
isWriting = true;
ensureDir();
if (logDir) {
rotateIfNeeded();
if (writeStream) {
const batch = writeQueue.splice(0).join('');
try {
writeStream.write(batch);
} catch {
/* 写入失败 — 数据丢失,但日志不应阻断业务 */
}
} else {
writeQueue.length = 0;
}
} else {
// app ready 前尚无 userData 路径 — 丢弃(此时距 ready 极近)
writeQueue.length = 0;
}
isWriting = false;
}
/** 错误对象 → 可序列化快照 */
function errorToSnapshot(err: Error): LogErrorSnapshot {
return {
name: err.name,
message: err.message,
stack: err.stack,
cause: (err as { cause?: unknown }).cause
? String((err as { cause?: unknown }).cause)
: undefined,
};
}
/** 构建日志条目 */
function createEntry(
level: LogLevel,
category: LogCategory,
message: string,
processType: 'main' | 'renderer',
error?: Error,
context?: Record<string, unknown>,
): LogEntry {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
category,
message,
processType,
};
if (context && Object.keys(context).length > 0) {
entry.context = context;
}
if (error) {
entry.error = errorToSnapshot(error);
}
return entry;
}
/** 内部写入入口 */
function write(
level: LogLevel,
category: LogCategory,
message: string,
error?: Error,
context?: Record<string, unknown>,
): void {
const entry = sanitizeLogEntry(
createEntry(level, category, message, 'main', error, context),
);
writeQueue.push(JSON.stringify(entry) + '\n');
// 首个入队项触发 setImmediate 批量冲刷
if (writeQueue.length === 1) {
setImmediate(processQueue);
}
}
// ---------- 对外 API ----------
/** 主进程日志器(与渲染进程 API 完全一致) */
export const logger = {
debug(category: LogCategory, message: string, context?: Record<string, unknown>): void {
write('DEBUG', category, message, undefined, context);
},
info(category: LogCategory, message: string, context?: Record<string, unknown>): void {
write('INFO', category, message, undefined, context);
},
warn(
category: LogCategory,
message: string,
error?: Error,
context?: Record<string, unknown>,
): void {
write('WARN', category, message, error, context);
},
error(
category: LogCategory,
message: string,
error?: Error,
context?: Record<string, unknown>,
): void {
write('ERROR', category, message, error, context);
},
};
/** 预热日志模块(在 app.whenReady() 中调用) */
export function initLogger(): void {
ensureDir();
if (logDir) {
rotateIfNeeded();
}
}
/**
* 接收渲染进程日志并写入文件
* 由 log-ipc.ts 的 IPC 监听器调用
*/
export function writeRendererLog(entry: LogEntry): void {
writeQueue.push(JSON.stringify(entry) + '\n');
if (writeQueue.length === 1) {
setImmediate(processQueue);
}
}
/** 冲刷所有未写入日志并关闭写入流(在 app.before-quit 中调用) */
export function flushLogger(): void {
if (writeQueue.length > 0) {
processQueue();
}
if (writeStream) {
try {
writeStream.end();
} catch {
/* ignore */
}
writeStream = null;
}
}

View File

@@ -0,0 +1,231 @@
// ============================================================
// NetRequest — Electron net.request 的 axios 风格封装
//
// 用法:
// import { request, get, post } from './net-request';
// const { data } = await get<UpdateCheckResult>('/api/v1/update/check', {
// baseURL: 'https://www.heixiu.com',
// params: { platform: 'win32', version: '1.0.0' },
// });
//
// 设计:
// - 底层使用 Electron net.request不引入额外依赖
// - Promise 封装,支持 async/await
// - JSON 自动解析、查询参数自动拼接
// - 流式下载场景仍直接用 net.request本模块不封装 stream
// ============================================================
import { net } from 'electron';
// ---------- 类型 ----------
export interface RequestConfig {
/** 请求方法(默认 GET */
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
/** 基础 URL可选方便切换环境 */
baseURL?: string;
/** 查询参数(自动拼接 ?key=value&... */
params?: Record<string, string | number | undefined>;
/** 请求头 */
headers?: Record<string, string>;
/** 请求体(对象自动 JSON.stringify字符串原样发送 */
data?: unknown;
/** 响应类型(默认 json设置 text 跳过解析) */
responseType?: 'json' | 'text';
/** 超时时间(毫秒,默认 30s */
timeout?: number;
}
export interface RequestResponse<T = unknown> {
/** HTTP 状态码 */
status: number;
/** 状态文本(如 "OK" */
statusText: string;
/** 响应头 */
headers: Record<string, string | string[]>;
/** 响应体 */
data: T;
}
/** 网络请求错误 */
export class RequestError extends Error {
status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'NetRequestError';
this.status = status;
}
}
// ---------- 工具 ----------
const DEFAULT_TIMEOUT = 30_000;
/** 拼接 URLbaseURL + path + query */
function buildUrl(url: string, config: RequestConfig): string {
// 如果没有 baseURL 且 url 不是绝对地址,直接报错(便于定位问题)
if (!config.baseURL && !/^https?:\/\//i.test(url)) {
throw new RequestError(
`URL 缺少 baseURL无法构造绝对地址。url="${url}", config=${JSON.stringify({ ...config, params: config.params ? '[object]' : undefined })}`,
);
}
const base = (config.baseURL || '').trim().replace(/\/+$/, '');
const path = url.startsWith('/') ? url : `/${url}`;
let fullUrl = base ? `${base}${path}` : url;
if (config.params) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(config.params)) {
if (value !== undefined && value !== null) {
search.append(key, String(value));
}
}
const qs = search.toString();
if (qs) {
fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs;
}
}
// 调试:在 net.request 调用之前验证 URL 合法性
try {
void new URL(fullUrl);
} catch {
throw new RequestError(
`构造的 URL 无法通过 Node.js 校验: "${fullUrl}"`,
);
}
console.log('[net-request] buildUrl:', fullUrl);
return fullUrl;
}
/** 序列化请求体 */
function serializeBody(data: unknown): { body: string; contentType: string } {
if (typeof data === 'string') {
return { body: data, contentType: 'text/plain' };
}
return { body: JSON.stringify(data), contentType: 'application/json' };
}
// ---------- 核心 ----------
/**
* 发起请求axios 风格 API
*
* 流式下载场景请直接使用 Electron net.request本模块不封装
*/
export async function request<T = unknown>(
url: string,
config: RequestConfig = {},
): Promise<RequestResponse<T>> {
const fullUrl = buildUrl(url, config);
const method = (config.method || 'GET').toUpperCase();
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
return new Promise((resolve, reject) => {
// 调试:验证 URL 是否合法
try {
void new URL(fullUrl);
} catch (urlErr) {
console.error('[net-request] 非法 URL:', JSON.stringify(fullUrl));
console.error('[net-request] URL 字符码:', [...fullUrl].map(c => c.charCodeAt(0)).join(','));
console.error('[net-request] config:', JSON.stringify(config));
reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`));
return;
}
const req = net.request({
method,
url: fullUrl,
});
// 超时处理
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
req.abort();
reject(new RequestError(`请求超时 (${timeout}ms)`));
}, timeout);
// 请求头
if (config.headers) {
for (const [key, value] of Object.entries(config.headers)) {
req.setHeader(key, value);
}
}
// 请求体
if (config.data !== undefined) {
const { body, contentType } = serializeBody(config.data);
req.setHeader('Content-Type', contentType);
req.write(body);
}
req.on('response', (response) => {
clearTimeout(timer);
const status = response.statusCode;
const statusText = response.statusMessage ?? '';
// 读取响应头
const headers: Record<string, string | string[]> = {};
// Electron IncomingMessage.headers 是 Record<string, string[]>
const rawHeaders = response.headers;
if (rawHeaders) {
for (const [key, value] of Object.entries(rawHeaders)) {
headers[key] = value.length === 1 ? value[0] : value;
}
}
// 读取响应体
let body = '';
response.on('data', (chunk: Buffer) => (body += chunk.toString()));
response.on('end', () => {
try {
const asJson = config.responseType !== 'text';
const data = asJson ? JSON.parse(body) : body;
resolve({ status, statusText, headers, data: data as T });
} catch {
// JSON 解析失败 → 返回原始文本
resolve({ status, statusText, headers, data: body as T });
}
});
response.on('error', (err) => {
clearTimeout(timer);
reject(new RequestError(`响应读取失败: ${err.message}`));
});
});
req.on('error', (err) => {
clearTimeout(timer);
if (timedOut) return;
reject(new RequestError(`网络请求失败: ${err.message}`));
});
req.end();
});
}
// ---------- 默认导出:类 axios 命名空间 ----------
export const netRequest = {
request,
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'GET' });
},
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'POST', data });
},
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'PUT', data });
},
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'DELETE' });
},
};

View File

@@ -0,0 +1,81 @@
// ============================================================
// safe-storage-ipc — safeStorage 加密/解密 IPC 处理器
//
// 在主进程注册,渲染进程通过 preload 暴露的安全方法调用。
// 加密后的数据为 Base64 字符串,方便存储在 localStorage。
//
// 平台对应:
// Windows → DPAPI
// macOS → Keychain
// Linux → libsecret
//
// 注意:
// - safeStorage 加密绑定当前 OS 用户账户,无法跨用户/跨设备解密
// - 用户重装 OS 后加密数据将无法恢复(需重新登录)
// - safeStorage.isEncryptionAvailable() 在部分 Linux 发行版可能返回 false
// ============================================================
import { ipcMain, safeStorage } from 'electron';
import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels';
import { logger } from './logger';
/** 检查 safeStorage 是否可用,不可用时记录警告 */
function checkAvailability(): boolean {
if (!safeStorage.isEncryptionAvailable()) {
logger.warn('safe-storage', 'safeStorage 加密不可用(可能缺少 libsecret 等系统密钥服务)');
return false;
}
return true;
}
/**
* 注册 safeStorage IPC 处理器
* 在 app.whenReady() 中调用
*/
export function registerSafeStorageIpcHandlers(): void {
// ---------- 加密 ----------
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
if (typeof plaintext !== 'string' || plaintext.length === 0) {
logger.warn('safe-storage', '加密参数无效');
return { success: false, error: '参数无效:需要非空字符串' };
}
if (!checkAvailability()) {
return { success: false, error: 'safeStorage 不可用' };
}
try {
const encrypted = safeStorage.encryptString(plaintext);
const base64 = encrypted.toString('base64');
return { success: true, data: base64 };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('safe-storage', '加密失败', err instanceof Error ? err : new Error(message));
return { success: false, error: message };
}
});
// ---------- 解密 ----------
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) {
logger.warn('safe-storage', '解密参数无效');
return { success: false, error: '参数无效:需要非空字符串' };
}
if (!checkAvailability()) {
return { success: false, error: 'safeStorage 不可用' };
}
try {
const buffer = Buffer.from(encryptedBase64, 'base64');
const plaintext = safeStorage.decryptString(buffer);
return { success: true, data: plaintext };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('safe-storage', '解密失败', err instanceof Error ? err : new Error(message));
return { success: false, error: message };
}
});
logger.info('safe-storage', 'safeStorage IPC 处理器已注册');
}

View File

@@ -1,30 +1,23 @@
// ============================================================ // 自动更新 — HeiXiuProvider + electron-updater 增量下载
// 自动更新模块 — 自定义 API 版本检查 + OSS 文件下载 // 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
//
// 架构:
// 客户端 → GET /api/v1/update/check → ECS (API) → 返回 JSON
// 客户端 → GET <downloadUrl> → OSS → 下载安装包
// 下载完成 → SHA512 校验 → spawn 安装器 → app.quit()
//
// 与旧版 (electron-updater generic provider) 的区别:
// - 版本检查走自定义 API后端可做灰度/强更/统计
// - 不再依赖 latest.yml 静态文件
// - 安装包仍托管于 OSS下载地址由 API 动态返回
//
// 环境变量:
// UPDATE_API_URL — 版本检查 API 基地址(默认 https://www.heixiu.com
// ============================================================
import { BrowserWindow, net, app, ipcMain } from 'electron'; import {BrowserWindow, ipcMain} from 'electron';
import { createWriteStream } from 'node:fs'; import {URL} from 'node:url';
import { tmpdir } from 'node:os';
import { join } from 'node:path'; import {
import { createHash } from 'node:crypto'; NsisUpdater,
import { spawn } from 'node:child_process'; MacUpdater,
import { unlink } from 'node:fs/promises'; AppImageUpdater,
Provider,
} from 'electron-updater';
import type {ProviderRuntimeOptions} from 'electron-updater/out/providers/Provider';
import type {UpdateInfo, UpdateFileInfo} from 'builder-util-runtime';
import type {ResolvedUpdateFileInfo} from 'electron-updater/out/types';
import type {UpdateCheckResult} from '../../shared/types'; import type {UpdateCheckResult} from '../../shared/types';
import {APP_VERSION} from '../../shared/constants/version'; import {APP_VERSION} from '../../shared/constants/version';
import {logger} from './logger';
import {netRequest} from './net-request';
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)---------- // ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
@@ -36,20 +29,212 @@ export const UPDATE_CHANNELS = {
UPDATE_NOT_AVAILABLE: 'update-not-available', UPDATE_NOT_AVAILABLE: 'update-not-available',
INSTALL_UPDATE: 'install-update', INSTALL_UPDATE: 'install-update',
CHECK_FOR_UPDATE: 'check-for-update', CHECK_FOR_UPDATE: 'check-for-update',
DOWNLOAD_UPDATE: 'download-update',
} as const; } as const;
// ---------- 配置 ---------- // ---------- 配置 ----------
function getApiBaseUrl(): string { function getApiBaseUrl(): string {
return process.env.UPDATE_API_URL || 'https://www.heixiu.com'; const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
return url;
}
// ============================================================
// 自定义 Provider
// ============================================================
class HeiXiuProvider extends Provider<UpdateInfo> {
private apiBaseUrl: string;
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
constructor(runtimeOptions: ProviderRuntimeOptions) {
super(runtimeOptions);
this.apiBaseUrl = getApiBaseUrl();
}
/**
* 调用自有版本检查 API返回 electron-updater 格式的 UpdateInfo。
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
* 无更新时返回当前版本号APP_VERSION
*/
async getLatestVersion(): Promise<UpdateInfo> {
// 短时缓存500ms预检和 electron-updater 内部调用共享同一结果
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
return this._cachedResult.data;
}
const data = await this._doGetLatestVersion();
this._cachedResult = { data, ts: Date.now() };
return data;
}
private async _doGetLatestVersion(): Promise<UpdateInfo> {
const {data} = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
'/api/v1/update/check',
{
baseURL: this.apiBaseUrl,
params: {
platform: process.platform,
version: APP_VERSION,
edition: process.env.EDITION || 'personal',
},
},
);
// 兼容两种 API 响应格式
const result: UpdateCheckResult =
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
// 无更新 → 返回当前版本号
if (!result.hasUpdate) {
return {
version: APP_VERSION,
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
};
}
const file: UpdateFileInfo = {
url: result.downloadUrl,
sha512: result.sha512,
size: result.fileSize,
};
// 后端 API 返回 blockMapSize 时启用增量更新
if (result.blockMapSize) {
file.blockMapSize = result.blockMapSize;
}
logger.info('updater', 'Provider 获取到新版本', {
latestVersion: result.latestVersion,
hasBlockmap: file.blockMapSize != null,
});
return {
version: result.latestVersion,
files: [file],
path: result.downloadUrl,
sha512: result.sha512,
releaseDate: result.releaseDate,
releaseNotes: result.changelog,
};
}
/**
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
* electron-updater 在下载前调用此方法。
*/
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
return updateInfo.files.map((f) => {
// 使用 downloadUrl 作为 base URL 来解析文件路径
const baseUrl = new URL(updateInfo.path);
const fileUrl = new URL(f.url, baseUrl.origin);
return {
url: fileUrl,
info: f,
};
});
}
}
// ============================================================
// 平台特定 Updater注入自定义 Provider
// ============================================================
/**
* 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
* 其他行为NSIS 安装、签名校验等)完全由父类处理。
*/
class HeiXiuNsisUpdater extends NsisUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
this.autoInstallOnAppQuit = false;
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
}
/** @internal — 注入自定义 Provider */
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
class HeiXiuMacUpdater extends MacUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
class HeiXiuAppImageUpdater extends AppImageUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(null);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
/** 工厂:按平台创建对应的 Updater 实例 */
function createPlatformUpdater(provider: HeiXiuProvider) {
switch (process.platform) {
case 'win32':
return new HeiXiuNsisUpdater(provider);
case 'darwin':
return new HeiXiuMacUpdater(provider);
default:
return new HeiXiuAppImageUpdater(provider);
}
} }
// ---------- 状态 ---------- // ---------- 状态 ----------
let updateWindow: BrowserWindow | null = null; let updateWindow: BrowserWindow | null = null;
let updateChecked = false; let provider: HeiXiuProvider | null = null;
/** 当前正在下载的临时文件路径(用于安装后清理) */ let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
let pendingInstallerPath: string | null = null;
// ============================================================ // ============================================================
// 初始化 // 初始化
@@ -58,246 +243,176 @@ let pendingInstallerPath: string | null = null;
export function initUpdater(mainWindow: BrowserWindow): void { export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow; updateWindow = mainWindow;
if (import.meta.env.DEV) { console.log('[updater] initUpdater 被调用API URL:', getApiBaseUrl());
console.log('[Updater] 开发模式跳过自动检查更新。API:', getApiBaseUrl());
return; // if (import.meta.env.DEV) {
// logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
// return;
// }
// 创建自定义 Provider
provider = new HeiXiuProvider({
isUseMultipleRangeRequest: true,
platform: process.platform as ProviderRuntimeOptions['platform'],
executor: null as unknown as ProviderRuntimeOptions['executor'],
});
// 创建平台 Updater
platformUpdater = createPlatformUpdater(provider);
// 将 electron-updater 事件桥接到现有 IPC 通道
platformUpdater.on('checking-for-update', () => {
logger.debug('updater', '正在检查更新');
});
platformUpdater.on('update-available', (info: UpdateInfo) => {
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null统一转为 string
let notes = '';
if (typeof info.releaseNotes === 'string') {
notes = info.releaseNotes;
} else if (Array.isArray(info.releaseNotes)) {
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
} }
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
version: info.version,
releaseDate: info.releaseDate,
releaseNotes: notes,
forceUpdate: false,
});
logger.info('updater', '发现新版本', {
version: info.version,
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
});
});
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.info('updater', '当前已是最新版本', {currentVersion: info.version});
});
platformUpdater.on('download-progress', (progress: {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number
}) => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
percent: Math.round(progress.percent),
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
});
});
platformUpdater.on('update-downloaded', () => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
logger.info('updater', '更新已下载完成');
});
platformUpdater.on('error', (error: Error) => {
logger.error('updater', '更新流程出错', error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: error.message || '更新失败',
});
});
// 启动 5 秒后静默检查 // 启动 5 秒后静默检查
setTimeout(() => { setTimeout(() => {
checkForUpdates(); checkForUpdates();
}, 5000); }, 5000);
} }
// ============================================================
// 版本检查(调用 API
// ============================================================
async function fetchUpdateInfo(): Promise<UpdateCheckResult | null> {
const platform = process.platform; // 'win32' | 'darwin' | 'linux'
const edition = process.env.EDITION || 'personal';
const apiUrl = `${getApiBaseUrl()}/api/v1/update/check?platform=${platform}&version=${APP_VERSION}&edition=${edition}`;
console.log('[Updater] 请求版本检查 API:', apiUrl);
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url: apiUrl });
req.on('response', (response) => {
let body = '';
response.on('data', (chunk) => (body += chunk.toString()));
response.on('end', () => {
try {
const json = JSON.parse(body);
// 兼容两种 API 响应格式:
// 格式 A: { code: 0, data: UpdateCheckResult }
// 格式 B: UpdateCheckResult 直接返回
const data: UpdateCheckResult = json.data ?? json;
if (!data.hasUpdate) {
console.log('[Updater] 当前已是最新版本');
return resolve(null);
}
console.log('[Updater] 发现新版本:', data.latestVersion);
resolve(data);
} catch (err) {
reject(new Error(`API 响应解析失败: ${(err as Error).message}`));
}
});
});
req.on('error', (err) => reject(new Error(`API 请求失败: ${err.message}`)));
req.end();
});
}
// ============================================================
// 下载安装包
// ============================================================
async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
const ext =
process.platform === 'win32' ? '.exe' : process.platform === 'darwin' ? '.dmg' : '.AppImage';
const tempPath = join(tmpdir(), `HeiXiu-${info.latestVersion}-update${ext}`);
console.log('[Updater] 开始下载:', info.downloadUrl);
console.log('[Updater] 临时文件:', tempPath);
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url: info.downloadUrl });
const fileStream = createWriteStream(tempPath);
const hash = createHash('sha512');
const totalSize = info.fileSize || 0;
let downloadedSize = 0;
let lastReportTime = 0;
req.on('response', (response) => {
const statusCode = response.statusCode;
if (statusCode !== 200) {
fileStream.close();
unlink(tempPath).catch(() => {});
return reject(new Error(`下载失败HTTP ${statusCode}`));
}
response.on('data', (chunk: Buffer) => {
fileStream.write(chunk);
hash.update(chunk);
downloadedSize += chunk.length;
// 限频:最多每 250ms 推送一次进度
const now = Date.now();
if (totalSize > 0 && now - lastReportTime >= 250) {
lastReportTime = now;
const percent = Math.round((downloadedSize / totalSize) * 100);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
percent,
bytesPerSecond: 0, // 简化处理,不计算实时速度
transferred: downloadedSize,
total: totalSize,
});
}
});
response.on('end', () => {
fileStream.end();
fileStream.on('finish', () => {
// SHA512 校验
const actualSha512 = hash.digest('base64');
if (info.sha512 && actualSha512 !== info.sha512) {
unlink(tempPath).catch(() => {});
return reject(
new Error(
`SHA512 校验失败\n期望: ${info.sha512.slice(0, 16)}...\n实际: ${actualSha512.slice(0, 16)}...`,
),
);
}
console.log('[Updater] 下载完成SHA512 校验通过');
pendingInstallerPath = tempPath;
resolve(tempPath);
});
});
response.on('error', (err) => {
fileStream.close();
unlink(tempPath).catch(() => {});
reject(new Error(`下载中断: ${err.message}`));
});
});
req.on('error', (err) => {
fileStream.close();
unlink(tempPath).catch(() => {});
reject(new Error(`下载请求失败: ${err.message}`));
});
req.end();
});
}
// ============================================================ // ============================================================
// 操作方法 // 操作方法
// ============================================================ // ============================================================
let updateChecked = false;
/** 静默检查更新(自动下载) */
export async function checkForUpdates(): Promise<void> { export async function checkForUpdates(): Promise<void> {
if (updateChecked) { if (updateChecked) {
console.log('[Updater] 已检查过更新,跳过'); logger.debug('updater', '已检查过更新,跳过');
return; return;
} }
if (import.meta.env.DEV) { if (!platformUpdater || !provider) return;
console.log('[Updater] 开发模式,跳过更新检查');
return;
}
try { try {
updateChecked = true; updateChecked = true;
const info = await fetchUpdateInfo();
if (!info) { // ① 先自行检查有无更新(不经过 electron-updater
// 无更新,不发通知(静默) const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI不触发 electron-updater 的任何事件
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '当前已是最新版本', {version: APP_VERSION});
return; return;
} }
// 通知渲染进程:有新版本 // ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, { await platformUpdater.checkForUpdates();
version: info.latestVersion,
releaseDate: info.releaseDate,
releaseNotes: info.changelog,
forceUpdate: info.forceUpdate,
});
// 自动开始下载
await downloadInstaller(info);
// 下载完成 → 通知渲染进程
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
} catch (error) { } catch (error) {
console.error('[Updater] 更新流程失败:', (error as Error).message); updateChecked = false;
logger.error('updater', '更新流程失败', error as Error, {apiUrl: getApiBaseUrl()});
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, { updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '更新检查失败', message: (error as Error).message || '更新检查失败',
}); });
updateChecked = false;
} }
} }
/** 手动检查更新(用户点击触发) */
export async function checkForUpdatesManual(): Promise<void> { export async function checkForUpdatesManual(): Promise<void> {
if (!platformUpdater || !provider) return;
try { try {
updateChecked = false; updateChecked = false;
const info = await fetchUpdateInfo();
if (!info) { // ① 先自行检查有无更新
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE); updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '手动检查:当前已是最新版本', {version: APP_VERSION});
return; return;
} }
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, { // ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
version: info.latestVersion, await platformUpdater.checkForUpdates();
releaseDate: info.releaseDate,
releaseNotes: info.changelog,
forceUpdate: info.forceUpdate,
});
await downloadInstaller(info);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
} catch (error) { } catch (error) {
console.error('[Updater] 手动更新失败:', (error as Error).message); logger.error('updater', '手动更新失败', error as Error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, { updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '检查更新失败', message: (error as Error).message || '检查更新失败',
}); });
} }
} }
/** 用户确认后开始下载更新 */
export async function downloadUpdate(): Promise<void> {
if (!platformUpdater) {
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
return;
}
try {
await platformUpdater.downloadUpdate();
} catch (error) {
logger.error('updater', '下载更新失败', error as Error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '下载更新失败',
});
}
}
/** 安装已下载的更新并退出应用 */
export function installUpdateNow(): void { export function installUpdateNow(): void {
if (!pendingInstallerPath) { if (!platformUpdater) {
console.error('[Updater] 没有待安装的更新文件'); logger.warn('updater', 'Updater 未初始化');
return; return;
} }
console.log('[Updater] 开始安装更新:', pendingInstallerPath); logger.info('updater', '开始安装更新并退出应用');
if (process.platform === 'win32') {
// Windows: NSIS 安装器,/S 静默安装
spawn(pendingInstallerPath, ['/S'], {
detached: true,
stdio: 'ignore',
});
} else if (process.platform === 'darwin') {
// macOS: 挂载 DMG
spawn('hdiutil', ['attach', pendingInstallerPath], {
detached: true,
stdio: 'ignore',
});
} else {
// Linux: AppImagechmod +x 后执行(通常需要手动替换)
console.log('[Updater] Linux 更新请手动替换 AppImage 文件');
}
// 退出应用,让安装器接管
setImmediate(() => { setImmediate(() => {
app.quit(); platformUpdater!.quitAndInstall(false, true);
}); });
} }
@@ -310,6 +425,10 @@ export function registerUpdateIpcHandlers(): void {
await checkForUpdatesManual(); await checkForUpdatesManual();
}); });
ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => {
await downloadUpdate();
});
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => { ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
installUpdateNow(); installUpdateNow();
}); });

View File

@@ -1,4 +1,5 @@
import { ipcRenderer, contextBridge } from 'electron'; import { ipcRenderer, contextBridge } from 'electron';
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
// --------- Expose some API to the Renderer process --------- // --------- Expose some API to the Renderer process ---------
contextBridge.exposeInMainWorld('ipcRenderer', { contextBridge.exposeInMainWorld('ipcRenderer', {
@@ -18,7 +19,27 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
const [channel, ...omit] = args; const [channel, ...omit] = args;
return ipcRenderer.invoke(channel, ...omit); return ipcRenderer.invoke(channel, ...omit);
}, },
});
// You can expose other APTs you need here.
// ... // --------- 安全加密 API基于 Electron safeStorage---------
contextBridge.exposeInMainWorld('safeStorage', {
/**
* 加密明文字符串
* @param plaintext - 待加密的明文
* @returns Base64 编码的密文,失败返回 null
*/
async encrypt(plaintext: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
return result?.success ? result.data : null;
},
/**
* 解密 Base64 密文
* @param encryptedBase64 - Base64 编码的密文
* @returns 解密后的明文,失败返回 null
*/
async decrypt(encryptedBase64: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
return result?.success ? result.data : null;
},
}); });

View File

@@ -5,7 +5,13 @@
<!-- 使用项目 Logo 作为 faviconWindows 可用 .ico --> <!-- 使用项目 Logo 作为 faviconWindows 可用 .ico -->
<link rel="icon" type="image/png" href="/src/assets/logo/HX.png" /> <link rel="icon" type="image/png" href="/src/assets/logo/HX.png" />
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />--> <!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"> <meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net;
">
<title>船长·HeiXiu</title> <title>船长·HeiXiu</title>
</head> </head>
<body> <body>

1405
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "ele-heixiu", "name": "ele-heixiu",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.12",
"description": "船长·HeiXiu — 桌面效率工作台", "description": "船长·HeiXiu — 桌面效率工作台",
"author": "HeiXiu 杨烨", "author": "HeiXiu 杨烨",
"type": "module", "type": "module",
@@ -23,13 +23,15 @@
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test" "test:e2e": "playwright test.json"
}, },
"dependencies": { "dependencies": {
"antd": "^6.4.3", "antd": "^6.4.3",
"axios": "^1.16.1", "axios": "^1.16.1",
"electron-updater": "^6.8.3",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7",
"react-router-dom": "^7.16.0"
}, },
"build": { "build": {
"appId": "com.heixiu.electron", "appId": "com.heixiu.electron",
@@ -80,6 +82,7 @@
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false, "deleteAppDataOnUninstall": false,
"differentialPackage": true,
"installerIcon": "src/assets/logo/heixiu.ico", "installerIcon": "src/assets/logo/heixiu.ico",
"uninstallerIcon": "src/assets/logo/heixiu.ico" "uninstallerIcon": "src/assets/logo/heixiu.ico"
} }

View File

@@ -30,12 +30,15 @@ const ROOT = join(__dirname, '..');
const args = process.argv.slice(2); const args = process.argv.slice(2);
// 提取 --url 参数 // 提取 --url 和 --blockmap-url 参数
let urlOverride = ''; let urlOverride = '';
let blockmapUrlOverride = '';
const positional = []; const positional = [];
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1]) { if (args[i] === '--url' && args[i + 1]) {
urlOverride = args[++i]; urlOverride = args[++i];
} else if (args[i] === '--blockmap-url' && args[i + 1]) {
blockmapUrlOverride = args[++i];
} else if (!args[i].startsWith('--')) { } else if (!args[i].startsWith('--')) {
positional.push(args[i]); positional.push(args[i]);
} }
@@ -88,6 +91,36 @@ const fileSize = stats.size;
console.log(`文件大小: ${(fileSize / 1024 / 1024).toFixed(2)} MB`); console.log(`文件大小: ${(fileSize / 1024 / 1024).toFixed(2)} MB`);
console.log(`SHA512: ${sha512.slice(0, 32)}...`); console.log(`SHA512: ${sha512.slice(0, 32)}...`);
// ---------- 查找 blockmap 文件 ----------
let blockMapUrl;
let blockMapSize;
try {
const blockmapFile = readdirSync(releaseDir).find((f) => f.endsWith('.blockmap'));
if (blockmapFile) {
const blockmapPath = join(releaseDir, blockmapFile);
const blockmapStats = statSync(blockmapPath);
blockMapSize = blockmapStats.size;
if (blockmapUrlOverride) {
// 方式 B使用指定的 blockmap URL
blockMapUrl = blockmapUrlOverride;
} else {
// 方式 A根据基地址 + 版本号 + 文件名拼接
const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases';
blockMapUrl = `${downloadBase.replace(/\/+$/, '')}/${version}/${encodeURI(blockmapFile)}`;
}
console.log(`Blockmap: ${blockmapFile} (${(blockMapSize / 1024).toFixed(2)} KB)`);
console.log(`Blockmap URL: ${blockMapUrl}`);
}
} catch {
// blockmap 不存在不影响主流程
blockMapUrl = undefined;
blockMapSize = undefined;
}
// ---------- 构建下载 URL ---------- // ---------- 构建下载 URL ----------
const fileName = installerPath.split(/[/\\]/).pop(); const fileName = installerPath.split(/[/\\]/).pop();
@@ -150,11 +183,16 @@ const updateInfo = {
forceUpdate, forceUpdate,
minimumVersion, minimumVersion,
// --- 增量更新 ---
...(blockMapUrl && { blockMapUrl }),
...(blockMapSize != null && { blockMapSize }),
// --- 附加:方便后端录入 --- // --- 附加:方便后端录入 ---
_meta: { _meta: {
platform, platform,
edition, edition,
fileName, fileName,
blockmap: blockMapUrl ? { url: blockMapUrl, size: blockMapSize } : null,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
}, },
}; };

View File

@@ -1,5 +1,5 @@
// ============================================================ // ============================================================
// 上传安装包到阿里云 OSS // 上传安装包 + blockmap 文件到阿里云 OSS
// //
// 用法: // 用法:
// node scripts/upload-oss.mjs <platform> <edition> [version] // node scripts/upload-oss.mjs <platform> <edition> [version]
@@ -54,7 +54,10 @@ try {
const all = readdirSync(releaseDir); const all = readdirSync(releaseDir);
const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage']; const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage'];
installerName = all.find((f) => exts.some((e) => f.endsWith(e))); installerName = all.find((f) => exts.some((e) => f.endsWith(e)));
if (!installerName) console.error(`错误: ${releaseDir} — 未找到安装包`); if (!installerName) {
console.error(`错误: ${releaseDir} — 未找到安装包`);
process.exit(1);
}
} catch (err) { } catch (err) {
console.error(`错误: ${releaseDir}${err.message}`); console.error(`错误: ${releaseDir}${err.message}`);
process.exit(1); process.exit(1);
@@ -62,6 +65,20 @@ try {
const installerPath = join(releaseDir, installerName); const installerPath = join(releaseDir, installerName);
// ---------- 查找 blockmap 文件 ----------
let blockmapName = null;
let blockmapPath = null;
try {
const all = readdirSync(releaseDir);
blockmapName = all.find((f) => f.endsWith('.blockmap'));
if (blockmapName) {
blockmapPath = join(releaseDir, blockmapName);
}
} catch {
// blockmap 不存在不影响主流程
}
// ---------- OSS 配置 ---------- // ---------- OSS 配置 ----------
const { const {
@@ -74,7 +91,9 @@ const {
const basePath = (process.env.OSS_BASE_PATH || 'releases').replace(/^\/+|\/+$/g, ''); const basePath = (process.env.OSS_BASE_PATH || 'releases').replace(/^\/+|\/+$/g, '');
const objectKey = `${basePath}/${version}/${installerName}`; const objectKey = `${basePath}/${version}/${installerName}`;
// ---------- 模式 A无 OSS 配置 → 打印手动命令 ---------- // ============================================================
// 模式 A无 OSS 配置 → 打印手动命令
// ============================================================
if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) { if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) {
const exampleBucket = OSS_BUCKET || '<bucket>'; const exampleBucket = OSS_BUCKET || '<bucket>';
@@ -82,48 +101,68 @@ if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) {
const downloadUrl = `https://${exampleBucket}.${exampleEndpoint}/${objectKey}`; const downloadUrl = `https://${exampleBucket}.${exampleEndpoint}/${objectKey}`;
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📦 上传安装包(手动)'); console.log('📦 上传安装包 + blockmap(手动)');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`文件: ${installerPath}`); console.log(`文件: ${installerPath}`);
console.log(`大小: ${(readFileSync(installerPath).length / 1024 / 1024).toFixed(2)} MB\n`); console.log(`大小: ${(readFileSync(installerPath).length / 1024 / 1024).toFixed(2)} MB\n`);
if (blockmapPath) {
console.log(`Blockmap: ${blockmapPath}`);
console.log(`Blockmap 大小: ${(readFileSync(blockmapPath).length / 1024).toFixed(2)} KB\n`);
}
console.log('方式 1 — ossutil CLI推荐'); console.log('方式 1 — ossutil CLI推荐');
console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${objectKey}\n`); console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${objectKey}`);
if (blockmapPath) {
const blockmapKey = `${basePath}/${version}/${blockmapName}`;
console.log(` ossutil cp "${blockmapPath}" oss://${exampleBucket}/${blockmapKey}`);
}
console.log('');
console.log('方式 2 — 阿里云控制台:'); console.log('方式 2 — 阿里云控制台:');
console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${objectKey}\n`); console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${objectKey}`);
if (blockmapPath) {
console.log(` 同时上传 ${blockmapName} 到相同目录`);
}
console.log('');
const blockmapUrl = blockmapPath
? `https://${exampleBucket}.${exampleEndpoint}/${basePath}/${version}/${blockmapName}`
: '';
console.log('上传后执行以下命令,用实际 URL 重新生成 info'); console.log('上传后执行以下命令,用实际 URL 重新生成 info');
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"\n`); if (blockmapUrl) {
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
console.log('');
console.log('或直接发布到后端 API');
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
} else {
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
console.log('');
console.log('或直接发布到后端 API'); console.log('或直接发布到后端 API');
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`); console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
process.exit(0); process.exit(0);
} }
// ---------- 模式 BNode.js 直传 OSS ---------- // ============================================================
// 模式 BNode.js 直传 OSS安装包 + blockmap
// ============================================================
const fileBuffer = readFileSync(installerPath); const fileBuffer = readFileSync(installerPath);
const contentType = platform === 'win32' const contentType = platform === 'win32'
? 'application/vnd.microsoft.portable-executable' ? 'application/vnd.microsoft.portable-executable'
: 'application/octet-stream'; : 'application/octet-stream';
// 上传安装包
const date = new Date().toUTCString(); const date = new Date().toUTCString();
// 阿里云 OSS Authorization 签名
const stringToSign = [ const stringToSign = [
'PUT', 'PUT', '', contentType, date, `/${OSS_BUCKET}/${objectKey}`,
'',
contentType,
date,
`/${OSS_BUCKET}/${objectKey}`,
].join('\n'); ].join('\n');
const signature = createHmac('sha1', OSS_ACCESS_KEY_SECRET) const signature = createHmac('sha1', OSS_ACCESS_KEY_SECRET)
.update(stringToSign) .update(stringToSign)
.digest('base64'); .digest('base64');
const authHeader = `OSS ${OSS_ACCESS_KEY_ID}:${signature}`; const authHeader = `OSS ${OSS_ACCESS_KEY_ID}:${signature}`;
const downloadUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${objectKey}`; const downloadUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${objectKey}`;
@@ -142,20 +181,68 @@ try {
body: fileBuffer, body: fileBuffer,
}); });
if (response.ok) { if (!response.ok) {
console.log(`✅ 上传成功`);
console.log(`下载 URL: ${downloadUrl}\n`);
console.log('接下来:');
console.log(` # 重新生成 update-info.json`);
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
console.log(`\n # 或直接发布到后端:`);
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
} else {
const body = await response.text(); const body = await response.text();
console.error(`❌ 上传失败 HTTP ${response.status}: ${body}`); console.error(`安装包上传失败 HTTP ${response.status}: ${body}`);
process.exit(1); process.exit(1);
} }
console.log(`✅ 安装包上传成功`);
console.log(`下载 URL: ${downloadUrl}`);
} catch (err) {
console.error(`❌ 安装包上传失败: ${err.message}`);
process.exit(1);
}
// 上传 blockmap 文件(如果存在)
let blockmapUrl = null;
if (blockmapPath) {
const blockmapBuffer = readFileSync(blockmapPath);
const blockmapKey = `${basePath}/${version}/${blockmapName}`;
blockmapUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${blockmapKey}`;
const bmDate = new Date().toUTCString();
const bmStringToSign = [
'PUT', '', 'application/octet-stream', bmDate, `/${OSS_BUCKET}/${blockmapKey}`,
].join('\n');
const bmSignature = createHmac('sha1', OSS_ACCESS_KEY_SECRET)
.update(bmStringToSign)
.digest('base64');
console.log(`\n上传 blockmap: ${blockmapName} (${(blockmapBuffer.length / 1024).toFixed(2)} KB)`);
try {
const bmResponse = await fetch(blockmapUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(blockmapBuffer.length),
'Date': bmDate,
'Authorization': `OSS ${OSS_ACCESS_KEY_ID}:${bmSignature}`,
},
body: blockmapBuffer,
});
if (bmResponse.ok) {
console.log(`✅ Blockmap 上传成功`);
console.log(`Blockmap URL: ${blockmapUrl}`);
} else {
console.warn(`⚠️ Blockmap 上传失败不影响主流程HTTP ${bmResponse.status}`);
blockmapUrl = null;
}
} catch (err) { } catch (err) {
console.error(`❌ 上传失败: ${err.message}`); console.warn(`⚠️ Blockmap 上传失败(不影响主流程): ${err.message}`);
process.exit(1); blockmapUrl = null;
}
}
// 后续步骤提示
console.log('\n接下来 — 重新生成 update-info.json');
if (blockmapUrl) {
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
console.log('\n或直接发布到后端 API');
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
} else {
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
console.log('\n或直接发布到后端 API');
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
} }

View File

@@ -30,6 +30,8 @@ export const RENDERER_TO_MAIN = {
TOGGLE_MAXIMIZE: 'toggle-maximize', TOGGLE_MAXIMIZE: 'toggle-maximize',
/** 关闭窗口 */ /** 关闭窗口 */
CLOSE_WINDOW: 'close-window', CLOSE_WINDOW: 'close-window',
/** 渲染进程日志上报 */
LOG_MESSAGE: 'log-message',
} as const; } as const;
/** 双向通信通道 */ /** 双向通信通道 */
@@ -40,4 +42,8 @@ export const BIDIRECTIONAL = {
WRITE_CONFIG: 'write-config', WRITE_CONFIG: 'write-config',
/** 文件对话框 */ /** 文件对话框 */
FILE_DIALOG: 'file-dialog', FILE_DIALOG: 'file-dialog',
/** safeStorage 加密(明文 → Base64 密文) */
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
/** safeStorage 解密Base64 密文 → 明文) */
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
} as const; } as const;

View File

@@ -40,6 +40,12 @@ export interface AppConfig {
// 更新相关类型 // 更新相关类型
// ============================================================ // ============================================================
// ============================================================
// 日志类型(从 logging.ts 重导出)
// ============================================================
export type { LogLevel, LogCategory, LogErrorSnapshot, LogEntry } from './logging';
/** 更新检查请求参数 */ /** 更新检查请求参数 */
export interface UpdateCheckParams { export interface UpdateCheckParams {
platform: Platform; platform: Platform;
@@ -68,4 +74,8 @@ export interface UpdateCheckResult {
forceUpdate: boolean; forceUpdate: boolean;
/** 最低兼容版本(低于此版本必须强制更新) */ /** 最低兼容版本(低于此版本必须强制更新) */
minimumVersion: string; minimumVersion: string;
/** Blockmap 文件下载地址(可选,用于增量更新) */
blockMapUrl?: string;
/** Blockmap 文件大小(可选,>0 时客户端启用差分下载) */
blockMapSize?: number;
} }

52
shared/types/logging.ts Normal file
View File

@@ -0,0 +1,52 @@
// ============================================================
// 日志模块 — 共享类型定义(主进程 + 渲染进程双端使用)
// ============================================================
/** 日志级别 */
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
/** 日志分类 */
export type LogCategory =
| 'request' // HTTP 请求相关
| 'auth' // 登录 / Token / 权限
| 'updater' // 自动更新
| 'event' // 事件总线
| 'app' // 应用生命周期
| 'ui' // UI 层错误 / 警告
| 'ipc' // IPC 通信
| 'safe-storage'; // 安全存储safeStorage 加密/解密)
/**
* 结构化错误快照
* Error 对象不可 JSON 序列化,提取关键字段保存
*/
export interface LogErrorSnapshot {
/** 错误名称Error.name 或 constructor name */
name: string;
/** 错误消息 */
message: string;
/** 堆栈信息(可选,按需记录以节省空间) */
stack?: string;
/** 原始错误链cause的消息摘要 */
cause?: string;
}
/**
* 单条日志条目JSON Lines 中的一行)
*/
export interface LogEntry {
/** ISO 8601 时间戳(例如 "2026-06-04T10:30:00.123Z" */
timestamp: string;
/** 日志级别 */
level: LogLevel;
/** 日志分类 */
category: LogCategory;
/** 日志消息(简短描述) */
message: string;
/** 日志来源进程 */
processType: 'main' | 'renderer';
/** 附加上下文(需 JSON 可序列化) */
context?: Record<string, unknown>;
/** 错误快照(仅在 warn / error 级别时填充) */
error?: LogErrorSnapshot;
}

182
shared/utils/sanitize.ts Normal file
View File

@@ -0,0 +1,182 @@
// ============================================================
// 日志脱敏工具 — 主进程 + 渲染进程共用
//
// 设计:在 createEntry 阶段统一对 context 字段脱敏,调用方无感知
//
// 脱敏策略(按字段名模式匹配,大小写不敏感):
// *token* / *secret* / *password* / *pwd* → [REDACTED]
// *email* → 保留首字符 + 域名t***@qq.com
// *phone* / *mobile* / *tel* → 保留首 3 尾 4138****5678
// *id / *_id / userId / user_id → SHA256 前 8 位(可追溯但不可读)
// *name / username / display_name → 保留首字符 + ***
// *balance* / *money* / *amount* → 数值替换为 [FILTERED]
// 其他字段 → 原样保留
// ============================================================
import type { LogEntry } from '@shared/types';
// ---------- 脱敏函数 ----------
/** 通过哈希缩短(用于 ID 类字段,可追溯不可读) */
export function hashShort(value: string): string {
// 简易哈希(避免引入 crypto 模块,双端兼容)
let h = 0;
for (let i = 0; i < value.length; i++) {
h = (Math.imul(31, h) + value.charCodeAt(i)) | 0;
}
// 转 hex 前缀,补零到 8 位
const hex = (h >>> 0).toString(16).padStart(8, '0');
return `#${hex}`;
}
/** 邮箱脱敏:首字符 + ***@域名 */
export function sanitizeEmail(value: string): string {
const atIndex = value.indexOf('@');
if (atIndex <= 0) return `${value[0]}***`;
return `${value[0]}***@${value.slice(atIndex + 1)}`;
}
/** 手机号脱敏:保留前 3 后 4 */
export function sanitizePhone(value: string): string {
const digits = value.replace(/\D/g, '');
if (digits.length < 7) return `${digits.slice(0, 3)}****`;
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
/** 名称脱敏:保留首字符 + *** */
export function sanitizeName(value: string): string {
if (!value || value.length <= 1) return '***';
return `${value[0]}***`;
}
/** 判断字段名是否匹配某个模式 */
function matchKey(key: string, patterns: string[]): boolean {
const lower = key.toLowerCase();
return patterns.some((p) => {
if (p.startsWith('*') && p.endsWith('*')) return lower.includes(p.slice(1, -1));
if (p.endsWith('*')) return lower.startsWith(p.slice(0, -1));
if (p.startsWith('*')) return lower.endsWith(p.slice(1));
return lower === p;
});
}
/** 对单个值脱敏(递归处理嵌套对象) */
function sanitizeValue(key: string, value: unknown): unknown {
if (value === null || value === undefined) return value;
// 完全隐藏
if (matchKey(key, ['*token*', '*secret*', '*password*', '*pwd*', 'authorization'])) {
return '[REDACTED]';
}
// 邮箱
if (
matchKey(key, ['*email*']) &&
typeof value === 'string' &&
value.includes('@')
) {
return sanitizeEmail(value);
}
// 手机号
if (matchKey(key, ['*phone*', '*mobile*', '*tel*']) && typeof value === 'string') {
return sanitizePhone(value);
}
// ID 类字段(数字或字符串)
if (
matchKey(key, ['*id', '*_id', 'userId', 'user_id', 'company_id']) &&
(typeof value === 'string' || typeof value === 'number')
) {
return hashShort(String(value));
}
// 名称
if (
matchKey(key, ['*name', 'username', 'display_name', 'real_name']) &&
typeof value === 'string'
) {
return sanitizeName(value);
}
// 金额 / 余额
if (matchKey(key, ['*balance*', '*money*', '*amount*', '*cent*', '*fee*'])) {
return '[FILTERED]';
}
// 递归处理嵌套对象
if (typeof value === 'object' && !Array.isArray(value)) {
return sanitizeRecord(value as Record<string, unknown>);
}
// 数组:逐个脱敏
if (Array.isArray(value)) {
return value.map((v) => sanitizeValue(key, v));
}
return value;
}
/** 脱敏整个 Record */
function sanitizeRecord(record: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
result[key] = sanitizeValue(key, value);
}
return result;
}
// ---------- 对外 API ----------
/**
* 脱敏任意对象(返回新对象,不修改原对象)
*
* 用法:
* import { sanitize } from '@shared/utils/sanitize';
* const safe = sanitize({ userId: 42, email: 'test@qq.com', phone: '13812345678' });
* // → { userId: "#a1b2c3d4", email: "t***@qq.com", phone: "138****5678" }
*/
export function sanitize(record: Record<string, unknown>): Record<string, unknown> {
return sanitizeRecord(record);
}
/**
* 对 LogEntry 脱敏(原地不修改,返回新对象)
* 内部也调用了 sanitizeRecord
*/
export function sanitizeLogEntry(entry: LogEntry): LogEntry {
const sanitized = { ...entry };
// 脱敏 context
if (sanitized.context && Object.keys(sanitized.context).length > 0) {
sanitized.context = sanitizeRecord(sanitized.context);
}
// 脱敏 error 中的 message可能含用户信息如 "User xxx not found"
if (sanitized.error) {
sanitized.error = { ...sanitized.error };
// 对 error message 做基础脱敏(邮箱/手机号)
if (sanitized.error.message) {
sanitized.error.message = sanitizeInlineText(sanitized.error.message);
}
}
// 脱敏 message 字段本身
sanitized.message = sanitizeInlineText(sanitized.message);
return sanitized;
}
/**
* 对任意文本中的敏感信息做内联脱敏
* 使用正则匹配邮箱和手机号模式
*/
export function sanitizeInlineText(text: string): string {
// 邮箱(简单模式)
let result = text.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, (match) =>
sanitizeEmail(match),
);
// 手机号(中国大陆 11 位)
result = result.replace(/\b1[3-9]\d{9}\b/g, (match) => sanitizePhone(match));
return result;
}

View File

@@ -1,56 +1,21 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import { HashRouter } from 'react-router-dom';
Button,
Card,
Space,
Tag,
Typography,
Switch as AntSwitch,
Slider,
Progress,
theme as antTheme,
} from 'antd';
import {
ApiOutlined,
ThunderboltOutlined,
BulbOutlined,
BulbFilled,
DownloadOutlined,
SyncOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
RocketOutlined,
} from '@ant-design/icons';
import { getPlatformName, isDev } from './utils/platform';
import { gradientPrimary } from './theme/tokens';
import { useTheme } from './hooks/use-theme';
import { useUpdater } from './hooks/use-updater';
import { useAppContext } from './contexts/app-context'; import { useAppContext } from './contexts/app-context';
import { NavBar } from './components/navbar';
import { LoginPage } from './pages/login'; import { LoginPage } from './pages/login';
import { on, off, EVENTS } from './utils/event-bus'; import { on, off, EVENTS } from './utils/event-bus';
import { AppRoutes } from './router';
const { Title, Text } = Typography;
/** /**
* 根组件 — 主窗口 * 根组件 — 主窗口
* 未登录时弹出登录 Modal已登录显示主页 *
* axios 返回 401 → 事件总线触发 → Modal 再次弹出 * 层级:
* HashRouter
* ├── LoginPageModal 弹层,事件总线驱动)
* ├── AppRoutes页面路由 + SettingsPage Drawer 叠加)
*/ */
function App() { function App() {
const { isDark, toggleTheme } = useTheme(); const { isLoggedIn } = useAppContext();
const { platform, edition, isLoggedIn, logout } = useAppContext();
const { token } = antTheme.useToken();
const [progress, setProgress] = useState(65);
const {
status: updateStatus,
progress: updateProgress,
info: updateInfo,
error: updateError,
checkForUpdates,
installUpdate,
} = useUpdater();
// 登录 Modal 状态 // 登录 Modal 状态
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
@@ -78,245 +43,13 @@ function App() {
}, []); }, []);
return ( return (
<> <HashRouter>
{/* 登录 Modal — 可关闭,关闭后主页可操作 */} {/* 登录 Modal — 可关闭,关闭后主页可操作 */}
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} /> <LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
{/* ========== 主页 ========== */} {/* 页面路由 + SettingsPage Drawer 叠加 */}
<div <AppRoutes />
className="min-h-screen flex flex-col transition-colors duration-300" </HashRouter>
style={{ background: token.colorBgLayout }}
>
{/* ======== 自定义导航栏(替代系统菜单) ======== */}
<NavBar />
{/* ======== 页面内容 ======== */}
<div className="flex-1 p-8">
<div className="mx-auto max-w-4xl space-y-6">
{/* ---- 标题区 + 主题开关 ---- */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-4">
<Title level={1} className="mb-0! gradient-primary-text">
HeiXiu ·
</Title>
<AntSwitch
checked={isDark}
onChange={toggleTheme}
checkedChildren={<BulbFilled />}
unCheckedChildren={<BulbOutlined />}
title={isDark ? '切换亮色模式' : '切换暗色模式'}
/>
</div>
<Text type="secondary">
Electron + React 19 + TypeScript + Ant Design + Tailwind CSS
</Text>
<br />
<Space size={4}>
<Tag color={isDark ? 'blue' : 'purple'}>
{isDark ? '🌙 暗色' : '☀️ 亮色'}
</Tag>
<Tag color={edition === 'enterprise' ? 'gold' : 'green'}>
{edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
{isLoggedIn ? (
<>
<Tag color="blue"></Tag>
<Button size="small" type="link" danger onClick={logout}>
退
</Button>
</>
) : (
<Tag color="default"></Tag>
)}
</Space>
</div>
{/* ---- 导航栏状态提示 ---- */}
<Card size="small" variant="borderless" className="rounded-lg">
<Text type="secondary" className="text-xs">
💡 使 <Text strong>H5 </Text>
{platform === 'darwin'
? ' macOS 保留了最小 Edit 菜单以支持 Cmd+C/V 等快捷键。'
: ' Windows/Linux 已完全移除系统菜单栏。'}
</Text>
</Card>
{/* ---- 主题色预览 ---- */}
<Card
title="🎨 蓝紫渐变主题色预览"
variant="borderless"
className="rounded-lg shadow-lg"
>
<Space orientation="vertical" size="large" className="w-full">
{/* 渐变展示 */}
<div>
<Text strong></Text>
<div className="flex gap-3 mt-2">
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: gradientPrimary }}
>
135°
</div>
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: 'linear-gradient(90deg, #4F46E5, #7C3AED)' }}
>
90°
</div>
</div>
</div>
{/* 组件示例 */}
<div>
<Text strong>Ant Design </Text>
<Space wrap className="mt-2">
<Button type="primary" icon={<ThunderboltOutlined />}>
</Button>
<Button></Button>
<Button type="dashed">线</Button>
<Button type="text"></Button>
<Button type="link"></Button>
</Space>
</div>
{/* 滑块 + 进度条 */}
<div>
<Text strong></Text>
<div className="flex items-center gap-4 mt-2">
<span className="text-sm" style={{ color: token.colorTextSecondary }}>
:
</span>
<Slider
className="flex-1"
value={progress}
onChange={setProgress}
min={0}
max={100}
/>
<Progress
type="circle"
percent={progress}
size={48}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
</div>
</div>
</Space>
</Card>
{/* ---- 平台信息 ---- */}
<Card title="🖥️ 系统信息" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">{getPlatformName()}</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color={isDev() ? 'blue' : 'green'}>
{isDev() ? '开发模式' : '生产模式'}
</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">
H5 {edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
</div>
</Space>
</Card>
{/* ---- 版本更新 ---- */}
<Card title="🔄 版本更新" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
{/* 状态提示 */}
{updateStatus === 'checking' && (
<div className="flex items-center gap-2">
<SyncOutlined spin style={{ color: token.colorPrimary }} />
<Text type="secondary">...</Text>
</div>
)}
{updateStatus === 'no-update' && (
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text type="success"></Text>
</div>
)}
{updateStatus === 'error' && (
<div className="flex items-center gap-2">
<ExclamationCircleOutlined style={{ color: token.colorError }} />
<Text type="danger">{updateError?.message || '检查更新失败'}</Text>
</div>
)}
{/* 发现新版本 */}
{(updateStatus === 'available' || updateStatus === 'downloading') && (
<>
<div className="flex items-center gap-2">
<RocketOutlined style={{ color: token.colorPrimary }} />
<Text strong>{updateInfo?.version}</Text>
{updateInfo?.forceUpdate && <Tag color="red"></Tag>}
</div>
{updateInfo?.releaseNotes && (
<Text type="secondary" className="text-xs whitespace-pre-line">
{updateInfo.releaseNotes}
</Text>
)}
</>
)}
{/* 下载进度 */}
{updateStatus === 'downloading' && (
<Progress
percent={updateProgress.percent}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
)}
{/* 下载完成 */}
{updateStatus === 'downloaded' && (
<>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text></Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={installUpdate}
>
</Button>
</>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button
icon={<SyncOutlined spin={updateStatus === 'checking'} />}
onClick={checkForUpdates}
disabled={updateStatus === 'checking'}
>
</Button>
{isDev() && (
<Text type="secondary" className="text-xs">
5
</Text>
)}
</div>
</Space>
</Card>
</div>
</div>
</div>
</>
); );
} }

View File

@@ -5,11 +5,24 @@
import { useState, useCallback, useEffect, type ReactNode } from 'react'; import { useState, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types'; import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '../utils/platform'; import { getPlatform, isDev } from '@/utils/platform';
import { parseEdition } from '@shared/constants/app'; import { parseEdition } from '@shared/constants/app';
import { safeIpcOn, safeIpcOff } from '../utils/ipc'; import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
import { AppContext } from '../contexts/app-context'; import { AppContext } from '@/contexts/app-context';
import type { AppContextValue } from '../contexts/app-context'; import type { AppContextValue } from '@/contexts/app-context';
import type { LoginResponseBody } from '@/services/modules';
import { setToken, clearToken, initTokenStore } from '@/services/request';
import { emit, EVENTS } from '@/utils/event-bus';
import { logger } from '@/utils/logger';
import { AuthAPI } from '@/services/modules';
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/pages/login/hooks/use-auth-state';
import {
initAuthRefresh,
getStoredRefreshToken,
setStoredRefreshToken,
clearStoredRefreshToken,
initRefreshTokenStore,
} from '@/services/auth-token';
interface AppProviderProps { interface AppProviderProps {
children: ReactNode; children: ReactNode;
@@ -21,25 +34,92 @@ export function AppProvider({ children }: AppProviderProps) {
return parseEdition(import.meta.env.VITE_EDITION); return parseEdition(import.meta.env.VITE_EDITION);
}); });
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState<LoginResponseBody | null>(null);
const login = useCallback(() => { // ---------- 登录 / 退出 ----------
// TODO: 接入真实登录流程
const login = useCallback(async (auth: LoginResponseBody) => {
await setToken(auth.access_token);
await setStoredRefreshToken(auth.refresh_token);
setUser(auth);
setIsLoggedIn(true); setIsLoggedIn(true);
logger.info('auth', 'User logged in', {
userId: auth.user_id,
username: auth.username,
});
emit(EVENTS.LOGIN_SUCCESS, auth);
}, []); }, []);
const logout = useCallback(() => { const logout = useCallback(() => {
// TODO: 清理 token、用户状态等 logger.info('auth', 'User logged out');
clearToken();
clearStoredRefreshToken();
setUser(null);
setIsLoggedIn(false); setIsLoggedIn(false);
emit(EVENTS.LOGOUT);
}, []); }, []);
// 监听主进程推送的平台/版本信息 // ---------- 启动时初始化 token 存储 + 自动登录 ----------
useEffect(() => {
// 从加密存储恢复 token 到内存缓存
Promise.all([initTokenStore(), initRefreshTokenStore()]).then(() => {
// 初始化 401 刷新回调token 缓存就绪后才注册)
initAuthRefresh();
// 自动登录refresh_token 续期)
if (!getAutoLoginFlag()) return;
const refreshToken = getStoredRefreshToken();
if (!refreshToken) return;
AuthAPI.refreshToken({ refresh_token: refreshToken })
.then(async (res) => {
await setToken(res.access_token);
await setStoredRefreshToken(res.refresh_token);
setIsLoggedIn(true);
logger.info('auth', 'Auto-login succeeded');
// 补全用户信息
AuthAPI.getUserInfo().then((info) => {
const u: LoginResponseBody = {
access_token: res.access_token,
refresh_token: res.refresh_token,
token_type: res.token_type,
expires_in: res.expires_in,
refresh_expires_in: res.refresh_expires_in,
user_id: info.id,
username: info.username,
role: info.role,
email: info.email,
balance_cent: info.balance_cent,
account_type: info.account_type,
display_name: info.display_name,
company_id: info.company_id,
real_name: info.real_name,
phone: info.phone,
license_code: '',
admin_permissions: info.admin_permissions,
};
setUser(u);
emit(EVENTS.LOGIN_SUCCESS, u);
});
})
.catch(() => {
logger.warn('auth', 'Auto-login failed, refresh_token expired');
clearStoredRefreshToken();
// 仅清除自动登录标记,保留用户名供下次表单回填
clearAutoLoginFlag();
});
});
}, []);
// ---------- 监听主进程推送的平台/版本信息 ----------
useEffect(() => { useEffect(() => {
const handler = (_event: unknown, info: unknown) => { const handler = (_event: unknown, info: unknown) => {
const data = info as { platform?: string; edition?: string } | undefined; const data = info as { platform?: string; edition?: string } | undefined;
if (data) { if (data) {
if (isDev()) { logger.debug('app', 'Received platform info from main process', data as Record<string, unknown>);
console.log('[AppContext] 主进程平台信息:', data);
}
} }
}; };
safeIpcOn('platform-info', handler); safeIpcOn('platform-info', handler);
@@ -52,6 +132,7 @@ export function AppProvider({ children }: AppProviderProps) {
platform, platform,
edition, edition,
isLoggedIn, isLoggedIn,
user,
login, login,
logout, logout,
isDevMode: isDev(), isDevMode: isDev(),

21
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
// ============================================================
// Layout — 页面布局壳NavBar + 页面内容 Outlet
// ============================================================
import { Outlet } from 'react-router-dom';
import { theme as antTheme } from 'antd';
import { NavBar } from './navbar';
export function Layout() {
const { token } = antTheme.useToken();
return (
<div
className="min-h-screen flex flex-col transition-colors duration-300"
style={{ background: token.colorBgLayout }}
>
<NavBar />
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,64 @@
// ============================================================
// SettingsProvider — 设置状态 Provider 组件
//
// 职责:
// - 初始化时从 localStorage 读取已存储的设置项
// - 提供 SettingsContext 给所有子组件
// - 写操作同步更新 state + localStorage
// ============================================================
import { useState, useCallback, type ReactNode } from 'react';
import {
SettingsContext,
readStoredOutputPath,
writeStoredOutputPath,
clearStoredOutputPath,
readStoredTeamRepoPath,
writeStoredTeamRepoPath,
clearStoredTeamRepoPath,
} from '@/contexts/settings-context';
import type { SettingsContextValue } from '@/contexts/settings-context';
interface SettingsProviderProps {
children: ReactNode;
}
export function SettingsProvider({ children }: SettingsProviderProps) {
const [outputPath, setOutputPathState] = useState<string>(readStoredOutputPath);
const [teamRepoPath, setTeamRepoPathState] = useState<string>(readStoredTeamRepoPath);
// ---------- outputPath ----------
const setOutputPath = useCallback((path: string) => {
setOutputPathState(path);
writeStoredOutputPath(path);
}, []);
const clearOutputPath = useCallback(() => {
setOutputPathState('');
clearStoredOutputPath();
}, []);
// ---------- teamRepoPath ----------
const setTeamRepoPath = useCallback((path: string) => {
setTeamRepoPathState(path);
writeStoredTeamRepoPath(path);
}, []);
const clearTeamRepoPath = useCallback(() => {
setTeamRepoPathState('');
clearStoredTeamRepoPath();
}, []);
const value: SettingsContextValue = {
outputPath,
teamRepoPath,
setOutputPath,
setTeamRepoPath,
clearOutputPath,
clearTeamRepoPath,
};
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
}

View File

@@ -4,7 +4,7 @@
// ============================================================ // ============================================================
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Drawer, List, Typography, Empty, Tag, Spin } from 'antd'; import { Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip } from 'antd';
import { NotificationOutlined } from '@ant-design/icons'; import { NotificationOutlined } from '@ant-design/icons';
import { fetchAnnouncements, type Announcement } from '@/services/modules'; import { fetchAnnouncements, type Announcement } from '@/services/modules';
@@ -29,13 +29,15 @@ const typeLabelMap: Record<string, string> = {
maintenance: '维护', maintenance: '维护',
}; };
/** 格式化日期YYYY-MM-DD */ /** 格式化日期YYYY-MM-DD HH:mm */
function formatDate(iso: string): string { function formatDate(iso: string): string {
try { try {
return new Date(iso).toLocaleDateString('zh-CN', { return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}); });
} catch { } catch {
return iso; return iso;
@@ -54,6 +56,9 @@ export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 详情 Modal
const [detail, setDetail] = useState<Announcement | null>(null);
// 打开抽屉时请求公告列表 // 打开抽屉时请求公告列表
const loadAnnouncements = useCallback(async () => { const loadAnnouncements = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -79,6 +84,7 @@ export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
// ---- 渲染 ---- // ---- 渲染 ----
return ( return (
<>
<Drawer <Drawer
title={ title={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -105,43 +111,94 @@ export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
<Empty description="暂无公告" /> <Empty description="暂无公告" />
</div> </div>
) : ( ) : (
<List <div className="flex flex-col gap-3 p-3">
dataSource={list} {list.map((item) => (
renderItem={(item) => ( <Tooltip
<List.Item className="px-6! py-4!"> key={item.id}
<div className="w-full space-y-2"> title={
{/* 标题 + 类型标签 */} item.content.length > 100
<div className="flex items-center justify-between gap-2"> ? item.content.slice(0, 200) + '…'
<Text strong className="text-sm flex-1" ellipsis> : undefined
}
placement="left"
>
<Card
size="small"
className="rounded-md! border-0! shadow-sm! cursor-pointer hover:shadow-md! transition-shadow"
title={
<Text strong className="text-sm" ellipsis>
{item.pinned && '📌 '} {item.pinned && '📌 '}
{item.title} {item.title}
</Text> </Text>
}
extra={
<Tag <Tag
color={typeColorMap[item.type] || 'default'} color={typeColorMap[item.type] || 'default'}
className="m-0! text-xs shrink-0" className="m-0! text-xs"
> >
{typeLabelMap[item.type] || item.type} {typeLabelMap[item.type] || item.type}
</Tag> </Tag>
</div> }
onClick={() => setDetail(item)}
{/* 内容 */} >
<Paragraph <Paragraph
className="mb-0! text-xs" className="mb-2! text-xs"
type="secondary" type="secondary"
ellipsis={{ rows: 2 }} ellipsis={{ rows: 2 }}
> >
{item.content} {item.content}
</Paragraph> </Paragraph>
{/* 日期 */}
<Text type="secondary" className="text-xs"> <Text type="secondary" className="text-xs">
{formatDate(item.created_at)} {formatDate(item.created_at)}
</Text> </Text>
</Card>
</Tooltip>
))}
</div> </div>
</List.Item>
)}
/>
)} )}
</Drawer> </Drawer>
{/* ======== 公告详情 Modal ======== */}
<Modal
open={!!detail}
onCancel={() => setDetail(null)}
footer={null}
width={480}
centered
title={
detail && (
<span className="flex items-center gap-2">
{detail.pinned && '📌 '}
{detail.title}
</span>
)
}
>
{detail && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Tag
color={typeColorMap[detail.type] || 'default'}
className="m-0! text-xs"
>
{typeLabelMap[detail.type] || detail.type}
</Tag>
<Text type="secondary" className="text-xs">
{formatDate(detail.created_at)}
</Text>
</div>
<div className="px-1 py-2 rounded-md"
style={{
background: 'var(--color-fill-tertiary, rgba(0,0,0,0.04))',
}}
>
<Paragraph className="mb-0! text-sm leading-relaxed whitespace-pre-wrap">
{detail.content}
</Paragraph>
</div>
</div>
)}
</Modal>
</>
); );
} }

View File

@@ -8,6 +8,7 @@
// ============================================================ // ============================================================
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { App } from 'antd'; import { App } from 'antd';
import { useAppContext } from '@/contexts/app-context'; import { useAppContext } from '@/contexts/app-context';
@@ -24,6 +25,7 @@ export function NavBar() {
const { platform, edition, isLoggedIn, logout } = useAppContext(); const { platform, edition, isLoggedIn, logout } = useAppContext();
const { isDark } = useTheme(); const { isDark } = useTheme();
const { message } = App.useApp(); const { message } = App.useApp();
const navigate = useNavigate();
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@@ -90,8 +92,8 @@ export function NavBar() {
case 'spa': case 'spa':
if (item.key === 'auth') { if (item.key === 'auth') {
emit(EVENTS.SHOW_LOGIN); emit(EVENTS.SHOW_LOGIN);
} else { } else if (item.target) {
console.log('[NavBar] 导航至:', item.target); navigate(item.target);
} }
break; break;
} }

View File

@@ -4,6 +4,7 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import type { Platform, Edition } from '@shared/types'; import type { Platform, Edition } from '@shared/types';
import type { LoginResponseBody } from '@/services/modules';
// ---------- Context 类型 ---------- // ---------- Context 类型 ----------
@@ -14,9 +15,11 @@ export interface AppContextValue {
edition: Edition; edition: Edition;
/** 用户是否已登录 */ /** 用户是否已登录 */
isLoggedIn: boolean; isLoggedIn: boolean;
/** 登录TODO: 接入真实认证 */ /** 当前登录用户信息(未登录时为 null结构与 LoginResponseBody 一致 */
login: () => void; user: LoginResponseBody | null;
/** 退出登录 */ /** 登录:保存 Token 并更新登录态auth 为登录接口返回值,异步加密存储) */
login: (auth: LoginResponseBody) => Promise<void>;
/** 退出登录:清除 Token 和用户状态 */
logout: () => void; logout: () => void;
/** 是否为开发环境 */ /** 是否为开发环境 */
isDevMode: boolean; isDevMode: boolean;

View File

@@ -0,0 +1,94 @@
// ============================================================
// SettingsContext — 设置项集中状态管理(类似 Vue Pinia store
//
// 职责:
// - 存储路径的集中管理read/write/clear
// - 持久化到 localStorage遵循 ele-heixiu-* 键名规范
// - 全局组件通过 useSettings() 消费
// ============================================================
import { createContext, useContext } from 'react';
// ---------- localStorage 键 ----------
const OUTPUT_PATH_KEY = 'ele-heixiu-output-path';
const TEAM_REPO_PATH_KEY = 'ele-heixiu-team-repo-path';
// ---------- Context 类型 ----------
export interface SettingsContextValue {
/** 大模型产物存储仓库路径 */
outputPath: string;
/** 团队创作存储仓库路径(企业版) */
teamRepoPath: string;
/** 设置产物输出路径 */
setOutputPath: (path: string) => void;
/** 设置团队创作仓库路径 */
setTeamRepoPath: (path: string) => void;
/** 清除产物输出路径 */
clearOutputPath: () => void;
/** 清除团队创作仓库路径 */
clearTeamRepoPath: () => void;
}
export const SettingsContext = createContext<SettingsContextValue | null>(null);
// ---------- 工具函数localStorage 读写) ----------
export function readStoredOutputPath(): string {
try {
return localStorage.getItem(OUTPUT_PATH_KEY) || '';
} catch {
return '';
}
}
export function writeStoredOutputPath(path: string): void {
try {
localStorage.setItem(OUTPUT_PATH_KEY, path);
} catch {
/* 静默忽略 */
}
}
export function clearStoredOutputPath(): void {
try {
localStorage.removeItem(OUTPUT_PATH_KEY);
} catch {
/* 静默忽略 */
}
}
export function readStoredTeamRepoPath(): string {
try {
return localStorage.getItem(TEAM_REPO_PATH_KEY) || '';
} catch {
return '';
}
}
export function writeStoredTeamRepoPath(path: string): void {
try {
localStorage.setItem(TEAM_REPO_PATH_KEY, path);
} catch {
/* 静默忽略 */
}
}
export function clearStoredTeamRepoPath(): void {
try {
localStorage.removeItem(TEAM_REPO_PATH_KEY);
} catch {
/* 静默忽略 */
}
}
// ---------- Consumer Hook ----------
export function useSettings(): SettingsContextValue {
const ctx = useContext(SettingsContext);
if (!ctx) {
throw new Error('useSettings() 必须在 <SettingsProvider> 内部调用');
}
return ctx;
}

View File

@@ -1,26 +1,31 @@
// ============================================================ // ============================================================
// useUpdater — 渲染进程中的更新状态管理 // useUpdater — 渲染进程中的更新状态管理(单例 IPC 监听)
// //
// 用法: // 用法:
// const { status, progress, checkForUpdates, installUpdate } = useUpdater(); // const { status, progress, checkForUpdates, installUpdate } = useUpdater();
// //
// 状态机idle → checking → (no-update | available → downloading → downloaded → idle) // 状态机idle → checking → (no-update | available → downloading → downloaded → idle)
//
// 关键设计:
// - IPC 监听器在模块级别只注册一次(单例),避免多个组件同时
// 调用 useUpdater() 导致 MaxListenersExceededWarning
// - 所有 useUpdater() 实例通过订阅机制共享同一份状态
// ============================================================ // ============================================================
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { safeIpcInvoke, safeIpcOn, safeIpcOff, safeIpcSend } from '@/utils/ipc'; import { safeIpcInvoke, safeIpcOn, safeIpcSend } from '@/utils/ipc';
// ---------- 类型 ---------- // ---------- 类型 ----------
export type UpdaterStatus = export type UpdaterStatus =
| 'idle' // 空闲 | 'idle'
| 'checking' // 正在检查 | 'checking'
| 'no-update' // 已是最新 | 'no-update'
| 'available' // 发现新版本,准备下载 | 'available'
| 'downloading' // 下载中 | 'downloading'
| 'downloaded' // 下载完成,待安装 | 'downloaded'
| 'error'; // 出错 | 'error';
export interface UpdateInfo { export interface UpdateInfo {
version: string; version: string;
@@ -40,6 +45,43 @@ export interface UpdateError {
message: string; message: string;
} }
// ---------- 模块级共享状态 ----------
interface UpdaterState {
status: UpdaterStatus;
info: UpdateInfo | null;
progress: UpdateProgress;
error: UpdateError | null;
}
type Subscriber = (state: UpdaterState) => void;
let sharedState: UpdaterState = {
status: 'idle',
info: null,
progress: { percent: 0 },
error: null,
};
const subscribers = new Set<Subscriber>();
let listenersRegistered = false;
function notifyAll(): void {
const snapshot = { ...sharedState };
subscribers.forEach((fn) => {
try {
fn(snapshot);
} catch {
/* 单个订阅者异常不影响其他 */
}
});
}
function setSharedState(patch: Partial<UpdaterState>): void {
sharedState = { ...sharedState, ...patch };
notifyAll();
}
// ---------- IPC 通道名(与主进程 updater.ts 保持一致)---------- // ---------- IPC 通道名(与主进程 updater.ts 保持一致)----------
const CH = { const CH = {
@@ -50,64 +92,90 @@ const CH = {
UPDATE_NOT_AVAILABLE: 'update-not-available', UPDATE_NOT_AVAILABLE: 'update-not-available',
INSTALL_UPDATE: 'install-update', INSTALL_UPDATE: 'install-update',
CHECK_FOR_UPDATE: 'check-for-update', CHECK_FOR_UPDATE: 'check-for-update',
DOWNLOAD_UPDATE: 'download-update',
} as const; } as const;
// ---------- 注册 IPC 监听器(模块级,仅一次)----------
function ensureListeners(): void {
if (listenersRegistered) return;
listenersRegistered = true;
safeIpcOn(CH.UPDATE_AVAILABLE, (_event: unknown, ...args: unknown[]) => {
setSharedState({ info: args[0] as UpdateInfo, status: 'available' });
});
safeIpcOn(CH.UPDATE_PROGRESS, (_event: unknown, ...args: unknown[]) => {
setSharedState({ status: 'downloading', progress: args[0] as UpdateProgress });
});
safeIpcOn(CH.UPDATE_DOWNLOADED, () => {
setSharedState({ status: 'downloaded' });
});
safeIpcOn(CH.UPDATE_ERROR, (_event: unknown, ...args: unknown[]) => {
setSharedState({ error: args[0] as UpdateError, status: 'error' });
});
safeIpcOn(CH.UPDATE_NOT_AVAILABLE, () => {
setSharedState({ status: 'no-update' });
});
}
// ---------- Hook ---------- // ---------- Hook ----------
export function useUpdater() { export function useUpdater() {
const [status, setStatus] = useState<UpdaterStatus>('idle'); const [state, setState] = useState<UpdaterState>(sharedState);
const [info, setInfo] = useState<UpdateInfo | null>(null); const subscribed = useRef(false);
const [progress, setProgress] = useState<UpdateProgress>({ percent: 0 });
const [error, setError] = useState<UpdateError | null>(null);
// 防止 StrictMode 双重监听
const registered = useRef(false);
// 确保 IPC 监听器只注册一次
useEffect(() => { useEffect(() => {
if (registered.current) return; ensureListeners();
registered.current = true; }, []);
safeIpcOn(CH.UPDATE_AVAILABLE, (_event: unknown, ...args: unknown[]) => { // 订阅模块级状态变更
setInfo(args[0] as UpdateInfo); useEffect(() => {
setStatus('available'); if (subscribed.current) return;
}); subscribed.current = true;
safeIpcOn(CH.UPDATE_PROGRESS, (_event: unknown, ...args: unknown[]) => { const sub: Subscriber = (newState) => setState(newState);
setStatus('downloading'); subscribers.add(sub);
setProgress(args[0] as UpdateProgress);
});
safeIpcOn(CH.UPDATE_DOWNLOADED, () => {
setStatus('downloaded');
});
safeIpcOn(CH.UPDATE_ERROR, (_event: unknown, ...args: unknown[]) => {
setError(args[0] as UpdateError);
setStatus('error');
});
safeIpcOn(CH.UPDATE_NOT_AVAILABLE, () => {
setStatus('no-update');
});
return () => { return () => {
safeIpcOff(CH.UPDATE_AVAILABLE, () => {}); subscribers.delete(sub);
safeIpcOff(CH.UPDATE_PROGRESS, () => {}); subscribed.current = false;
safeIpcOff(CH.UPDATE_DOWNLOADED, () => {});
safeIpcOff(CH.UPDATE_ERROR, () => {});
safeIpcOff(CH.UPDATE_NOT_AVAILABLE, () => {});
}; };
}, []); }, []);
// 同步初始状态(防止在订阅之前状态已变更)
useEffect(() => {
setState(sharedState);
}, []);
/** 手动检查更新 */ /** 手动检查更新 */
const checkForUpdates = useCallback(async () => { const checkForUpdates = useCallback(async () => {
setStatus('checking'); console.log("process.env.UPDATE_API_URL:", process.env.UPDATE_API_URL);
setError(null); setSharedState({ status: 'checking', error: null });
try { try {
await safeIpcInvoke(CH.CHECK_FOR_UPDATE); await safeIpcInvoke(CH.CHECK_FOR_UPDATE);
} catch (err) { } catch (err) {
setStatus('error'); setSharedState({
setError({ message: (err as Error).message || '检查更新失败' }); status: 'error',
error: { message: (err as Error).message || '检查更新失败' },
});
}
}, []);
/** 用户确认后开始下载更新 */
const downloadUpdate = useCallback(async () => {
setSharedState({ status: 'downloading', error: null });
try {
await safeIpcInvoke(CH.DOWNLOAD_UPDATE);
} catch (err) {
setSharedState({
status: 'error',
error: { message: (err as Error).message || '下载更新失败' },
});
} }
}, []); }, []);
@@ -118,18 +186,21 @@ export function useUpdater() {
/** 重置状态 */ /** 重置状态 */
const reset = useCallback(() => { const reset = useCallback(() => {
setStatus('idle'); setSharedState({
setInfo(null); status: 'idle',
setProgress({ percent: 0 }); info: null,
setError(null); progress: { percent: 0 },
error: null,
});
}, []); }, []);
return { return {
status, status: state.status,
info, info: state.info,
progress, progress: state.progress,
error, error: state.error,
checkForUpdates, checkForUpdates,
downloadUpdate,
installUpdate, installUpdate,
reset, reset,
} as const; } as const;

View File

@@ -5,6 +5,7 @@ import { App as AntdApp } from 'antd';
import App from './App'; import App from './App';
import { ThemeProvider } from './components/ThemeProvider'; import { ThemeProvider } from './components/ThemeProvider';
import { AppProvider } from './components/AppProvider'; import { AppProvider } from './components/AppProvider';
import { SettingsProvider } from './components/SettingsProvider';
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置) // 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
import './theme/globals.css'; import './theme/globals.css';
@@ -14,10 +15,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<AppProvider> <AppProvider>
{/* ThemeProvider 内部管理 ConfigProvider亮色/暗色切换) */} {/* ThemeProvider 内部管理 ConfigProvider亮色/暗色切换) */}
<ThemeProvider> <ThemeProvider>
{/* SettingsProvider — 集中管理设置项(存储路径等) */}
<SettingsProvider>
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */} {/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
<AntdApp> <AntdApp>
<App /> <App />
</AntdApp> </AntdApp>
</SettingsProvider>
</ThemeProvider> </ThemeProvider>
</AppProvider> </AppProvider>
</React.StrictMode>, </React.StrictMode>,
@@ -28,3 +32,22 @@ import { safeIpcOn } from './utils/ipc';
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => { safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
console.log('[Main Process Message]:', message); console.log('[Main Process Message]:', message);
}); });
// 渲染进程全局未捕获错误(兜底记录到日志文件)
import { logger } from './utils/logger';
window.addEventListener('error', (event) => {
logger.error('ui', 'Uncaught error (renderer)', event.error, {
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
});
});
window.addEventListener('unhandledrejection', (event) => {
const error =
event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
logger.error('ui', 'Unhandled promise rejection (renderer)', error);
});

277
src/pages/home/HomePage.tsx Normal file
View File

@@ -0,0 +1,277 @@
// ============================================================
// HomePage — 首页仪表盘(从 App.tsx 提取)
// ============================================================
import { useState } from 'react';
import {
Button,
Card,
Space,
Tag,
Typography,
Switch as AntSwitch,
Slider,
Progress,
theme as antTheme,
} from 'antd';
import {
ApiOutlined,
ThunderboltOutlined,
BulbOutlined,
BulbFilled,
DownloadOutlined,
SyncOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
RocketOutlined,
} from '@ant-design/icons';
import { getPlatformName, isDev } from '@/utils/platform';
import { gradientPrimary } from '@/theme/tokens';
import { useTheme } from '@/hooks/use-theme';
import { useUpdater } from '@/hooks/use-updater';
import { useAppContext } from '@/contexts/app-context';
const { Title, Text } = Typography;
export function HomePage() {
const { isDark, toggleTheme } = useTheme();
const { platform, edition, isLoggedIn, logout } = useAppContext();
const { token } = antTheme.useToken();
const [progress, setProgress] = useState(65);
const {
status: updateStatus,
progress: updateProgress,
info: updateInfo,
error: updateError,
checkForUpdates,
installUpdate,
} = useUpdater();
return (
<div className="flex-1 p-8">
<div className="mx-auto max-w-4xl space-y-6">
{/* ---- 标题区 + 主题开关 ---- */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-4">
<Title level={1} className="mb-0! gradient-primary-text">
HeiXiu ·
</Title>
<AntSwitch
checked={isDark}
onChange={toggleTheme}
checkedChildren={<BulbFilled />}
unCheckedChildren={<BulbOutlined />}
title={isDark ? '切换亮色模式' : '切换暗色模式'}
/>
</div>
<Text type="secondary">
Electron + React 19 + TypeScript + Ant Design + Tailwind CSS
</Text>
<br />
<Space size={4}>
<Tag color={isDark ? 'blue' : 'purple'}>
{isDark ? '🌙 暗色' : '☀️ 亮色'}
</Tag>
<Tag color={edition === 'enterprise' ? 'gold' : 'green'}>
{edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
{isLoggedIn ? (
<>
<Tag color="blue"></Tag>
<Button size="small" type="link" danger onClick={logout}>
退
</Button>
</>
) : (
<Tag color="default"></Tag>
)}
</Space>
</div>
{/* ---- 导航栏状态提示 ---- */}
<Card size="small" variant="borderless" className="rounded-lg">
<Text type="secondary" className="text-xs">
💡 使 <Text strong>H5 </Text>
{platform === 'darwin'
? ' macOS 保留了最小 Edit 菜单以支持 Cmd+C/V 等快捷键。'
: ' Windows/Linux 已完全移除系统菜单栏。'}
</Text>
</Card>
{/* ---- 主题色预览 ---- */}
<Card
title="🎨 蓝紫渐变主题色预览"
variant="borderless"
className="rounded-lg shadow-lg"
>
<Space orientation="vertical" size="large" className="w-full">
{/* 渐变展示 */}
<div>
<Text strong></Text>
<div className="flex gap-3 mt-2">
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: gradientPrimary }}
>
135°
</div>
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: 'linear-gradient(90deg, #4F46E5, #7C3AED)' }}
>
90°
</div>
</div>
</div>
{/* 组件示例 */}
<div>
<Text strong>Ant Design </Text>
<Space wrap className="mt-2">
<Button type="primary" icon={<ThunderboltOutlined />}>
</Button>
<Button></Button>
<Button type="dashed">线</Button>
<Button type="text"></Button>
<Button type="link"></Button>
</Space>
</div>
{/* 滑块 + 进度条 */}
<div>
<Text strong></Text>
<div className="flex items-center gap-4 mt-2">
<span className="text-sm" style={{ color: token.colorTextSecondary }}>
:
</span>
<Slider
className="flex-1"
value={progress}
onChange={setProgress}
min={0}
max={100}
/>
<Progress
type="circle"
percent={progress}
size={48}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
</div>
</div>
</Space>
</Card>
{/* ---- 平台信息 ---- */}
<Card title="🖥️ 系统信息" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">{getPlatformName()}</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color={isDev() ? 'blue' : 'green'}>
{isDev() ? '开发模式' : '生产模式'}
</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">
H5 {edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
</div>
</Space>
</Card>
{/* ---- 版本更新 ---- */}
<Card title="🔄 版本更新" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
{/* 状态提示 */}
{updateStatus === 'checking' && (
<div className="flex items-center gap-2">
<SyncOutlined spin style={{ color: token.colorPrimary }} />
<Text type="secondary">...</Text>
</div>
)}
{updateStatus === 'no-update' && (
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text type="success"></Text>
</div>
)}
{updateStatus === 'error' && (
<div className="flex items-center gap-2">
<ExclamationCircleOutlined style={{ color: token.colorError }} />
<Text type="danger">{updateError?.message || '检查更新失败'}</Text>
</div>
)}
{/* 发现新版本 */}
{(updateStatus === 'available' || updateStatus === 'downloading') && (
<>
<div className="flex items-center gap-2">
<RocketOutlined style={{ color: token.colorPrimary }} />
<Text strong>{updateInfo?.version}</Text>
{updateInfo?.forceUpdate && <Tag color="red"></Tag>}
</div>
{updateInfo?.releaseNotes && (
<Text type="secondary" className="text-xs whitespace-pre-line">
{updateInfo.releaseNotes}
</Text>
)}
</>
)}
{/* 下载进度 */}
{updateStatus === 'downloading' && (
<Progress
percent={updateProgress.percent}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
)}
{/* 下载完成 */}
{updateStatus === 'downloaded' && (
<>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text></Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={installUpdate}
>
</Button>
</>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button
icon={<SyncOutlined spin={updateStatus === 'checking'} />}
onClick={checkForUpdates}
disabled={updateStatus === 'checking'}
>
</Button>
{isDev() && (
<Text type="secondary" className="text-xs">
5
</Text>
)}
</div>
</Space>
</Card>
</div>
</div>
);
}

View File

@@ -1,6 +1,12 @@
// ============================================================ // ============================================================
// useAuthState — 记住密码 / 自动登录 状态管理 // useAuthState — 记住账号 / 自动登录 状态管理
// 持久化到 localStorage下次打开页面自动回填 //
// "记住密码":下次打开页面自动回填用户名(不再存储密码)
// "自动登录"登录成功时标记AppProvider 启动时用 refresh_token 静默续期
//
// 安全:
// - 密码从不持久化存储access_token / refresh_token 由 safeStorage 加密保护)
// - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障
// ============================================================ // ============================================================
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
@@ -8,33 +14,14 @@ import { useState, useCallback } from 'react';
const STORAGE_KEY = 'ele-heixiu-auth'; const STORAGE_KEY = 'ele-heixiu-auth';
interface StoredAuth { interface StoredAuth {
/** 上次登录的用户名 */ /** 上次登录的用户名(表单回填用) */
username: string; username: string;
/** 加密后的密码Base64 编码,非安全加密,仅方便回填 */ /** 是否记住用户名(勾选后表单自动回填用户名 */
password: string;
/** 是否记住密码 */
remember: boolean; remember: boolean;
/** 是否自动登录 */ /** 是否勾选了自动登录AppProvider 据此决定启动时是否刷新 token */
autoLogin: boolean; autoLogin: boolean;
} }
/** 简单 Base64 编解码(非安全加密,仅避免明文存储) */
function encode(str: string): string {
try {
return btoa(decodeURI(encodeURIComponent(str)));
} catch {
return '';
}
}
function decode(str: string): string {
try {
return decodeURIComponent(decodeURI(atob(str)));
} catch {
return '';
}
}
/** 从 localStorage 读取已保存的认证信息 */
function readAuth(): StoredAuth | null { function readAuth(): StoredAuth | null {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
@@ -45,7 +32,6 @@ function readAuth(): StoredAuth | null {
} }
} }
/** 写入 localStorage */
function writeAuth(auth: StoredAuth): void { function writeAuth(auth: StoredAuth): void {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth)); localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
@@ -54,8 +40,22 @@ function writeAuth(auth: StoredAuth): void {
} }
} }
/** 清除 localStorage */ // ---------- 工具函数(不依赖 ReactAppProvider 可直接调用)----------
function clearAuth(): void {
/** 读取自动登录标记 */
export function getAutoLoginFlag(): boolean {
return readAuth()?.autoLogin || false;
}
/** 仅清除自动登录标记(保留用户名,下次还能回填表单) */
export function clearAutoLoginFlag(): void {
const auth = readAuth();
if (!auth) return;
writeAuth({ ...auth, autoLogin: false });
}
/** 清除所有保存的认证信息 */
export function clearSavedAuth(): void {
try { try {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} catch { } catch {
@@ -66,66 +66,55 @@ function clearAuth(): void {
// ---------- Hook ---------- // ---------- Hook ----------
export function useAuthState() { export function useAuthState() {
// 初始化:从 localStorage 恢复 const saved = readAuth();
const [username, setUsername] = useState(() => readAuth()?.username || '');
const [password, setPassword] = useState(() => { const [username, setUsername] = useState(saved?.username || '');
const auth = readAuth(); const [remember, setRemember] = useState(saved?.remember || false);
return auth?.remember ? decode(auth.password) : ''; const [autoLogin, setAutoLogin] = useState(saved?.autoLogin || false);
});
const [remember, setRemember] = useState(() => readAuth()?.remember || false);
const [autoLogin, setAutoLogin] = useState(() => readAuth()?.autoLogin || false);
// 勾选"记住密码"变化时持久化
const handleRememberChange = useCallback((checked: boolean) => { const handleRememberChange = useCallback((checked: boolean) => {
setRemember(checked); setRemember(checked);
if (!checked) { if (!checked) {
// 取消记住密码 → 同时取消自动登录
setAutoLogin(false); setAutoLogin(false);
clearAuth(); clearSavedAuth();
} }
}, []); }, []);
// 勾选"自动登录"变化时持久化
const handleAutoLoginChange = useCallback((checked: boolean) => { const handleAutoLoginChange = useCallback((checked: boolean) => {
setAutoLogin(checked); setAutoLogin(checked);
}, []); }, []);
// 登录成功后保存 /**
* 登录成功后调用。
* 参数由调用方LoginPage直接从表单值传入避免 useCallback 的 stale closure。
*
* @param user - 用户名
* @param _password - 明文密码(不再持久化存储,保留参数以兼容调用方)
* @param doRemember - 是否勾选"记住用户名"
* @param doAutoLogin - 是否勾选"自动登录"
*/
const saveAuth = useCallback( const saveAuth = useCallback(
(user: string, pass: string) => { (user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => {
setUsername(user); setUsername(user);
if (remember) { if (doRemember || doAutoLogin) {
setPassword(pass); const payload: StoredAuth = {
writeAuth({
username: user, username: user,
password: encode(pass), remember: doRemember,
remember, autoLogin: doAutoLogin,
autoLogin, };
}); writeAuth(payload);
} }
}, },
[remember, autoLogin], [],
); );
// 清除保存的认证信息
const clearSavedAuth = useCallback(() => {
setUsername('');
setPassword('');
setRemember(false);
setAutoLogin(false);
clearAuth();
}, []);
return { return {
username, username,
password,
remember, remember,
autoLogin, autoLogin,
setUsername, setUsername,
setPassword,
handleRememberChange, handleRememberChange,
handleAutoLoginChange, handleAutoLoginChange,
saveAuth, saveAuth,
clearSavedAuth,
}; };
} }

View File

@@ -15,6 +15,8 @@ import { useTheme } from '@/hooks/use-theme.ts';
import { LoginForm } from './components/LoginForm'; import { LoginForm } from './components/LoginForm';
import { RegisterForm } from './components/RegisterForm'; import { RegisterForm } from './components/RegisterForm';
import { useAuthState } from './hooks/use-auth-state'; import { useAuthState } from './hooks/use-auth-state';
import {AuthAPI, LoginResponseBody} from '@/services/modules';
import { getDeviceId } from '@/utils/device';
import type { LoginFormValues, RegisterFormValues } from './types'; import type { LoginFormValues, RegisterFormValues } from './types';
const { Text } = Typography; const { Text } = Typography;
@@ -41,14 +43,23 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
async (values: LoginFormValues) => { async (values: LoginFormValues) => {
setSubmitting(true); setSubmitting(true);
try { try {
// TODO: 接入登录 API const auth:LoginResponseBody = await AuthAPI.login({
console.log('[Login]', values); username: values.username,
authState.saveAuth(values.username, values.password); password: values.password,
message.success('登录成功Demo'); device_id: getDeviceId(),
login(); user_ip: '',
});
// 记住偏好(用户名、自动登录标记,密码不再持久化)
authState.saveAuth(values.username, values.password, values.remember, values.autoLogin);
// 更新全局登录态(内部完成 setToken + emit LOGIN_SUCCESS
await login(auth);
message.success(`登录成功,欢迎 ${auth.display_name || auth.username}`);
onClose(); onClose();
} catch (err) { } catch (err) {
message.error((err as Error).message || '登录失败'); message.error((err as Error).message || '登录失败,请检查用户名和密码');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -62,8 +73,17 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
async (values: RegisterFormValues) => { async (values: RegisterFormValues) => {
setSubmitting(true); setSubmitting(true);
try { try {
console.log('[Register]', values); await AuthAPI.register({
message.success('注册成功Demo请登录'); username: values.username,
password: values.password,
phone: values.phone,
sms_code: values.sms_code,
device_id: values.device_id || getDeviceId(),
device_label: values.device_label || '',
display_name: values.display_name || '',
referral_code: values.referral_code || '',
});
message.success('注册成功,请登录');
setActiveTab('login'); setActiveTab('login');
} catch (err) { } catch (err) {
message.error((err as Error).message || '注册失败'); message.error((err as Error).message || '注册失败');
@@ -75,9 +95,13 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
); );
const handleSendSms = useCallback( const handleSendSms = useCallback(
(phone: string) => { async (phone: string) => {
console.log('[SMS]', phone); try {
message.info(`验证码已发送到 ${phone}Demo`); await AuthAPI.sendSms({ phone });
message.success('验证码已发送');
} catch (err) {
message.error((err as Error).message || '验证码发送失败');
}
}, },
[message], [message],
); );
@@ -87,7 +111,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
const loginTab = ( const loginTab = (
<LoginForm <LoginForm
initialUsername={authState.username} initialUsername={authState.username}
initialPassword={authState.password}
initialRemember={authState.remember} initialRemember={authState.remember}
initialAutoLogin={authState.autoLogin} initialAutoLogin={authState.autoLogin}
submitting={submitting} submitting={submitting}

View File

@@ -0,0 +1,55 @@
// ============================================================
// SettingsPage — 设置页面(路由命中 /settings 时以居中 Modal 叠加展示)
//
// 核心设计:
// - 独立于 <Routes> 之外,通过 useLocation 判断是否渲染
// - 命中 /settings → Modal 居中弹出,首页内容保持在背景不动
// - 关闭 Modal → navigate(-1),仅可通过 X 按钮关闭
// - destroyOnClose → 关闭即销毁 DOM不堆内存
// ============================================================
import {Modal} from 'antd';
import {SettingOutlined} from '@ant-design/icons';
import {useLocation, useNavigate} from 'react-router-dom';
import {useAppContext} from '@/contexts/app-context';
import {ThemeSetting} from './ThemeSetting';
import {StoragePathSetting} from './StoragePathSetting';
import {SystemInfoSetting} from './SystemInfoSetting';
import {UpdateSetting} from './UpdateSetting';
export function SettingsPage() {
const location = useLocation();
const navigate = useNavigate();
const {edition} = useAppContext();
const open = location.pathname === '/settings';
return (
<Modal
open={open}
width={480}
onCancel={() => navigate(-1)}
destroyOnHidden={true}
mask={
{
closable: false
}
}
keyboard={false}
footer={null}
title={
<span className="flex items-center gap-2">
<SettingOutlined />
</span>
}
styles={{body: {padding: 0, maxHeight: '70vh', overflow: 'auto'}}}
>
<div className="flex flex-col gap-4 p-4">
<ThemeSetting />
<StoragePathSetting edition={edition} />
<SystemInfoSetting />
<UpdateSetting />
</div>
</Modal>
);
}

View File

@@ -0,0 +1,188 @@
// ============================================================
// StoragePathSetting — 存储路径设置区块
//
// 个人版:仅"大模型产物存储仓库路径"
// 企业版:额外"团队创作存储仓库路径"
// ============================================================
import { useState, useCallback } from 'react';
import { Card, Button, Input, Typography, Space, App } from 'antd';
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
import type { Edition } from '@shared/types';
import { useSettings } from '@/contexts/settings-context';
import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
const { Text, Paragraph } = Typography;
interface StoragePathSettingProps {
edition: Edition;
}
/** 单行路径选择器 */
function PathRow({
label,
path,
onChange,
onClear,
}: {
label: string;
path: string;
onChange: (path: string) => void;
onClear: () => void;
}) {
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(path);
const { message } = App.useApp();
const handleSelectFolder = useCallback(async () => {
if (!hasIpc()) {
// 非 Electron 环境 → 切换为手动输入模式
setInputValue(path);
setEditing(true);
return;
}
try {
const result = (await safeIpcInvoke(BIDIRECTIONAL.FILE_DIALOG, {
title: label,
defaultPath: path || undefined,
properties: ['openDirectory'],
})) as { canceled: boolean; filePaths: string[] } | undefined;
if (!result || result.canceled || result.filePaths.length === 0) return;
const selectedPath = result.filePaths[0];
onChange(selectedPath);
message.success('路径已更新');
} catch {
message.error('选择文件夹失败');
}
}, [label, path, onChange, message]);
const handleSaveInput = useCallback(() => {
onChange(inputValue.trim());
setEditing(false);
if (inputValue.trim()) {
message.success('路径已更新');
}
}, [inputValue, onChange, message]);
const handleCancelEdit = useCallback(() => {
setInputValue(path);
setEditing(false);
}, [path]);
return (
<div className="flex flex-col gap-1.5">
<Text type="secondary" className="text-xs">
{label}
</Text>
{editing ? (
<Space.Compact className="w-full">
<Input
size="small"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={handleSaveInput}
placeholder="请输入或粘贴路径"
className="flex-1"
/>
<Button size="small" type="primary" onClick={handleSaveInput}>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
) : path ? (
<div className="flex items-center gap-2">
<Paragraph
className="mb-0! flex-1 min-w-0"
style={{ maxWidth: 240 }}
ellipsis={{ rows: 1, tooltip: path }}
>
<Text className="text-xs" style={{ fontFamily: 'monospace' }}>
{path}
</Text>
</Paragraph>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => {
setInputValue(path);
setEditing(true);
}}
title="手动编辑"
/>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
onClick={onClear}
title="清除路径"
/>
</div>
) : (
<div className="flex items-center gap-2">
<Text type="secondary" className="text-xs italic flex-1">
</Text>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => {
setInputValue('');
setEditing(true);
}}
title="手动输入"
/>
</div>
)}
{!editing && (
<Button
size="small"
icon={<FolderOpenOutlined />}
onClick={handleSelectFolder}
className="self-start mt-1"
>
{hasIpc() ? '选择文件夹' : '手动输入路径'}
</Button>
)}
</div>
);
}
export function StoragePathSetting({ edition }: StoragePathSettingProps) {
const { outputPath, teamRepoPath, setOutputPath, setTeamRepoPath, clearOutputPath, clearTeamRepoPath } =
useSettings();
const isEnterprise = edition === 'enterprise';
return (
<Card size="small" title="存储路径设置" className="rounded-md!">
<div className="flex flex-col gap-4">
<PathRow
label="大模型产物存储仓库路径"
path={outputPath}
onChange={setOutputPath}
onClear={clearOutputPath}
/>
{isEnterprise && (
<PathRow
label="团队创作存储仓库路径"
path={teamRepoPath}
onChange={setTeamRepoPath}
onClear={clearTeamRepoPath}
/>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,65 @@
// ============================================================
// SystemInfoSetting — 系统信息区块(只读展示)
// ============================================================
import { Card, Tag, Typography } from 'antd';
import {
WindowsOutlined,
AppleOutlined,
InfoCircleOutlined,
EnvironmentOutlined,
} from '@ant-design/icons';
import { useAppContext } from '@/contexts/app-context';
import { getPlatformName } from '@/utils/platform';
import { APP_VERSION } from '@shared/constants/version';
import { getEditionName } from '@shared/constants/app';
const { Text } = Typography;
export function SystemInfoSetting() {
const { platform, edition, isDevMode } = useAppContext();
const platformIcon =
platform === 'darwin' ? (
<AppleOutlined />
) : platform === 'win32' ? (
<WindowsOutlined />
) : (
<InfoCircleOutlined />
);
const infoItems = [
{ label: '运行平台', value: getPlatformName(), icon: platformIcon, color: 'purple' },
{ label: '应用版本', value: `V${APP_VERSION}`, icon: <InfoCircleOutlined />, color: 'blue' },
{
label: '版本类型',
value: getEditionName(edition),
icon: <InfoCircleOutlined />,
color: edition === 'enterprise' ? 'gold' : 'green',
},
{
label: '运行环境',
value: isDevMode ? '开发模式' : '生产模式',
icon: <EnvironmentOutlined />,
color: isDevMode ? 'orange' : 'green',
},
];
return (
<Card size="small" title="系统信息" className="rounded-md!">
<div className="flex flex-col gap-2">
{infoItems.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span style={{ color: 'var(--color-primary, #4F46E5)', fontSize: 14 }}>
{item.icon}
</span>
<Text type="secondary" className="text-sm min-w-[64px]">
{item.label}
</Text>
<Tag color={item.color}>{item.value}</Tag>
</div>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,34 @@
// ============================================================
// ThemeSetting — 主题切换区块
// ============================================================
import { Card, Segmented, Typography } from 'antd';
import { BulbOutlined, BulbFilled } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme';
const { Text } = Typography;
export function ThemeSetting() {
const { isDark, setLight, setDark } = useTheme();
return (
<Card size="small" title="主题设置" className="rounded-md!">
<div className="flex items-center justify-between">
<Text type="secondary" className="text-sm">
{isDark ? '🌙 当前使用暗色模式' : '☀️ 当前使用亮色模式'}
</Text>
<Segmented
value={isDark ? 'dark' : 'light'}
onChange={(val) => {
if (val === 'dark') setDark();
else setLight();
}}
options={[
{ label: '亮色', value: 'light', icon: <BulbOutlined /> },
{ label: '暗色', value: 'dark', icon: <BulbFilled /> },
]}
/>
</div>
</Card>
);
}

View File

@@ -0,0 +1,226 @@
// ============================================================
// UpdateSetting — 版本更新区块
// ============================================================
import { useState } from 'react';
import { Card, Button, Typography, Progress, Tag, Modal, Divider } from 'antd';
import {
SyncOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
RocketOutlined,
DownloadOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { useUpdater } from '@/hooks/use-updater';
import { isDev } from '@/utils/platform';
const { Text, Title } = Typography;
/** 简单渲染 changelogMarkdown → JSX支持 ## / - / --- */
function renderChangelog(markdown: string) {
const lines = markdown.split('\n');
const elements: React.ReactNode[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const key = `cl-${i}`;
if (line.startsWith('## ')) {
elements.push(
<Title key={key} level={5} className="!mt-3! !mb-1!">
{line.slice(3)}
</Title>,
);
continue;
}
if (/^-{3,}$/.test(line.trim())) {
elements.push(<Divider key={key} className="!my-2!" />);
continue;
}
if (line.trim().startsWith('- ')) {
elements.push(
<Text key={key} className="block pl-3 text-sm leading-relaxed">
{line.trim().slice(2)}
</Text>,
);
continue;
}
if (line.trim() === '') {
elements.push(<div key={key} className="h-2" />);
continue;
}
elements.push(
<Text key={key} className="block text-sm leading-relaxed">
{line}
</Text>,
);
}
return elements.length > 0 ? elements : <Text type="secondary"></Text>;
}
export function UpdateSetting() {
const {
status,
info,
progress,
error,
checkForUpdates,
downloadUpdate,
installUpdate,
} = useUpdater();
const [detailOpen, setDetailOpen] = useState(false);
return (
<Card size="small" title="版本更新" className="rounded-md!">
<div className="flex flex-col gap-3">
{/* ---- idle ---- */}
{status === 'idle' && (
<Text type="secondary" className="text-sm">
</Text>
)}
{/* ---- checking ---- */}
{status === 'checking' && (
<div className="flex items-center gap-2">
<SyncOutlined spin style={{ color: 'var(--color-primary, #4F46E5)' }} />
<Text type="secondary">...</Text>
</div>
)}
{/* ---- no-update ---- */}
{status === 'no-update' && (
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: 'var(--color-success, #52c41a)' }} />
<Text style={{ color: 'var(--color-success, #52c41a)' }}></Text>
</div>
)}
{/* ---- error ---- */}
{status === 'error' && (
<div className="flex items-center gap-2">
<ExclamationCircleOutlined style={{ color: 'var(--color-error, #ff4d4f)' }} />
<Text type="danger">{error?.message || '检查更新失败'}</Text>
</div>
)}
{/* ---- 发现新版本(等待用户决定)---- */}
{status === 'available' && (
<>
<div className="flex items-center gap-2">
<RocketOutlined style={{ color: 'var(--color-primary, #4F46E5)' }} />
<Text strong>{info?.version}</Text>
{info?.forceUpdate && <Tag color="red"></Tag>}
</div>
{info?.releaseNotes && (
<div className="flex items-start gap-2">
<Text
type="secondary"
className="text-xs whitespace-pre-line line-clamp-2 flex-1"
>
{info.releaseNotes
.replace(/^## .+?\n\n?/, '')
.replace(/\n---\n?[\s\S]*$/, '')
.trim()}
</Text>
<Button
type="link"
size="small"
icon={<FileTextOutlined />}
onClick={() => setDetailOpen(true)}
className="!px-1! flex-shrink-0"
>
</Button>
</div>
)}
<Button
type="primary"
size="small"
icon={<DownloadOutlined />}
onClick={downloadUpdate}
className="self-start"
>
</Button>
</>
)}
{/* ---- 下载中 ---- */}
{status === 'downloading' && (
<>
<div className="flex items-center gap-2">
<DownloadOutlined style={{ color: 'var(--color-primary, #4F46E5)' }} />
<Text> v{info?.version}...</Text>
</div>
<Progress
percent={progress.percent}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
size="small"
/>
</>
)}
{/* ---- 下载完成 ---- */}
{status === 'downloaded' && (
<>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: 'var(--color-success, #52c41a)' }} />
<Text></Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={installUpdate}
size="small"
className="self-start"
>
</Button>
</>
)}
{/* ---- 操作按钮 ---- */}
<div className="flex items-center gap-3">
<Button
size="small"
icon={<SyncOutlined spin={status === 'checking'} />}
onClick={checkForUpdates}
disabled={status === 'checking' || status === 'downloading'}
>
</Button>
{isDev() && (
<Text type="secondary" className="text-xs">
5
</Text>
)}
</div>
</div>
{/* ---- 更新详情弹窗 ---- */}
<Modal
title={`更新详情 — v${info?.version || ''}`}
open={detailOpen}
onCancel={() => setDetailOpen(false)}
footer={
<Button onClick={() => setDetailOpen(false)}></Button>
}
width={520}
>
<div className="max-h-96 overflow-y-auto py-2">
{info?.releaseNotes
? renderChangelog(info.releaseNotes)
: <Text type="secondary"></Text>}
</div>
</Modal>
</Card>
);
}

View File

@@ -0,0 +1,9 @@
// ============================================================
// Settings barrel export
// ============================================================
export { SettingsPage } from './SettingsPage';
export { ThemeSetting } from './ThemeSetting';
export { StoragePathSetting } from './StoragePathSetting';
export { SystemInfoSetting } from './SystemInfoSetting';
export { UpdateSetting } from './UpdateSetting';

26
src/router/index.tsx Normal file
View File

@@ -0,0 +1,26 @@
// ============================================================
// AppRoutes — 应用路由配置(由 App.tsx 在 HashRouter 内使用)
//
// 核心设计:
// - SettingsPage 在 <Routes> 外部,路由命中时以 Drawer 叠加首页
// - 首页始终保持挂载,不会因导航而卸载
// ============================================================
import { Routes, Route } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { HomePage } from '@/pages/home/HomePage';
import { SettingsPage } from '@/pages/settings';
export function AppRoutes() {
return (
<>
<Routes>
<Route path="/*" element={<Layout />}>
<Route index element={<HomePage />} />
</Route>
</Routes>
{/* 独立于 Routes根据 URL /settings 自动显隐 Drawer */}
<SettingsPage />
</>
);
}

View File

@@ -0,0 +1,81 @@
// ============================================================
// auth-token — Token 生命周期管理(存取 refresh_token + 注册刷新回调)
//
// 职责:
// - 持久化 / 清除 refresh_token通过 safeStorage 加密)
// - 向 request.ts 注册 handle401 触发的 token 刷新回调
// - request.ts 不依赖此模块(通过 setTokenRefreshHandler 解耦)
//
// 安全:
// - refresh_token 由 Electron safeStorage 加密后存入 localStorage
// - 内存缓存供同步读取setTokenRefreshHandler 回调内使用)
// ============================================================
import { setToken, setTokenRefreshHandler } from './request';
import { AuthAPI } from './modules';
import type { RefreshTokenResponseBody } from './modules';
import {
getSafeStore,
setSafeStore,
clearSafeStore,
initSafeStore,
} from '../utils/safe-storage';
const REFRESH_KEY = 'ele-heixiu-refresh-token';
// ---------- 初始化 ----------
/**
* 启动时初始化 refresh_token从加密存储解密到内存缓存。
* 应在 AppProvider 挂载时调用。
*/
export async function initRefreshTokenStore(): Promise<void> {
await initSafeStore(REFRESH_KEY);
}
// ---------- 存取 ----------
/** 获取当前内存缓存中的 refresh_token同步需先调用 initRefreshTokenStore */
export function getStoredRefreshToken(): string | null {
return getSafeStore(REFRESH_KEY);
}
/** 设置 refresh_token异步加密存储 + 更新内存缓存) */
export async function setStoredRefreshToken(token: string): Promise<void> {
await setSafeStore(REFRESH_KEY, token);
}
/** 清除 refresh_token同步清除内存缓存 + localStorage */
export function clearStoredRefreshToken(): void {
clearSafeStore(REFRESH_KEY);
}
// ---------- 初始化App 启动时调用一次) ----------
/**
* 向 request.ts 注册 token 刷新回调。
* 当任意请求收到 401 时request.ts 会调用此回调尝试换新 token
* 成功后自动重试原请求,失败则 emit AUTH_REQUIRED。
*
* 应在 AppProvider 挂载时调用。
*/
export function initAuthRefresh(): void {
setTokenRefreshHandler(async (): Promise<string | null> => {
const refreshToken = getStoredRefreshToken();
if (!refreshToken) return null;
try {
const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken });
// 更新持久化 token加密存储
await setToken(res.access_token);
await setStoredRefreshToken(res.refresh_token);
return res.access_token;
} catch {
// 刷新失败 → 清除残留
clearStoredRefreshToken();
return null;
}
});
}

View File

@@ -5,6 +5,14 @@
import { get } from '../request'; import { get } from '../request';
// ============================================================
// AnnouncementUrlObj — 公告模块路由常量
// ============================================================
const AnnouncementUrlObj = {
list: '/api/v1/announcements',
} as const;
// ---------- 类型 ---------- // ---------- 类型 ----------
/** 公告类型枚举 */ /** 公告类型枚举 */
@@ -34,5 +42,5 @@ export type AnnouncementListResponse = Announcement[];
* GET /api/v1/announcements * GET /api/v1/announcements
*/ */
export function fetchAnnouncements(): Promise<AnnouncementListResponse> { export function fetchAnnouncements(): Promise<AnnouncementListResponse> {
return get<AnnouncementListResponse>('/api/v1/announcements'); return get<AnnouncementListResponse>(AnnouncementUrlObj.list);
} }

View File

@@ -0,0 +1,190 @@
import {post, get} from '../request';
// ============================================================
// AuthUrlObj — 认证模块路由常量
// 集中管理,后续接口升级 v2 等只需改此处
// ============================================================
const AuthUrlObj = {
login: '/api/v1/auth/login',
register: '/api/v1/auth/register',
sendSms: '/api/v1/auth/sms/send',
refreshToken: '/api/v1/auth/refresh',
userInfo: '/api/v1/auth/me',
changePassword: '/api/v1/auth/change-password',
resetPassword: '/api/v1/auth/reset-password',
logout: '/api/v1/auth/logout',
} as const;
// ============================================================
// 认证相关 DTO 传输模型
// ============================================================
/** 短信验证码请求体 */
export interface SendSMSRequestBody {
phone: string;
}
/** 注册请求体 */
export interface RegisterRequestBody {
// 用户名3~64 位,仅允许字母、数字、-、_
username: string;
// 密码6~128 位
password: string;
// 手机号5~32 位
phone: string;
// 短信验证码4~8 位
sms_code: string;
// 设备标识(网卡 MAC8~128 位
device_id: string;
// 设备标签/备注(可选)
device_label: string;
// 个人昵称(可选)
display_name: string;
// 邀请码(可选),填写后触发邀请佣金逻辑
referral_code: string;
}
export interface RegisterResponseBody {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: 0;
refresh_expires_in: 0;
user_id: string;
username: string;
role: "user" | "admin" | "superadmin";
email: string;
balance_cent: 0;
account_type: "individual" | "company" | "employee";
display_name: string;
company_id: string;
real_name: string;
phone: string;
license_code: string;
admin_permissions: string []
}
/** 登录请求体 */
export interface LoginRequestBody {
username: string;
password: string;
device_id: string;
user_ip: string;
}
export interface LoginResponseBody {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
refresh_expires_in: number;
user_id: string;
username: string;
role: "user" | "admin" | "superadmin";
email: string;
balance_cent: number;
account_type: "individual" | "company" | "employee";
display_name: string;
company_id: string;
real_name: string;
phone: string;
license_code: string;
admin_permissions: string[];
}
export interface RefreshTokenRequestBody {
refresh_token: string;
}
export interface RefreshTokenResponseBody {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
refresh_expires_in: number;
}
export interface UserInfoBody {
id: string;
username: string;
email: string;
balance_cent: number;
gift_balance_cent: number;
role: "user" | "admin" | "superadmin";
status: string;
account_type: "individual" | "company" | "employee";
display_name: string;
real_name: string;
phone: string;
company_id: string;
discount_rate: number;
subscription_plan: string;
subscription_expires_at: Date;
seat_limit: number;
last_login_at: Date;
created_at: Date;
model_package_id: number;
vip_level_id: number;
software_expires_at: Date;
referral_code: string;
admin_permissions: string[];
}
export interface ChangePasswordRequestBody {
current_password: string;
new_password: string;
}
export interface UsePhoneResetPasswordRequestBody {
phone: string;
sms_code: string;
new_password: string;
}
// ============================================================
// AuthAPI — 认证相关 API 静态方法类
// 调用方式AuthAPI.login() / AuthAPI.register() 等
// ============================================================
export class AuthAPI {
/** 登录 */
static login(data: LoginRequestBody): Promise<LoginResponseBody> {
return post<LoginResponseBody>(AuthUrlObj.login, data);
}
/** 注册 */
static register(data: RegisterRequestBody): Promise<RegisterResponseBody> {
return post<RegisterResponseBody>(AuthUrlObj.register, data);
}
/** 发送短信验证码 */
static sendSms(data: SendSMSRequestBody): Promise<void> {
return post<void>(AuthUrlObj.sendSms, data);
}
/** 刷新 Token */
static refreshToken(data: RefreshTokenRequestBody): Promise<RefreshTokenResponseBody> {
return post<RefreshTokenResponseBody>(AuthUrlObj.refreshToken, data);
}
/** 获取当前用户信息 */
static getUserInfo(): Promise<UserInfoBody> {
return get<UserInfoBody>(AuthUrlObj.userInfo);
}
/** 修改密码(已登录) */
static changePassword(data: ChangePasswordRequestBody): Promise<void> {
return post<void>(AuthUrlObj.changePassword, data);
}
/** 手机验证码重置密码 */
static resetPassword(data: UsePhoneResetPasswordRequestBody): Promise<void> {
return post<void>(AuthUrlObj.resetPassword, data);
}
/** 退出登录 */
static logout(): Promise<void> {
return post<void>(AuthUrlObj.logout);
}
}

View File

@@ -1,6 +1,5 @@
// ============================================================ // ============================================================
// API 模块统一导出 // API 模块统一导出
// 后续新增模块(如 auth、user、billing 等)在此追加
// ============================================================ // ============================================================
export { export {
@@ -9,3 +8,17 @@ export {
type AnnouncementType, type AnnouncementType,
type AnnouncementListResponse, type AnnouncementListResponse,
} from './announcement'; } from './announcement';
export {
AuthAPI,
type SendSMSRequestBody,
type RegisterRequestBody,
type RegisterResponseBody,
type LoginRequestBody,
type LoginResponseBody,
type RefreshTokenRequestBody,
type RefreshTokenResponseBody,
type UserInfoBody,
type ChangePasswordRequestBody,
type UsePhoneResetPasswordRequestBody,
} from './auth';

View File

@@ -16,46 +16,76 @@ import axios, {
type InternalAxiosRequestConfig, type InternalAxiosRequestConfig,
} from 'axios'; } from 'axios';
import type { ApiResponse } from './types'; import type { ApiResponse } from './types';
import { RequestError, RequestErrorType } from './types'; import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types';
import { emit, EVENTS } from '../utils/event-bus'; import { emit, EVENTS } from '../utils/event-bus';
import { logger } from '../utils/logger';
import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '../utils/safe-storage';
// ---------- 配置 ---------- // ---------- 配置 ----------
/** API 基础地址(可通过 Vite 环境变量覆盖) */ /** API 基础地址(可通过 Vite 环境变量覆盖) */
const BASE_URL = import.meta.env.VITE_API_BASE_URL; const BASE_URL = import.meta.env.VITE_API_BASE_URL;
/** 请求超时时间(毫秒) */ /** 请求超时时间(毫秒) */
const TIMEOUT = 30000; const TIMEOUT = 30_000;
/** localStorage 中 Token 的键名 */ /** localStorage 中 Token 的键名(密文由 safeStorage 保护) */
const TOKEN_KEY = 'ele-heixiu-token'; const TOKEN_KEY = 'ele-heixiu-token';
/** refresh token 接口路径(该接口 401 时不应触发 handle401否则死循环 */
const REFRESH_TOKEN_URL = '/api/v1/auth/refresh';
/** 判断请求是否为 refresh token 接口 */
function isRefreshRequest(config: InternalAxiosRequestConfig): boolean {
return config.url?.includes(REFRESH_TOKEN_URL) ?? false;
}
// ---------- Token 管理 ---------- // ---------- Token 管理 ----------
/** 获取存储的 Token */ /**
* 启动时初始化 Token从加密存储解密到内存缓存。
* 应在 AppProvider 挂载时调用login/logout 之前)。
*/
export async function initTokenStore(): Promise<void> {
await initSafeStore(TOKEN_KEY);
}
/** 获取当前内存缓存中的 Token同步高频安全 */
export function getToken(): string | null { export function getToken(): string | null {
try { return getSafeStore(TOKEN_KEY);
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
} }
/** 设置 Token */ /** 设置 Token(异步加密存储 + 更新内存缓存) */
export function setToken(token: string): void { export async function setToken(token: string): Promise<void> {
try { await setSafeStore(TOKEN_KEY, token);
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* 静默忽略 */
}
} }
/** 清除 Token */ /** 清除 Token(同步清除内存缓存 + localStorage */
export function clearToken(): void { export function clearToken(): void {
try { clearSafeStore(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
} catch {
/* 静默忽略 */
} }
// ---------- Token 刷新由外部注册request.ts 不依赖任何 auth 模块) ----------
let refreshHandler: (() => Promise<string | null>) | null = null;
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
/** 等待刷新完成的请求队列 */
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
/** 由 auth 模块调用,注册 token 刷新回调 */
export function setTokenRefreshHandler(handler: () => Promise<string | null>): void {
refreshHandler = handler;
}
function processQueue(error: Error | null, token: string | null): void {
failedQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token!);
});
failedQueue = [];
} }
// ---------- Axios 实例 ---------- // ---------- Axios 实例 ----------
@@ -84,6 +114,61 @@ instance.interceptors.request.use(
}, },
); );
// ---------- 401 → 刷新 Token + 重试 ----------
function handle401(config: InternalAxiosRequestConfig): Promise<AxiosResponse<ApiResponse>> {
// 已经在刷新中 → 排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(instance(config));
},
reject,
});
});
}
// 没有注册刷新回调 → 直接要求重新登录
if (!refreshHandler) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(new RefreshError());
}
// 开始刷新
isRefreshing = true;
refreshPromise = refreshHandler();
return new Promise((resolve, reject) => {
refreshPromise!
.then((newToken) => {
if (newToken) {
processQueue(null, newToken);
config.headers.Authorization = `Bearer ${newToken}`;
resolve(instance(config));
} else {
const err = new RefreshError();
processQueue(err, null);
clearToken();
emit(EVENTS.AUTH_REQUIRED);
reject(err);
}
isRefreshing = false;
refreshPromise = null;
})
.catch((err) => {
processQueue(err, null);
isRefreshing = false;
refreshPromise = null;
clearToken();
emit(EVENTS.AUTH_REQUIRED);
reject(new RefreshError());
});
});
}
// ---------- 响应拦截器 ---------- // ---------- 响应拦截器 ----------
instance.interceptors.response.use( instance.interceptors.response.use(
@@ -92,51 +177,94 @@ instance.interceptors.response.use(
// HTTP 200 但业务码非 0 → 业务错误 // HTTP 200 但业务码非 0 → 业务错误
if (data && data.code !== 0) { if (data && data.code !== 0) {
const err = new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', { // 401 → 尝试刷新 token 并重试(但 refresh 接口自身 401 必须直接拒绝,否则死循环)
if (data.code === 401 && response.config) {
if (isRefreshRequest(response.config)) {
return Promise.reject(new RefreshError());
}
return handle401(response.config);
}
logger.warn('request', `Business error: ${data.message || '请求失败'}`, undefined, {
url: response.config.url,
method: response.config.method?.toUpperCase(),
code: data.code, code: data.code,
traceId: data.traceId, traceId: data.traceId,
}); });
// 特殊业务码处理 return Promise.reject(
if (data.code === 401) { new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
clearToken(); code: data.code,
emit(EVENTS.AUTH_REQUIRED); traceId: data.traceId,
}),
);
} }
return Promise.reject(err);
}
return response; return response;
}, },
(error) => { (error) => {
// 提取请求上下文(所有日志共用)
const reqCtx = {
url: error.config?.url,
method: error.config?.method?.toUpperCase(),
};
// 全局捕获 RefreshError → 自动触发登录 Modal
if (isRefreshError(error)) {
logger.debug('request', 'Auth expired (RefreshError caught)', {
...reqCtx,
errorType: error.name,
errorMessage: error.message,
});
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(error);
}
// 请求已被取消 // 请求已被取消
if (axios.isCancel(error)) { if (axios.isCancel(error)) {
return Promise.reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消')); const cancelErr = new RequestError(RequestErrorType.CANCELLED, '请求已取消');
logger.debug('request', 'Request cancelled', reqCtx);
return Promise.reject(cancelErr);
} }
// 超时 // 超时
if (error.code === 'ECONNABORTED') { if (error.code === 'ECONNABORTED') {
return Promise.reject(new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络')); const timeoutErr = new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络');
logger.warn('request', 'Request timeout', timeoutErr, reqCtx);
return Promise.reject(timeoutErr);
} }
// 无网络 // 无网络
if (!error.response) { if (!error.response) {
return Promise.reject(new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接')); const netErr = new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接');
logger.warn('request', 'Network error', netErr, reqCtx);
return Promise.reject(netErr);
} }
// HTTP 状态码错误 // HTTP 状态码错误
const { status, data } = error.response; const { status, data } = error.response;
// 401 → 尝试刷新 token 并重试refresh 接口自身 401 直接拒绝,防死循环)
if (status === 401 && error.config) {
if (isRefreshRequest(error.config)) {
const refreshErr = new RefreshError();
logger.debug('request', 'Refresh endpoint returned 401', {
...reqCtx,
errorType: refreshErr.name,
});
return Promise.reject(refreshErr);
}
return handle401(error.config);
}
let errMsg = '请求失败'; let errMsg = '请求失败';
switch (status) { switch (status) {
case 400: case 400:
errMsg = '请求参数有误'; errMsg = '请求参数有误';
break; break;
case 401:
errMsg = '登录已过期,请重新登录';
clearToken();
emit(EVENTS.AUTH_REQUIRED);
break;
case 403: case 403:
errMsg = '没有访问权限'; errMsg = '没有访问权限';
break; break;
@@ -154,12 +282,19 @@ instance.interceptors.response.use(
break; break;
} }
return Promise.reject( const httpErr = new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
httpStatus: status, httpStatus: status,
code: data?.code, code: data?.code,
}), });
);
// HTTP 5xx → error 级别4xx → warn 级别
if (status >= 500) {
logger.error('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx);
} else {
logger.warn('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx);
}
return Promise.reject(httpErr);
}, },
); );

View File

@@ -47,6 +47,8 @@ export enum RequestErrorType {
BUSINESS = 'BUSINESS', BUSINESS = 'BUSINESS',
/** 请求被取消 */ /** 请求被取消 */
CANCELLED = 'CANCELLED', CANCELLED = 'CANCELLED',
/** 认证过期refresh_token 失效,需重新登录) */
AUTH_EXPIRED = 'AUTH_EXPIRED',
} }
/** 请求错误 */ /** 请求错误 */
@@ -69,3 +71,23 @@ export class RequestError extends Error {
this.traceId = options?.traceId; this.traceId = options?.traceId;
} }
} }
// ---------- 自定义错误子类(全局统一捕获 → 事件总线触发)----------
/**
* RefreshError — refresh_token 过期/无效,需重新登录。
* 全局响应拦截器自动捕获此错误并 emit AUTH_REQUIRED调用方无需手动处理。
*
* 用法throw new RefreshError('登录已过期,请重新登录');
*/
export class RefreshError extends RequestError {
constructor(message = '登录已过期,请重新登录') {
super(RequestErrorType.AUTH_EXPIRED, message);
this.name = 'RefreshError';
}
}
/** 判断一个错误是否为 RefreshError支持 instanceof 或 type 判断) */
export function isRefreshError(err: unknown): err is RefreshError {
return err instanceof RefreshError || (err as RequestError)?.type === RequestErrorType.AUTH_EXPIRED;
}

29
src/utils/device.ts Normal file
View File

@@ -0,0 +1,29 @@
// ============================================================
// 设备标识 — 生成/读取持久化设备 ID
// 用于登录/注册时标识当前设备MAC 地址通常不可行,用 UUID 替代)
// ============================================================
const DEVICE_ID_KEY = 'ele-heixiu-device-id';
/** 生成 UUID v4兼容所有浏览器 */
function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
/** 获取设备唯一标识(若无则自动生成并持久化到 localStorage */
export function getDeviceId(): string {
try {
let id = localStorage.getItem(DEVICE_ID_KEY);
if (!id) {
id = generateUUID();
localStorage.setItem(DEVICE_ID_KEY, id);
}
return id;
} catch {
// localStorage 不可用时返回一次性 UUID
return generateUUID();
}
}

View File

@@ -7,6 +7,21 @@ type Listener = (...args: unknown[]) => void;
const listeners = new Map<string, Set<Listener>>(); const listeners = new Map<string, Set<Listener>>();
// ---------- 日志钩子(由 logger 模块注册,避免循环依赖)----------
type EventLogListener = (event: string, ...args: unknown[]) => void;
let eventLogListener: EventLogListener | null = null;
/**
* 注册事件日志监听器(由 logger 模块调用)
* 每次 emit() 时回调,用于审计追踪
*/
export function setEventLogListener(fn: EventLogListener): void {
eventLogListener = fn;
}
// ---------- API ----------
/** 监听事件 */ /** 监听事件 */
export function on(event: string, fn: Listener): void { export function on(event: string, fn: Listener): void {
if (!listeners.has(event)) { if (!listeners.has(event)) {
@@ -22,6 +37,16 @@ export function off(event: string, fn: Listener): void {
/** 触发事件 */ /** 触发事件 */
export function emit(event: string, ...args: unknown[]): void { export function emit(event: string, ...args: unknown[]): void {
// ① 通知日志监听器(在触发业务监听器之前记录)
if (eventLogListener) {
try {
eventLogListener(event, ...args);
} catch {
/* 日志钩子异常不影响事件投递 */
}
}
// ② 通知业务监听器
listeners.get(event)?.forEach((fn) => { listeners.get(event)?.forEach((fn) => {
try { try {
fn(...args); fn(...args);

140
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,140 @@
// ============================================================
// 渲染进程日志器 — 通过 IPC 转发到主进程写入文件
//
// 用法:
// import { logger } from '@/utils/logger';
// logger.info('auth', 'User logged in', { userId: 42 });
// logger.warn('request', 'Request timeout', new Error('...'), { url: '...' });
//
// 行为:
// Electron 环境 → safeIpcSend('log-message', entry) 发往主进程
// 浏览器 DEV 环境 → console[level] 输出(不回退文件)
//
// 事件总线联动:
// 调用 registerEventLogHook() 后,所有 emit() 自动记录 DEBUG event 日志
// ============================================================
import { safeIpcSend, hasIpc } from './ipc';
import { RENDERER_TO_MAIN } from '@shared/constants/ipc-channels';
import { setEventLogListener } from './event-bus';
import { sanitizeLogEntry } from '@shared/utils/sanitize';
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '@shared/types/logging';
// ---------- 辅助 ----------
/** 错误对象 → 可序列化快照 */
function errorToSnapshot(err: Error): LogErrorSnapshot {
return {
name: err.name,
message: err.message,
stack: err.stack,
cause: (err as { cause?: unknown }).cause
? String((err as { cause?: unknown }).cause)
: undefined,
};
}
/** 构建日志条目 */
function createEntry(
level: LogLevel,
category: LogCategory,
message: string,
error?: Error,
context?: Record<string, unknown>,
): LogEntry {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
category,
message,
processType: 'renderer',
};
if (context && Object.keys(context).length > 0) {
entry.context = context;
}
if (error) {
entry.error = errorToSnapshot(error);
}
return entry;
}
/** 发送日志条目 */
function send(rawEntry: LogEntry): void {
const entry = sanitizeLogEntry(rawEntry);
// Electron 环境 → IPC 发往主进程写入文件
if (hasIpc()) {
safeIpcSend(RENDERER_TO_MAIN.LOG_MESSAGE, entry);
}
// DEV 模式 → 同时输出到浏览器控制台(开发者可见)
if (import.meta.env.DEV) {
const prefix = `[${entry.level}][${entry.category}]`;
const ctx = entry.context ? ` ${JSON.stringify(entry.context)}` : '';
const errMsg = entry.error ? `${entry.error.name}: ${entry.error.message}` : '';
switch (entry.level) {
case 'DEBUG':
console.debug(prefix, entry.message, ctx);
break;
case 'INFO':
console.info(prefix, entry.message, ctx);
break;
case 'WARN':
console.warn(prefix, entry.message + errMsg, ctx);
break;
case 'ERROR':
console.error(prefix, entry.message + errMsg, ctx, entry.error?.stack || '');
break;
}
}
}
// ---------- 对外 API ----------
export const logger = {
debug(category: LogCategory, message: string, context?: Record<string, unknown>): void {
send(createEntry('DEBUG', category, message, undefined, context));
},
info(category: LogCategory, message: string, context?: Record<string, unknown>): void {
send(createEntry('INFO', category, message, undefined, context));
},
warn(
category: LogCategory,
message: string,
error?: Error,
context?: Record<string, unknown>,
): void {
send(createEntry('WARN', category, message, error, context));
},
error(
category: LogCategory,
message: string,
error?: Error,
context?: Record<string, unknown>,
): void {
send(createEntry('ERROR', category, message, error, context));
},
};
// ---------- 事件总线联动 ----------
let eventHookRegistered = false;
/**
* 注册事件总线日志钩子(单次调用,重复调用无副作用)
* 注册后所有 emit() 调用自动记录为 DEBUG event 日志
*/
export function registerEventLogHook(): void {
if (eventHookRegistered) return;
eventHookRegistered = true;
setEventLogListener((event: string, ...args: unknown[]) => {
// 过滤高频事件,避免日志文件膨胀
// 仅记录非 LOGIN_SUCCESS / LOGOUT 的开销极低的事件
logger.debug('event', 'Event emitted', { event, args });
});
}
// 模块加载时自动注册事件总线钩子
registerEventLogHook();

147
src/utils/safe-storage.ts Normal file
View File

@@ -0,0 +1,147 @@
// ============================================================
// safe-storage — 渲染进程安全存储工具
//
// 架构:
// - 内存缓存get() 同步返回,避免每次 API 请求都触发 IPC 往返
// - 持久化set() 异步加密后写入 localStorage
// - 启动恢复init() 从 localStorage 读取密文,异步解密到内存缓存
// - 降级:非 Electron 环境回退到 localStorage 明文(仅开发用)
//
// 使用模式:
// // App 启动时
// await safeStore.init('my-key');
//
// // 任意位置读取(同步,高频安全)
// const value = safeStore.get('my-key');
//
// // 写入(异步加密)
// await safeStore.set('my-key', 'plaintext');
//
// // 清除
// safeStore.clear('my-key');
// ============================================================
import { hasIpc } from './ipc';
// ---------- 内部状态 ----------
/** 内存缓存key → 明文) */
const cache = new Map<string, string>();
// ---------- IPC 桥接 ----------
interface SafeStorageBridge {
encrypt(plaintext: string): Promise<string | null>;
decrypt(encryptedBase64: string): Promise<string | null>;
}
/** 获取 safeStorage 桥接(由 preload 注入) */
function getBridge(): SafeStorageBridge | null {
if (!hasIpc() || !window.safeStorage) return null;
return window.safeStorage;
}
// ---------- 公开 API ----------
/**
* 初始化指定的键:从持久化存储读取密文 → 解密 → 加载到内存缓存。
* 应在 App 启动时(如 AppProvider 挂载)对需要用到的 key 逐一调用。
*
* @param key - 存储键名(与 localStorage key 对应)
* @returns 解密后的明文,失败或无数据返回 null
*/
export async function initSafeStore(key: string): Promise<string | null> {
// 先从 localStorage 读取密文
let encrypted: string | null = null;
try {
encrypted = localStorage.getItem(key);
} catch {
return null;
}
if (!encrypted) return null;
const bridge = getBridge();
if (bridge) {
// Electron 环境IPC 解密
const plaintext = await bridge.decrypt(encrypted);
if (plaintext !== null) {
cache.set(key, plaintext);
return plaintext;
}
// 解密失败(可能 OS 密钥变更)→ 清除残留
try {
localStorage.removeItem(key);
} catch {
/* 静默 */
}
return null;
}
// 非 Electron 环境浏览器开发localStorage 明文
cache.set(key, encrypted);
return encrypted;
}
/**
* 同步读取已缓存的明文(高频安全,不触发 IPC
* 如果缓存未命中,返回 null调用方应先 await initSafeStore
*/
export function getSafeStore(key: string): string | null {
return cache.get(key) ?? null;
}
/**
* 加密并持久化存储。
* 同时更新内存缓存(后续 get() 可直接同步读取)。
*
* @returns 是否存储成功
*/
export async function setSafeStore(key: string, plaintext: string): Promise<boolean> {
// 先更新内存缓存(即使持久化失败,当前 session 仍可用)
cache.set(key, plaintext);
const bridge = getBridge();
if (bridge) {
// Electron 环境:加密后写入 localStorage
const encrypted = await bridge.encrypt(plaintext);
if (encrypted !== null) {
try {
localStorage.setItem(key, encrypted);
return true;
} catch {
return false;
}
}
return false;
}
// 非 Electron 环境:直接写入 localStorage 明文
try {
localStorage.setItem(key, plaintext);
return true;
} catch {
return false;
}
}
/**
* 清除指定键的缓存和持久化数据。
* 同步执行(不阻塞),持久化清除为 best-effort。
*/
export function clearSafeStore(key: string): void {
cache.delete(key);
try {
localStorage.removeItem(key);
} catch {
/* 静默 */
}
}
/**
* 检查指定键是否已有缓存(用于判断 init 是否已被调用)。
*/
export function hasSafeStoreCache(key: string): boolean {
return cache.has(key);
}

5
src/vite-env.d.ts vendored
View File

@@ -8,6 +8,11 @@ declare global {
send(channel: string, ...args: unknown[]): void; send(channel: string, ...args: unknown[]): void;
invoke(channel: string, ...args: unknown[]): Promise<unknown>; invoke(channel: string, ...args: unknown[]): Promise<unknown>;
}; };
/** Electron safeStorage 安全加密 API由 preload 注入,基于 OS 原生密钥链) */
safeStorage: {
encrypt(plaintext: string): Promise<string | null>;
decrypt(encryptedBase64: string): Promise<string | null>;
};
} }
} }

View File

@@ -0,0 +1 @@
3.14

180
test-server/README.md Normal file
View File

@@ -0,0 +1,180 @@
# HeiXiu 更新服务器 — 后端对接文档
> 本文档供后端程序员阅读和实现。
---
## 快速启动测试服务器
```bash
cd test-server
pip install fastapi uvicorn
python server.py --release-dir ../release --port 8000
```
启动后访问:
- API 文档Swagger`http://localhost:8000/docs`
- 健康检查:`http://localhost:8000/health`
---
## API 端点
所有请求为 `GET`,无鉴权。
### 1. `GET /api/v1/update/check` — 版本检查
客户端启动后调用,决定是否提示用户更新。
**请求参数Query String**
| 参数 | 类型 | 必填 | 示例 | 说明 |
|------|------|:--:|------|------|
| `platform` | string | 是 | `win32` | `win32` / `darwin` / `linux` |
| `version` | string | 是 | `0.0.3` | 客户端当前版本号(三段式 semver |
| `edition` | string | 否 | `personal` | `personal` / `enterprise`,默认 `personal` |
**响应 — 有更新时:**
```json
{
"code": 0,
"data": {
"hasUpdate": true,
"latestVersion": "0.0.4",
"downloadUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.0.4/xxx-Setup.exe",
"fileSize": 113581841,
"sha512": "Base64...",
"blockMapUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.0.4/xxx-Setup.exe.blockmap",
"blockMapSize": 120256,
"changelog": "## 0.0.4\n\n- 日志记录\n- 增量更新",
"releaseDate": "2026-06-04",
"forceUpdate": false,
"minimumVersion": "0.0.0"
}
}
```
**响应 — 无更新时:**
```json
{
"code": 0,
"data": {
"hasUpdate": false
}
}
```
**字段说明:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `downloadUrl` | string | 是 | 安装包 HTTPS 直链 |
| `fileSize` | number | 是 | 安装包字节数 |
| `sha512` | string | 是 | 安装包 SHA512 Base64 |
| `blockMapUrl` | string | 否 | blockmap 文件 URL缺失时客户端全量下载 |
| `blockMapSize` | number | 否 | blockmap 文件字节数(`> 0` 时客户端启用增量下载) |
| `changelog` | string | 否 | Markdown 格式更新日志 |
| `releaseDate` | string | 否 | 发布日期YYYY-MM-DD |
| `forceUpdate` | boolean | 否 | 是否强制更新(`true` 时用户不可跳过) |
| `minimumVersion` | string | 否 | 最低兼容版本(低于此版本的客户端必须更新) |
> **增量更新说明**`blockMapUrl` + `blockMapSize` 为可选字段。客户端检测到 `blockMapSize > 0` 时自动启用增量下载(仅下载变更区块,节省 30%-70% 流量)。不传或为 `null` 时客户端全量下载。
---
### 2. `POST /api/v1/releases` — 发布新版本(需要鉴权)
```http
POST /api/v1/releases
Authorization: Bearer <UPDATE_API_KEY>
Content-Type: application/json
```
**请求体:**
```json
{
"platform": "win32",
"edition": "personal",
"latestVersion": "0.1.0",
"downloadUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe",
"fileSize": 112910987,
"sha512": "Base64...",
"blockMapUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe.blockmap",
"blockMapSize": 152340,
"changelog": "## 0.1.0\n\n- 新功能 A\n- 修复 B",
"releaseDate": "2026-06-04",
"forceUpdate": false,
"minimumVersion": "0.0.0"
}
```
---
## 数据库表结构
```sql
CREATE TABLE releases (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux',
edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise',
version VARCHAR(20) NOT NULL COMMENT '三段式 semver',
file_url VARCHAR(500) NOT NULL COMMENT '安装包 OSS HTTPS 地址',
file_size BIGINT NOT NULL COMMENT '安装包字节数',
sha512 VARCHAR(128) NOT NULL COMMENT '安装包 SHA512 Base64',
blockmap_url VARCHAR(500) COMMENT 'blockmap OSS 地址(可为空)',
blockmap_size BIGINT COMMENT 'blockmap 文件字节数',
changelog TEXT COMMENT 'Markdown 格式更新日志',
force_update TINYINT(1) DEFAULT 0,
min_version VARCHAR(20) DEFAULT '',
release_date DATE NOT NULL,
status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_edition_version (platform, edition, version),
INDEX idx_platform_edition_status (platform, edition, status)
);
```
---
## 更新检查逻辑(参考实现)
```
GET /api/v1/update/check?platform=win32&version=0.0.3&edition=personal
1. 查询最新 published 版本:
SELECT * FROM releases
WHERE platform = 'win32'
AND edition = 'personal'
AND status = 'published'
ORDER BY version DESC
LIMIT 1
2. 比较版本号:
- 请求 version >= 最新 version → hasUpdate = false
- 请求 version < 最新 version → hasUpdate = true
3. 版本比较建议用 semver 规则major.minor.patch
```
---
## 文件托管OSS
- 安装包和 `.blockmap` 文件托管在阿里云 OSS允许公开读取
- 路径约定:`releases/{version}/{filename}`
- 客户端通过 `downloadUrl` 直接下载,不经过后端 API
---
## 后端实现清单
- [ ] 部署 `GET /api/v1/update/check`
- [ ] 部署 `POST /api/v1/releases`Bearer Token 鉴权)
- [ ] 创建 `releases`
- [ ] 配置 OSS 公开读取 + HTTPS
- [ ] 可选API Key 白名单 + 限流

View File

@@ -0,0 +1,10 @@
[project]
name = "test-server"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastapi>=0.136.3",
"uvicorn[start]>=0.49.0",
]

View File

@@ -0,0 +1,2 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0

205
test-server/uv.lock generated Normal file
View File

@@ -0,0 +1,205 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "click"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.136.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
]
[[package]]
name = "starlette"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" },
]
[[package]]
name = "test-server"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.136.3" },
{ name = "uvicorn", extras = ["start"], specifier = ">=0.49.0" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.49.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
]

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import path from 'node:path'; import path from 'node:path';
import electron from 'vite-plugin-electron/simple'; import electron from 'vite-plugin-electron/simple';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
@@ -8,13 +8,29 @@ import { visualizer } from 'rollup-plugin-visualizer';
const __dirname = import.meta.dirname; const __dirname = import.meta.dirname;
export default defineConfig({ // ============================================================
// 热更新说明
// ============================================================
// 修改 src/ → React Fast Refresh组件级热替换不丢失状态
// 修改 electron/ → 主进程重建 + Electron 自动重启(预期行为)
// 修改 shared/ → 因为 shared/ 被主进程引用,也会触发重启
// 如需避免,可把仅渲染进程用的类型放到 src/types/
// ============================================================
export default defineConfig(({ mode }) => {
// 加载 .env.development / .env.production 中的环境变量
const env = loadEnv(mode, __dirname, '');
return {
plugins: [ plugins: [
react(), react(),
tailwindcss(), tailwindcss(),
electron({ electron({
main: { main: {
// 主进程入口
entry: 'electron/main.ts', entry: 'electron/main.ts',
// 只监听 electron/ 目录,避免 shared/ 变更误触重启
// (默认会监听 entry 文件的所有依赖,包括 shared/
}, },
preload: { preload: {
input: path.join(__dirname, 'electron/preload.ts'), input: path.join(__dirname, 'electron/preload.ts'),
@@ -36,6 +52,38 @@ export default defineConfig({
'@shared': path.resolve(__dirname, 'shared'), '@shared': path.resolve(__dirname, 'shared'),
}, },
}, },
// ============================================================
// 编译时注入环境变量到主进程
// 主进程中的 process.env.xxx 会在编译时被替换为实际值
// 这样无需在 OS 环境中设置这些变量
// ============================================================
define: {
'process.env.UPDATE_API_URL': JSON.stringify(env.UPDATE_API_URL || 'https://www.heixiu.com'),
'process.env.EDITION': JSON.stringify(env.EDITION || 'personal'),
},
// ============================================================
// 开发服务器 & HMR 配置
// ============================================================
server: {
port: 5173,
strictPort: true,
host: 'localhost',
// 文件监听Windows 优化)
watch: {
// Windows 上默认的 fs.watch 有时不稳定,
// 如果文件保存后界面无反应,可临时开启 usePolling
// usePolling: true,
// interval: 100,
// 忽略不需要监听的目录,减少 CPU 占用
ignored: ['**/node_modules/**', '**/dist/**', '**/dist-electron/**', '**/release/**', '**/test-server/**'],
},
// HMR 配置
hmr: {
// 在浏览器控制台显示 HMR 覆盖层(错误/警告时)
overlay: true,
// 协议默认 ws://localhost 下无需额外配置
},
},
build: { build: {
rollupOptions: { rollupOptions: {
output: {}, output: {},
@@ -44,4 +92,5 @@ export default defineConfig({
preview: { preview: {
port: 5173, port: 5173,
}, },
};
}); });