Compare commits
4 Commits
767bb8b297
...
ae733016bb
| Author | SHA1 | Date | |
|---|---|---|---|
| ae733016bb | |||
| 337234443a | |||
| 397d80c87b | |||
| 3becdb85d4 |
17
.codegraph/.gitignore
vendored
Normal file
17
.codegraph/.gitignore
vendored
Normal 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
6
.codegraph/daemon.pid
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pid": 91784,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
||||
"startedAt": 1780539931451
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,3 +33,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.py
|
||||
*.pyi
|
||||
WORKLOG.md
|
||||
|
||||
203
ARCHITECTURE.md
Normal file
203
ARCHITECTURE.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# 船长 · 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 重试 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 存储层 │
|
||||
│ localStorage(access_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.ts(Token 生命周期)
|
||||
|
||||
- 职责: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 }` | 记住账号偏好(**不存密码**) |
|
||||
|
||||
---
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 开发值 | 生产值 |
|
||||
|------|--------|--------|
|
||||
| `VITE_API_BASE_URL` | `http://8.160.179.64:8000` | `https://www.heixiu.net` |
|
||||
| `VITE_EDITION` | `personal` | `personal` / `enterprise` |
|
||||
| `UPDATE_API_URL` | — | `https://www.heixiu.com` |
|
||||
@@ -1,3 +1,4 @@
|
||||
# 回答语言
|
||||
|
||||
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
|
||||
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录
|
||||
|
||||
146
PROJECT.md
146
PROJECT.md
@@ -30,17 +30,21 @@
|
||||
|
||||
## 前端架构原则
|
||||
|
||||
- 组件化:以函数组件 + Hooks 为唯一组件形式,UI 拆分为展示组件与容器组件(若无特殊需求,可使用 Hooks 直接连接 Redux)。
|
||||
- 组件化:以函数组件 + Hooks 为唯一组件形式,UI 拆分为展示组件与容器组件。
|
||||
|
||||
- 单向数据流:全局状态使用 Redux Toolkit,严格遵循 Action → Reducer → Store → View 单向流动。组件内部状态使用
|
||||
useState/useReducer,不污染全局 Store。
|
||||
- 单向数据流:全局状态使用 Context + Provider 模式(AppContext / ThemeContext / SettingsContext),
|
||||
遵循 Provider → Context → Comsumer 单向流动。组件内部状态使用 useState/useReducer,不污染全局 Context。
|
||||
|
||||
- 事件驱动(EDA):
|
||||
1. 主进程与渲染进程通信基于 Electron IPC(ipcMain/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+ 管理页面切换,支持嵌套路由。
|
||||
|
||||
@@ -50,50 +54,62 @@
|
||||
# 目录结构
|
||||
|
||||
```md
|
||||
├── electron/ # Electron 主进程与预加载脚本
|
||||
│ ├── main/ # 主进程代码
|
||||
│ │ ├── index.ts # 入口,初始化应用
|
||||
│ │ ├── windowManager.ts # 多窗口管理器(创建、销毁、聚焦)
|
||||
│ │ ├── ipc/ # IPC 事件注册与处理
|
||||
│ │ ├── menu/ # 系统菜单模板(按平台拆分)
|
||||
│ │ └── utils/ # 平台判断、路径处理等
|
||||
│ └── preload/ # 预加载脚本
|
||||
│ ├── index.ts # 主窗口预加载
|
||||
│ └── subWindow.ts # 辅助窗口预加载
|
||||
├── src/ # 渲染进程(React 应用)
|
||||
│ ├── main/ # 主窗口 SPA 入口
|
||||
│ │ ├── index.html # HTML 入口
|
||||
│ │ ├── main.tsx # React 挂载点
|
||||
│ │ └── App.tsx # 根组件(路由、主题)
|
||||
│ ├── sub/ # 辅助窗口独立入口(一个窗口对应一个子目录)
|
||||
│ │ ├── settings/ # 设置窗口示例
|
||||
│ │ │ ├── index.html
|
||||
│ │ │ └── main.tsx
|
||||
│ │ └── preview/ # 预览窗口示例
|
||||
│ ├── components/ # 通用 UI 组件
|
||||
│ ├── containers/ # 业务容器组件(连接 Redux)
|
||||
│ ├── store/ # Redux Toolkit 配置
|
||||
│ │ ├── index.ts # Store 创建
|
||||
│ │ ├── slices/ # 按功能拆分 Slice
|
||||
│ │ └── hooks.ts # 类型化 useAppSelector/useAppDispatch
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── theme/ # 主题适配器与定制
|
||||
│ │ ├── adapters/ # Ant Design 主题配置、Tailwind 扩展
|
||||
│ │ ├── tokens.ts # 设计令牌(颜色、圆角、阴影等)
|
||||
│ │ └── globals.css # Tailwind 指令 + 全局样式
|
||||
│ ├── router/ # 路由配置(主窗口)
|
||||
│ ├── utils/ # 纯函数工具
|
||||
│ │ └── platform.ts # 平台判定工具(process.platform 封装)
|
||||
│ └── types/ # 渲染进程专用类型
|
||||
├── shared/ # 主进程 & 渲染进程共享
|
||||
│ ├── types/ # IPC 通道、载荷类型、通用接口
|
||||
│ ├── constants/ # 事件名称、配置常量
|
||||
│ └── utils/ # 通用逻辑(如版本比较)
|
||||
├── package.json
|
||||
├── vite.config.ts # Vite 配置(多入口)
|
||||
├── tailwind.config.ts # Tailwind 主题扩展
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期
|
||||
│ ├── preload.ts # 预加载脚本(contextBridge)
|
||||
│ └── main/
|
||||
│ ├── updater.ts # 自定义更新(API 版本检查 + OSS 下载 + NSIS 安装)
|
||||
│ ├── logger.ts # 主进程日志核心(JSON Lines 写入 / 轮转 / 清理)
|
||||
│ ├── log-ipc.ts # 渲染进程日志 IPC 通道接收
|
||||
│ ├── net-request.ts # Electron net.request 的 axios 风格封装
|
||||
│ ├── menu/index.ts # 系统菜单
|
||||
│ └── utils/ # 平台判断 / 图标路径
|
||||
├── src/ # 渲染进程(React 19 SPA)
|
||||
│ ├── main.tsx # React 挂载点 + 全局错误捕获
|
||||
│ ├── App.tsx # 根组件(路由 + 登录弹窗)
|
||||
│ ├── components/ # 通用 UI 组件 + Provider
|
||||
│ │ ├── AppProvider.tsx # 全局状态(platform / edition / login)
|
||||
│ │ ├── ThemeProvider.tsx # 主题状态(light / dark)
|
||||
│ │ ├── SettingsProvider.tsx # 设置状态(存储路径等)
|
||||
│ │ ├── Layout.tsx # 页面壳(NavBar + Outlet)
|
||||
│ │ └── navbar/ # 导航栏 + 公告抽屉
|
||||
│ ├── contexts/ # Context 定义(app / settings / theme)
|
||||
│ ├── hooks/ # 自定义 Hooks(useUpdater / useTheme)
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── home/ # 首页仪表盘
|
||||
│ │ ├── login/ # 登录 / 注册页
|
||||
│ │ └── settings/ # 设置面板(主题 / 存储 / 更新)
|
||||
│ ├── router/index.tsx # HashRouter 路由配置
|
||||
│ ├── services/ # HTTP 请求封装
|
||||
│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
|
||||
│ │ ├── types.ts # 自定义错误类型(RequestError / RefreshError)
|
||||
│ │ ├── auth-token.ts # refresh_token 管理
|
||||
│ │ └── modules/ # API 模块(auth / announcement)
|
||||
│ ├── theme/ # 主题系统
|
||||
│ │ ├── tokens.ts # 设计令牌
|
||||
│ │ └── globals.css # Tailwind + 全局样式
|
||||
│ └── utils/ # 纯函数工具
|
||||
│ ├── event-bus.ts # 事件总线(on / off / emit + 日志钩子)
|
||||
│ ├── logger.ts # 渲染进程日志器(IPC 转发 / DEV 控制台)
|
||||
│ ├── ipc.ts # 安全 IPC 守卫(safeIpcOn / send / invoke)
|
||||
│ ├── platform.ts # 平台判定
|
||||
│ └── device.ts # 设备 ID 生成
|
||||
├── shared/ # 主进程 & 渲染进程共享
|
||||
│ ├── types/ # 类型定义(API / IPC / update / logging)
|
||||
│ ├── constants/ # 常量(IPC 通道 / version / app)
|
||||
│ └── utils/ # 共享工具
|
||||
│ ├── index.ts # debounce / throttle / compareVersions
|
||||
│ └── sanitize.ts # 日志脱敏工具(ID / 邮箱 / 手机 / 名称)
|
||||
├── 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
|
||||
└── electron-builder.yml # 打包配置(跨平台适配)
|
||||
└── tailwind.config.ts
|
||||
```
|
||||
|
||||
# 多窗口与平台判定
|
||||
@@ -104,7 +120,7 @@
|
||||
- src/utils/platform.ts 提供 isMacOS、isWindows 等函数(基于 navigator.userAgent 或 preload 暴露的 process.platform)。在主进程的
|
||||
electron/main/utils/platform.ts 中直接使用 Node.js API。
|
||||
|
||||
- 打包配置 (electron-builder.yml) 须针对平台设置不同的图标、签名策略、安装包格式。
|
||||
- 打包配置 (package.json build 字段) 须针对平台设置不同的图标、签名策略、安装包格式。
|
||||
|
||||
# 代码风格
|
||||
|
||||
@@ -131,13 +147,29 @@
|
||||
|
||||
- 使用 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 / useSettings),Hook 内部做 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 → debug,5xx → error,其余 → warn)。
|
||||
|
||||
- 脱敏:shared/utils/sanitize.ts 在日志写入前自动处理 ID(哈希)、邮箱/手机/名称/Token 等字段。
|
||||
|
||||
# 样式与主题
|
||||
|
||||
@@ -153,11 +185,15 @@
|
||||
|
||||
# 事件与 IPC
|
||||
|
||||
- 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,避免魔法字符串。
|
||||
- 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,分为三类:
|
||||
MAIN_TO_RENDERER / RENDERER_TO_MAIN / BIDIRECTIONAL。
|
||||
|
||||
- 渲染进程通过 src/utils/ipc.ts 中的安全守卫访问 IPC(hasIpc 检查 → safeIpcOn / send / invoke),
|
||||
非 Electron 环境下静默跳过,保证浏览器预览不崩溃。
|
||||
|
||||
- 主进程事件处理函数需做好错误捕获与日志记录,渲染进程调用 IPC 时使用 try-catch 并处理超时。
|
||||
|
||||
- 窗口间通信走主进程转发:发送窗口 ipcRenderer.send,主进程接收后定位目标窗口并 webContents.send。
|
||||
- 窗口间通信走主进程转发:发送窗口 safeIpcSend,主进程接收后定位目标窗口并 webContents.send。
|
||||
|
||||
# 开发与构建命令
|
||||
|
||||
|
||||
168
TODO.md
Normal file
168
TODO.md
Normal 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 loading(React.lazy + Suspense)
|
||||
- [ ] 错误边界组件(ErrorBoundary)
|
||||
|
||||
### 文档
|
||||
|
||||
- [ ] API 接口文档(Swagger / 手动维护)
|
||||
- [ ] 组件文档(Storybook)
|
||||
- [ ] 用户操作手册
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 已知技术债务
|
||||
|
||||
| 问题 | 说明 | 影响 |
|
||||
|-----------------|---------------------------------------|----------------|
|
||||
| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 |
|
||||
| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 |
|
||||
| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 |
|
||||
| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 下次开发计划
|
||||
|
||||
1. **safeStorage 加密改造**(安全加固 #1)
|
||||
2. **合规素材库页面**(功能待完善 Route 页面)
|
||||
3. **各页面 nav 导航补齐**
|
||||
168
UpdateA.md
168
UpdateA.md
@@ -13,20 +13,25 @@
|
||||
│ 版本检查 ─────────┼────────▶│ /api/v1/update/ │ │ │
|
||||
│ │ │ check │ │ │
|
||||
│ │◀────────│ 返回 JSON │ │ │
|
||||
│ │ │ (含 blockmap 信息) │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ 下载安装包 ───────┼─────────┼──────────────────┼────────▶│ 静态文件托管 │
|
||||
│ │◀────────┼──────────────────┼─────────│ .exe / .dmg │
|
||||
│ 增量下载(未来)────┼─────────┼──────────────────┼────────▶│ 静态文件托管 │
|
||||
│ 仅下载变化块 │◀────────┼──────────────────┼─────────│ .exe + .blockmap │
|
||||
│ │ │ │ │ │
|
||||
│ 安装 (spawn) │ │ │ │ │
|
||||
│ 本地重建安装包 │ │ │ │ │
|
||||
│ 校验 SHA512 │ │ │ │ │
|
||||
│ NSIS /S 静默安装 │ │ │ │ │
|
||||
│ → app.quit() │ │ │ │ │
|
||||
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
| 角色 | 技术 | 职责 |
|
||||
| ------ | -------------------------- | ------------------------------------------ |
|
||||
| 客户端 | Electron + `net.request()` | 调用 API → 下载安装包 → 校验 SHA512 → 安装 |
|
||||
| ECS | 任意后端语言 | 提供版本检查 API |
|
||||
| OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage |
|
||||
> **当前状态**:客户端为全量下载(`net.request` 下载完整 .exe)。`electron-builder` 打包已生成 `.exe.blockmap` 文件(`"differential": true`),但客户端尚未使用。**增量下载**(electron-updater 自定义 Provider)为规划中的优化。
|
||||
|
||||
| 角色 | 技术 | 职责 |
|
||||
|-----|--------------------|---------------------------------|
|
||||
| 客户端 | Electron + electron-updater | 调用 API → 下载 blockmap → 差分下载 → 重建 → 校验 → 安装 |
|
||||
| ECS | 任意后端语言 | 提供版本检查 API(含 blockmap 信息) |
|
||||
| OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage + .blockmap |
|
||||
|
||||
---
|
||||
|
||||
@@ -60,14 +65,14 @@ node scripts/publish-release.mjs <平台> <版本类型> [版本号] [--url <url
|
||||
|
||||
### `build.mjs` 操作组合
|
||||
|
||||
| 操作 | 说明 |
|
||||
| ----------- | -------------------------------------------------------- |
|
||||
| 操作 | 说明 |
|
||||
|-------------|------------------------------------------------------|
|
||||
| `--build` | 构建(tsc + vite + electron-builder + update-info.json) |
|
||||
| `--clean` | 清理旧产物 |
|
||||
| `--sign` | 代码签名(配合 `--cert` 或环境变量) |
|
||||
| `--upload` | 上传 OSS |
|
||||
| `--publish` | POST 版本信息到后端 API |
|
||||
| `--all` | 全流程 = clean + build + sign + upload + publish |
|
||||
| `--clean` | 清理旧产物 |
|
||||
| `--sign` | 代码签名(配合 `--cert` 或环境变量) |
|
||||
| `--upload` | 上传 OSS |
|
||||
| `--publish` | POST 版本信息到后端 API |
|
||||
| `--all` | 全流程 = clean + build + sign + upload + publish |
|
||||
|
||||
---
|
||||
|
||||
@@ -87,9 +92,10 @@ npm run build:win && npm run build:win:enterprise
|
||||
|
||||
# 4. 产物在 release/0.1.0/ 下:
|
||||
# - xxx-Setup.exe(安装包)
|
||||
# - xxx-Setup.exe.blockmap(块映射,增量更新用)
|
||||
# - 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 表
|
||||
```
|
||||
|
||||
@@ -127,8 +133,8 @@ node build.mjs --win --personal --upload --publish
|
||||
|
||||
### 证书获取
|
||||
|
||||
| 平台 | 格式 | 渠道 | 年费 |
|
||||
| ------- | ----------------- | ------------------------------- | -------- |
|
||||
| 平台 | 格式 | 渠道 | 年费 |
|
||||
|---------|-------------------|---------------------------------|----------|
|
||||
| Windows | `.pfx` / `.p12` | DigiCert / Sectigo / GlobalSign | $200-700 |
|
||||
| macOS | `.p12` / Keychain | Apple Developer Program | $99 |
|
||||
|
||||
@@ -158,41 +164,57 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
|
||||
|
||||
```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"
|
||||
}
|
||||
"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...",
|
||||
"blockMapUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe.blockmap",
|
||||
"blockMapSize": 152340,
|
||||
"changelog": "更新日志...",
|
||||
"releaseDate": "2026-06-03",
|
||||
"forceUpdate": false,
|
||||
"minimumVersion": "0.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|:--:|------|
|
||||
| `blockMapUrl` | string | 否 | blockmap 文件 OSS 地址(客户端未支持增量时可不传,传了也不影响) |
|
||||
| `blockMapSize` | number | 否 | blockmap 文件字节数 |
|
||||
|
||||
> **向后兼容**:`blockMapUrl` / `blockMapSize` 为可选字段。旧版客户端不关心增量更新;新版客户端检测到有 `blockMapUrl` 时启用增量下载,缺失时回退全量下载。
|
||||
|
||||
**无更新**:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "hasUpdate": false } }
|
||||
{
|
||||
"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"
|
||||
"platform": "win32",
|
||||
"edition": "personal",
|
||||
"latestVersion": "0.1.0",
|
||||
"downloadUrl": "https://...",
|
||||
"fileSize": 112910987,
|
||||
"sha512": "Base64...",
|
||||
"blockMapUrl": "https://...",
|
||||
"blockMapSize": 152340,
|
||||
"changelog": "...",
|
||||
"releaseDate": "2026-06-03",
|
||||
"forceUpdate": false,
|
||||
"minimumVersion": "0.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -203,58 +225,63 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
|
||||
## 六、数据库
|
||||
|
||||
```sql
|
||||
CREATE TABLE releases (
|
||||
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 编码',
|
||||
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,
|
||||
force_update TINYINT(1) DEFAULT 0,
|
||||
min_version VARCHAR(20) DEFAULT '',
|
||||
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,
|
||||
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)
|
||||
INDEX idx_platform_edition_status (platform, edition, status)
|
||||
);
|
||||
```
|
||||
|
||||
> **新字段说明**:`blockmap_url` 和 `blockmap_size` 为可选字段(DEFAULT NULL),不影响现有数据。首次发版时无需回填历史数据——客户端检测到字段为空时自动回退全量下载。
|
||||
|
||||
---
|
||||
|
||||
## 七、客户端行为
|
||||
|
||||
| 场景 | 行为 |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| 启动 | 5 秒后静默检查更新(仅生产环境) |
|
||||
| 手动检查 | 点击按钮 → 调用 API → 反馈结果 |
|
||||
| 下载 | 流式写入临时目录,SHA512 实时校验,每 250ms 推送进度 |
|
||||
| 安装 | 用户确认 → spawn NSIS /S → app.quit() |
|
||||
| 强制更新 | `forceUpdate=true` 时不可跳过 |
|
||||
| 开发模式 | 跳过所有更新检查 |
|
||||
| 场景 | 行为 |
|
||||
|------|-----------------------------------|
|
||||
| 启动 | 5 秒后静默检查更新(仅生产环境) |
|
||||
| 手动检查 | 点击按钮 → 调用 API → 反馈结果 |
|
||||
| 下载 | 流式写入临时目录,SHA512 实时校验,每 250ms 推送进度 |
|
||||
| 安装 | 用户确认 → spawn NSIS /S → app.quit() |
|
||||
| 强制更新 | `forceUpdate=true` 时不可跳过 |
|
||||
| 开发模式 | 跳过所有更新检查 |
|
||||
|
||||
---
|
||||
|
||||
## 八、环境变量速查
|
||||
|
||||
| 变量 | 默认值 | 用途 |
|
||||
| ----------------------- | ------------------------------------------------------ | ----------------------- |
|
||||
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API 基地址 |
|
||||
| `UPDATE_API_KEY` | — | 发布 API 鉴权 Key |
|
||||
| `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | 预判 URL 基地址 |
|
||||
| 变量 | 默认值 | 用途 |
|
||||
|-------------------------|--------------------------------------------------------|----------------------|
|
||||
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API 基地址 |
|
||||
| `UPDATE_API_KEY` | — | 发布 API 鉴权 Key |
|
||||
| `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | 预判 URL 基地址 |
|
||||
| `OSS_ENDPOINT` | — | 阿里云 OSS endpoint |
|
||||
| `OSS_BUCKET` | — | 阿里云 OSS bucket |
|
||||
| `OSS_ACCESS_KEY_ID` | — | 阿里云 AccessKey ID |
|
||||
| `OSS_ACCESS_KEY_SECRET` | — | 阿里云 AccessKey Secret |
|
||||
| `OSS_BASE_PATH` | `releases` | OSS 内基路径 |
|
||||
| `CSC_LINK` | — | 签名证书路径 |
|
||||
| `CSC_KEY_PASSWORD` | — | 签名证书密码 |
|
||||
| `UPDATE_FORCE` | `false` | 强制更新标记(打包时) |
|
||||
| `UPDATE_CHANGELOG` | 读取 CHANGELOG.md | 覆盖更新日志(CI/CD) |
|
||||
| `OSS_BASE_PATH` | `releases` | OSS 内基路径 |
|
||||
| `CSC_LINK` | — | 签名证书路径 |
|
||||
| `CSC_KEY_PASSWORD` | — | 签名证书密码 |
|
||||
| `UPDATE_FORCE` | `false` | 强制更新标记(打包时) |
|
||||
| `UPDATE_CHANGELOG` | 读取 CHANGELOG.md | 覆盖更新日志(CI/CD) |
|
||||
|
||||
---
|
||||
|
||||
@@ -268,12 +295,13 @@ ele-heixiu/
|
||||
├ package.json ← 仅保留简短的 npm 快捷命令
|
||||
├ scripts/
|
||||
│ ├ clean.mjs ← 清理产物
|
||||
│ ├ generate-update-info.mjs ← 生成 API 信息 JSON
|
||||
│ ├ upload-oss.mjs ← 上传 OSS
|
||||
│ ├ generate-update-info.mjs ← 生成 API 信息 JSON(含 blockmap 信息)
|
||||
│ ├ upload-oss.mjs ← 上传 OSS(安装包 + blockmap)
|
||||
│ └ publish-release.mjs ← 发布到 API(axios)
|
||||
├ release/ ← 构建产物目录(按版本分)
|
||||
│ └ 0.1.0/
|
||||
│ ├ xxx-Setup.exe
|
||||
│ ├ xxx-Setup.exe.blockmap ← 差分更新块映射文件
|
||||
│ └ xxx-update-info.json
|
||||
├ dist/ ← Vite 前端产物(构建输入)
|
||||
└ dist-electron/ ← Electron 主进程产物(构建输入)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
@@ -8,6 +8,9 @@ import { getWindowIconPath } from './main/utils/logo';
|
||||
import { buildWindowTitle, parseEdition } from '../shared/constants/app';
|
||||
import { initUpdater, registerUpdateIpcHandlers } from './main/updater';
|
||||
import { setupAppMenu } from './main/menu';
|
||||
import { initLogger, flushLogger, logger } from './main/logger';
|
||||
import { registerLogIpcHandlers } from './main/log-ipc';
|
||||
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
|
||||
|
||||
createRequire(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -27,6 +30,27 @@ const currentPlatform = getPlatform();
|
||||
const currentEdition = parseEdition(process.env.EDITION);
|
||||
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 +135,26 @@ app.on('activate', () => {
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// 清理工作
|
||||
flushLogger();
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
initLogger();
|
||||
registerLogIpcHandlers();
|
||||
setupAppMenu();
|
||||
app.setName(WINDOW_TITLE);
|
||||
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 弹层处理,不再新开窗口
|
||||
const win = createMainWindow();
|
||||
|
||||
21
electron/main/log-ipc.ts
Normal file
21
electron/main/log-ipc.ts
Normal 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.on(fire-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
251
electron/main/logger.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// ============================================================
|
||||
// 主进程日志核心 — 文件写入 / 轮转 / 清理
|
||||
//
|
||||
// 架构:
|
||||
// - 日志写入 {userData}/logs/app-YYYY-MM-DD.log(JSON 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;
|
||||
}
|
||||
}
|
||||
208
electron/main/net-request.ts
Normal file
208
electron/main/net-request.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// ============================================================
|
||||
// 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;
|
||||
|
||||
/** 拼接 URL(baseURL + path + query) */
|
||||
function buildUrl(url: string, config: RequestConfig): string {
|
||||
let fullUrl = url;
|
||||
|
||||
if (config.baseURL) {
|
||||
// url 以 / 开头时为绝对路径,否则拼接
|
||||
const base = config.baseURL.replace(/\/+$/, '');
|
||||
const path = url.startsWith('/') ? url : `/${url}`;
|
||||
fullUrl = `${base}${path}`;
|
||||
}
|
||||
|
||||
if (config.params) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(config.params)) {
|
||||
if (value !== undefined) {
|
||||
search.append(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = search.toString();
|
||||
if (qs) {
|
||||
fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs;
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
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' });
|
||||
},
|
||||
};
|
||||
@@ -25,6 +25,8 @@ import { unlink } from 'node:fs/promises';
|
||||
|
||||
import type { UpdateCheckResult } from '../../shared/types';
|
||||
import { APP_VERSION } from '../../shared/constants/version';
|
||||
import { logger } from './logger';
|
||||
import { netRequest } from './net-request';
|
||||
|
||||
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
|
||||
|
||||
@@ -59,7 +61,7 @@ export function initUpdater(mainWindow: BrowserWindow): void {
|
||||
updateWindow = mainWindow;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Updater] 开发模式,跳过自动检查更新。API:', getApiBaseUrl());
|
||||
logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,43 +76,30 @@ export function initUpdater(mainWindow: BrowserWindow): void {
|
||||
// ============================================================
|
||||
|
||||
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}`;
|
||||
const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
|
||||
'/api/v1/update/check',
|
||||
{
|
||||
baseURL: getApiBaseUrl(),
|
||||
params: {
|
||||
platform: process.platform,
|
||||
version: APP_VERSION,
|
||||
edition: process.env.EDITION || 'personal',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
console.log('[Updater] 请求版本检查 API:', apiUrl);
|
||||
// 兼容两种 API 响应格式:
|
||||
// 格式 A: { code: 0, data: UpdateCheckResult }
|
||||
// 格式 B: UpdateCheckResult 直接返回
|
||||
const result: UpdateCheckResult = (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = net.request({ method: 'GET', url: apiUrl });
|
||||
if (!result.hasUpdate) {
|
||||
logger.info('updater', '当前已是最新版本');
|
||||
return null;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
logger.info('updater', '发现新版本', { latestVersion: result.latestVersion });
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -122,8 +111,7 @@ async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
|
||||
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);
|
||||
logger.info('updater', '开始下载', { downloadUrl: info.downloadUrl, tempPath });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = net.request({ method: 'GET', url: info.downloadUrl });
|
||||
@@ -174,7 +162,7 @@ async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Updater] 下载完成,SHA512 校验通过');
|
||||
logger.info('updater', '下载完成,SHA512 校验通过');
|
||||
pendingInstallerPath = tempPath;
|
||||
resolve(tempPath);
|
||||
});
|
||||
@@ -203,12 +191,12 @@ async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
|
||||
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
if (updateChecked) {
|
||||
console.log('[Updater] 已检查过更新,跳过');
|
||||
logger.debug('updater', '已检查过更新,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Updater] 开发模式,跳过更新检查');
|
||||
logger.debug('updater', '开发模式,跳过更新检查');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,7 +223,7 @@ export async function checkForUpdates(): Promise<void> {
|
||||
// 下载完成 → 通知渲染进程
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||||
} catch (error) {
|
||||
console.error('[Updater] 更新流程失败:', (error as Error).message);
|
||||
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '更新检查失败',
|
||||
});
|
||||
@@ -263,7 +251,7 @@ export async function checkForUpdatesManual(): Promise<void> {
|
||||
await downloadInstaller(info);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||||
} catch (error) {
|
||||
console.error('[Updater] 手动更新失败:', (error as Error).message);
|
||||
logger.error('updater', '手动更新失败', error as Error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '检查更新失败',
|
||||
});
|
||||
@@ -272,11 +260,11 @@ export async function checkForUpdatesManual(): Promise<void> {
|
||||
|
||||
export function installUpdateNow(): void {
|
||||
if (!pendingInstallerPath) {
|
||||
console.error('[Updater] 没有待安装的更新文件');
|
||||
logger.warn('updater', '没有待安装的更新文件');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Updater] 开始安装更新:', pendingInstallerPath);
|
||||
logger.info('updater', '开始安装更新', { installerPath: pendingInstallerPath });
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: NSIS 安装器,/S 静默安装
|
||||
@@ -292,7 +280,7 @@ export function installUpdateNow(): void {
|
||||
});
|
||||
} else {
|
||||
// Linux: AppImage,chmod +x 后执行(通常需要手动替换)
|
||||
console.log('[Updater] Linux 更新请手动替换 AppImage 文件');
|
||||
logger.warn('updater', 'Linux 更新需手动替换 AppImage 文件');
|
||||
}
|
||||
|
||||
// 退出应用,让安装器接管
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
<!-- 使用项目 Logo 作为 favicon(Windows 可用 .ico) -->
|
||||
<link rel="icon" type="image/png" href="/src/assets/logo/HX.png" />
|
||||
<!-- <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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
1356
package-lock.json
generated
1356
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,18 +23,20 @@
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
"test:e2e": "playwright test.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^6.4.3",
|
||||
"axios": "^1.16.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.16.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.heixiu.electron",
|
||||
"productName": "船长·HeiXiu",
|
||||
"asar": true,
|
||||
"differential": true,
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
|
||||
@@ -40,6 +40,12 @@ export interface AppConfig {
|
||||
// 更新相关类型
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// 日志类型(从 logging.ts 重导出)
|
||||
// ============================================================
|
||||
|
||||
export type { LogLevel, LogCategory, LogErrorSnapshot, LogEntry } from './logging';
|
||||
|
||||
/** 更新检查请求参数 */
|
||||
export interface UpdateCheckParams {
|
||||
platform: Platform;
|
||||
|
||||
51
shared/types/logging.ts
Normal file
51
shared/types/logging.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// ============================================================
|
||||
// 日志模块 — 共享类型定义(主进程 + 渲染进程双端使用)
|
||||
// ============================================================
|
||||
|
||||
/** 日志级别 */
|
||||
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
/** 日志分类 */
|
||||
export type LogCategory =
|
||||
| 'request' // HTTP 请求相关
|
||||
| 'auth' // 登录 / Token / 权限
|
||||
| 'updater' // 自动更新
|
||||
| 'event' // 事件总线
|
||||
| 'app' // 应用生命周期
|
||||
| 'ui' // UI 层错误 / 警告
|
||||
| 'ipc'; // IPC 通信
|
||||
|
||||
/**
|
||||
* 结构化错误快照
|
||||
* 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
182
shared/utils/sanitize.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// ============================================================
|
||||
// 日志脱敏工具 — 主进程 + 渲染进程共用
|
||||
//
|
||||
// 设计:在 createEntry 阶段统一对 context 字段脱敏,调用方无感知
|
||||
//
|
||||
// 脱敏策略(按字段名模式匹配,大小写不敏感):
|
||||
// *token* / *secret* / *password* / *pwd* → [REDACTED]
|
||||
// *email* → 保留首字符 + 域名(t***@qq.com)
|
||||
// *phone* / *mobile* / *tel* → 保留首 3 尾 4(138****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;
|
||||
}
|
||||
291
src/App.tsx
291
src/App.tsx
@@ -1,56 +1,21 @@
|
||||
import { useState, useEffect, useCallback } 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 { HashRouter } from 'react-router-dom';
|
||||
|
||||
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 { NavBar } from './components/navbar';
|
||||
import { LoginPage } from './pages/login';
|
||||
import { on, off, EVENTS } from './utils/event-bus';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
import { AppRoutes } from './router';
|
||||
|
||||
/**
|
||||
* 根组件 — 主窗口
|
||||
* 未登录时弹出登录 Modal;已登录显示主页
|
||||
* axios 返回 401 → 事件总线触发 → Modal 再次弹出
|
||||
*
|
||||
* 层级:
|
||||
* HashRouter
|
||||
* ├── LoginPage(Modal 弹层,事件总线驱动)
|
||||
* ├── AppRoutes(页面路由 + SettingsPage Drawer 叠加)
|
||||
*/
|
||||
function App() {
|
||||
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();
|
||||
const { isLoggedIn } = useAppContext();
|
||||
|
||||
// 登录 Modal 状态
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
@@ -78,245 +43,13 @@ function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HashRouter>
|
||||
{/* 登录 Modal — 可关闭,关闭后主页可操作 */}
|
||||
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
|
||||
|
||||
{/* ========== 主页 ========== */}
|
||||
<div
|
||||
className="min-h-screen flex flex-col transition-colors duration-300"
|
||||
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>
|
||||
</>
|
||||
{/* 页面路由 + SettingsPage Drawer 叠加 */}
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,23 @@
|
||||
import { useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||
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 { safeIpcOn, safeIpcOff } from '../utils/ipc';
|
||||
import { AppContext } from '../contexts/app-context';
|
||||
import type { AppContextValue } from '../contexts/app-context';
|
||||
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
||||
import { AppContext } from '@/contexts/app-context';
|
||||
import type { AppContextValue } from '@/contexts/app-context';
|
||||
import type { LoginResponseBody } from '@/services/modules';
|
||||
import { setToken, clearToken } 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,
|
||||
} from '@/services/auth-token';
|
||||
|
||||
interface AppProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -21,25 +33,91 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
return parseEdition(import.meta.env.VITE_EDITION);
|
||||
});
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [user, setUser] = useState<LoginResponseBody | null>(null);
|
||||
|
||||
const login = useCallback(() => {
|
||||
// TODO: 接入真实登录流程
|
||||
// ---------- 登录 / 退出 ----------
|
||||
|
||||
const login = useCallback((auth: LoginResponseBody) => {
|
||||
setToken(auth.access_token);
|
||||
setStoredRefreshToken(auth.refresh_token);
|
||||
setUser(auth);
|
||||
setIsLoggedIn(true);
|
||||
logger.info('auth', 'User logged in', {
|
||||
userId: auth.user_id,
|
||||
username: auth.username,
|
||||
});
|
||||
emit(EVENTS.LOGIN_SUCCESS, auth);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
// TODO: 清理 token、用户状态等
|
||||
logger.info('auth', 'User logged out');
|
||||
clearToken();
|
||||
clearStoredRefreshToken();
|
||||
setUser(null);
|
||||
setIsLoggedIn(false);
|
||||
emit(EVENTS.LOGOUT);
|
||||
}, []);
|
||||
|
||||
// 监听主进程推送的平台/版本信息
|
||||
// ---------- 初始化 token 刷新回调(给 request.ts 401 拦截器用) ----------
|
||||
|
||||
useEffect(() => {
|
||||
initAuthRefresh();
|
||||
}, []);
|
||||
|
||||
// ---------- 启动时自动登录(refresh_token 续期) ----------
|
||||
|
||||
useEffect(() => {
|
||||
if (!getAutoLoginFlag()) return;
|
||||
|
||||
const refreshToken = getStoredRefreshToken();
|
||||
if (!refreshToken) return;
|
||||
|
||||
AuthAPI.refreshToken({ refresh_token: refreshToken })
|
||||
.then((res) => {
|
||||
setToken(res.access_token);
|
||||
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(() => {
|
||||
const handler = (_event: unknown, info: unknown) => {
|
||||
const data = info as { platform?: string; edition?: string } | undefined;
|
||||
if (data) {
|
||||
if (isDev()) {
|
||||
console.log('[AppContext] 主进程平台信息:', data);
|
||||
}
|
||||
logger.debug('app', 'Received platform info from main process', data as Record<string, unknown>);
|
||||
}
|
||||
};
|
||||
safeIpcOn('platform-info', handler);
|
||||
@@ -52,6 +130,7 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
platform,
|
||||
edition,
|
||||
isLoggedIn,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
isDevMode: isDev(),
|
||||
|
||||
21
src/components/Layout.tsx
Normal file
21
src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/SettingsProvider.tsx
Normal file
64
src/components/SettingsProvider.tsx
Normal 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>;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// ============================================================
|
||||
|
||||
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 { fetchAnnouncements, type Announcement } from '@/services/modules';
|
||||
@@ -29,13 +29,15 @@ const typeLabelMap: Record<string, string> = {
|
||||
maintenance: '维护',
|
||||
};
|
||||
|
||||
/** 格式化日期(YYYY-MM-DD) */
|
||||
/** 格式化日期(YYYY-MM-DD HH:mm) */
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
@@ -54,6 +56,9 @@ export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 详情 Modal
|
||||
const [detail, setDetail] = useState<Announcement | null>(null);
|
||||
|
||||
// 打开抽屉时请求公告列表
|
||||
const loadAnnouncements = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -79,69 +84,121 @@ export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
|
||||
// ---- 渲染 ----
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<NotificationOutlined />
|
||||
公告
|
||||
</span>
|
||||
}
|
||||
placement="right"
|
||||
size={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spin description="加载中..." />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description={error} />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description="暂无公告" />
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
dataSource={list}
|
||||
renderItem={(item) => (
|
||||
<List.Item className="px-6! py-4!">
|
||||
<div className="w-full space-y-2">
|
||||
{/* 标题 + 类型标签 */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Text strong className="text-sm flex-1" ellipsis>
|
||||
{item.pinned && '📌 '}
|
||||
{item.title}
|
||||
</Text>
|
||||
<Tag
|
||||
color={typeColorMap[item.type] || 'default'}
|
||||
className="m-0! text-xs shrink-0"
|
||||
>
|
||||
{typeLabelMap[item.type] || item.type}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<Paragraph
|
||||
className="mb-0! text-xs"
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
<>
|
||||
<Drawer
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<NotificationOutlined />
|
||||
公告
|
||||
</span>
|
||||
}
|
||||
placement="right"
|
||||
size={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spin description="加载中..." />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description={error} />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description="暂无公告" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{list.map((item) => (
|
||||
<Tooltip
|
||||
key={item.id}
|
||||
title={
|
||||
item.content.length > 100
|
||||
? item.content.slice(0, 200) + '…'
|
||||
: 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.title}
|
||||
</Text>
|
||||
}
|
||||
extra={
|
||||
<Tag
|
||||
color={typeColorMap[item.type] || 'default'}
|
||||
className="m-0! text-xs"
|
||||
>
|
||||
{typeLabelMap[item.type] || item.type}
|
||||
</Tag>
|
||||
}
|
||||
onClick={() => setDetail(item)}
|
||||
>
|
||||
{item.content}
|
||||
</Paragraph>
|
||||
<Paragraph
|
||||
className="mb-2! text-xs"
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{item.content}
|
||||
</Paragraph>
|
||||
<Text type="secondary" className="text-xs">
|
||||
{formatDate(item.created_at)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* 日期 */}
|
||||
<Text type="secondary" className="text-xs">
|
||||
{formatDate(item.created_at)}
|
||||
</Text>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { App } from 'antd';
|
||||
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
@@ -24,6 +25,7 @@ export function NavBar() {
|
||||
const { platform, edition, isLoggedIn, logout } = useAppContext();
|
||||
const { isDark } = useTheme();
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
@@ -90,8 +92,8 @@ export function NavBar() {
|
||||
case 'spa':
|
||||
if (item.key === 'auth') {
|
||||
emit(EVENTS.SHOW_LOGIN);
|
||||
} else {
|
||||
console.log('[NavBar] 导航至:', item.target);
|
||||
} else if (item.target) {
|
||||
navigate(item.target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Platform, Edition } from '@shared/types';
|
||||
import type { LoginResponseBody } from '@/services/modules';
|
||||
|
||||
// ---------- Context 类型 ----------
|
||||
|
||||
@@ -14,9 +15,11 @@ export interface AppContextValue {
|
||||
edition: Edition;
|
||||
/** 用户是否已登录 */
|
||||
isLoggedIn: boolean;
|
||||
/** 登录(TODO: 接入真实认证) */
|
||||
login: () => void;
|
||||
/** 退出登录 */
|
||||
/** 当前登录用户信息(未登录时为 null,结构与 LoginResponseBody 一致) */
|
||||
user: LoginResponseBody | null;
|
||||
/** 登录:保存 Token 并更新登录态(auth 为登录接口返回值) */
|
||||
login: (auth: LoginResponseBody) => void;
|
||||
/** 退出登录:清除 Token 和用户状态 */
|
||||
logout: () => void;
|
||||
/** 是否为开发环境 */
|
||||
isDevMode: boolean;
|
||||
|
||||
94
src/contexts/settings-context.ts
Normal file
94
src/contexts/settings-context.ts
Normal 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;
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
// ============================================================
|
||||
// useUpdater — 渲染进程中的更新状态管理
|
||||
// useUpdater — 渲染进程中的更新状态管理(单例 IPC 监听)
|
||||
//
|
||||
// 用法:
|
||||
// const { status, progress, checkForUpdates, installUpdate } = useUpdater();
|
||||
//
|
||||
// 状态机:idle → checking → (no-update | available → downloading → downloaded → idle)
|
||||
//
|
||||
// 关键设计:
|
||||
// - IPC 监听器在模块级别只注册一次(单例),避免多个组件同时
|
||||
// 调用 useUpdater() 导致 MaxListenersExceededWarning
|
||||
// - 所有 useUpdater() 实例通过订阅机制共享同一份状态
|
||||
// ============================================================
|
||||
|
||||
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 =
|
||||
| 'idle' // 空闲
|
||||
| 'checking' // 正在检查
|
||||
| 'no-update' // 已是最新
|
||||
| 'available' // 发现新版本,准备下载
|
||||
| 'downloading' // 下载中
|
||||
| 'downloaded' // 下载完成,待安装
|
||||
| 'error'; // 出错
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'no-update'
|
||||
| 'available'
|
||||
| 'downloading'
|
||||
| 'downloaded'
|
||||
| 'error';
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
@@ -40,6 +45,43 @@ export interface UpdateError {
|
||||
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 保持一致)----------
|
||||
|
||||
const CH = {
|
||||
@@ -52,62 +94,73 @@ const CH = {
|
||||
CHECK_FOR_UPDATE: 'check-for-update',
|
||||
} 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 ----------
|
||||
|
||||
export function useUpdater() {
|
||||
const [status, setStatus] = useState<UpdaterStatus>('idle');
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
const [progress, setProgress] = useState<UpdateProgress>({ percent: 0 });
|
||||
const [error, setError] = useState<UpdateError | null>(null);
|
||||
|
||||
// 防止 StrictMode 双重监听
|
||||
const registered = useRef(false);
|
||||
const [state, setState] = useState<UpdaterState>(sharedState);
|
||||
const subscribed = useRef(false);
|
||||
|
||||
// 确保 IPC 监听器只注册一次
|
||||
useEffect(() => {
|
||||
if (registered.current) return;
|
||||
registered.current = true;
|
||||
ensureListeners();
|
||||
}, []);
|
||||
|
||||
safeIpcOn(CH.UPDATE_AVAILABLE, (_event: unknown, ...args: unknown[]) => {
|
||||
setInfo(args[0] as UpdateInfo);
|
||||
setStatus('available');
|
||||
});
|
||||
// 订阅模块级状态变更
|
||||
useEffect(() => {
|
||||
if (subscribed.current) return;
|
||||
subscribed.current = true;
|
||||
|
||||
safeIpcOn(CH.UPDATE_PROGRESS, (_event: unknown, ...args: unknown[]) => {
|
||||
setStatus('downloading');
|
||||
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');
|
||||
});
|
||||
const sub: Subscriber = (newState) => setState(newState);
|
||||
subscribers.add(sub);
|
||||
|
||||
return () => {
|
||||
safeIpcOff(CH.UPDATE_AVAILABLE, () => {});
|
||||
safeIpcOff(CH.UPDATE_PROGRESS, () => {});
|
||||
safeIpcOff(CH.UPDATE_DOWNLOADED, () => {});
|
||||
safeIpcOff(CH.UPDATE_ERROR, () => {});
|
||||
safeIpcOff(CH.UPDATE_NOT_AVAILABLE, () => {});
|
||||
subscribers.delete(sub);
|
||||
subscribed.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 同步初始状态(防止在订阅之前状态已变更)
|
||||
useEffect(() => {
|
||||
setState(sharedState);
|
||||
}, []);
|
||||
|
||||
/** 手动检查更新 */
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
setStatus('checking');
|
||||
setError(null);
|
||||
setSharedState({ status: 'checking', error: null });
|
||||
try {
|
||||
await safeIpcInvoke(CH.CHECK_FOR_UPDATE);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setError({ message: (err as Error).message || '检查更新失败' });
|
||||
setSharedState({
|
||||
status: 'error',
|
||||
error: { message: (err as Error).message || '检查更新失败' },
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -118,17 +171,19 @@ export function useUpdater() {
|
||||
|
||||
/** 重置状态 */
|
||||
const reset = useCallback(() => {
|
||||
setStatus('idle');
|
||||
setInfo(null);
|
||||
setProgress({ percent: 0 });
|
||||
setError(null);
|
||||
setSharedState({
|
||||
status: 'idle',
|
||||
info: null,
|
||||
progress: { percent: 0 },
|
||||
error: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status,
|
||||
info,
|
||||
progress,
|
||||
error,
|
||||
status: state.status,
|
||||
info: state.info,
|
||||
progress: state.progress,
|
||||
error: state.error,
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
reset,
|
||||
|
||||
31
src/main.tsx
31
src/main.tsx
@@ -5,6 +5,7 @@ import { App as AntdApp } from 'antd';
|
||||
import App from './App';
|
||||
import { ThemeProvider } from './components/ThemeProvider';
|
||||
import { AppProvider } from './components/AppProvider';
|
||||
import { SettingsProvider } from './components/SettingsProvider';
|
||||
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
|
||||
import './theme/globals.css';
|
||||
|
||||
@@ -14,10 +15,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<AppProvider>
|
||||
{/* ThemeProvider 内部管理 ConfigProvider(亮色/暗色切换) */}
|
||||
<ThemeProvider>
|
||||
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
|
||||
<AntdApp>
|
||||
<App />
|
||||
</AntdApp>
|
||||
{/* SettingsProvider — 集中管理设置项(存储路径等) */}
|
||||
<SettingsProvider>
|
||||
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
|
||||
<AntdApp>
|
||||
<App />
|
||||
</AntdApp>
|
||||
</SettingsProvider>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
</React.StrictMode>,
|
||||
@@ -28,3 +32,22 @@ import { safeIpcOn } from './utils/ipc';
|
||||
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
|
||||
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
277
src/pages/home/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
// ============================================================
|
||||
// useAuthState — 记住密码 / 自动登录 状态管理
|
||||
// 持久化到 localStorage,下次打开页面自动回填
|
||||
// useAuthState — 记住账号 / 记住密码 / 自动登录 状态管理
|
||||
//
|
||||
// "记住密码":下次打开页面自动回填用户名 + 密码(Base64 编码)
|
||||
// "自动登录":登录成功时标记,AppProvider 启动时用 refresh_token 静默续期
|
||||
//
|
||||
// 注意:密码 Base64 仅作基本混淆,非安全加密。
|
||||
// 安全性由 refresh_token + access_token 的 JWT 双重机制保障。
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
@@ -8,33 +13,16 @@ import { useState, useCallback } from 'react';
|
||||
const STORAGE_KEY = 'ele-heixiu-auth';
|
||||
|
||||
interface StoredAuth {
|
||||
/** 上次登录的用户名 */
|
||||
/** 上次登录的用户名(表单回填用) */
|
||||
username: string;
|
||||
/** 加密后的密码(Base64 编码,非安全加密,仅方便回填) */
|
||||
password: string;
|
||||
/** 是否记住密码 */
|
||||
/** 密码的 Base64 编码(仅"记住密码"勾选时存储) */
|
||||
passwordBase64?: string;
|
||||
/** 是否记住密码(勾选后表单自动回填用户名+密码) */
|
||||
remember: boolean;
|
||||
/** 是否自动登录 */
|
||||
/** 是否勾选了自动登录(AppProvider 据此决定启动时是否刷新 token) */
|
||||
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 {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -45,7 +33,6 @@ function readAuth(): StoredAuth | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入 localStorage */
|
||||
function writeAuth(auth: StoredAuth): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
||||
@@ -54,8 +41,33 @@ function writeAuth(auth: StoredAuth): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除 localStorage */
|
||||
function clearAuth(): void {
|
||||
// ---------- 工具函数(不依赖 React,AppProvider 可直接调用)----------
|
||||
|
||||
/** 读取自动登录标记 */
|
||||
export function getAutoLoginFlag(): boolean {
|
||||
return readAuth()?.autoLogin || false;
|
||||
}
|
||||
|
||||
/** 读取存储的密码(Base64 解码后返回明文,表单回填用) */
|
||||
export function getStoredPassword(): string {
|
||||
const auth = readAuth();
|
||||
if (!auth?.passwordBase64) return '';
|
||||
try {
|
||||
return atob(auth.passwordBase64);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅清除自动登录标记(保留用户名和密码,下次还能回填表单) */
|
||||
export function clearAutoLoginFlag(): void {
|
||||
const auth = readAuth();
|
||||
if (!auth) return;
|
||||
writeAuth({ ...auth, autoLogin: false });
|
||||
}
|
||||
|
||||
/** 清除所有保存的认证信息 */
|
||||
export function clearSavedAuth(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
@@ -66,66 +78,64 @@ function clearAuth(): void {
|
||||
// ---------- Hook ----------
|
||||
|
||||
export function useAuthState() {
|
||||
// 初始化:从 localStorage 恢复
|
||||
const [username, setUsername] = useState(() => readAuth()?.username || '');
|
||||
const [password, setPassword] = useState(() => {
|
||||
const auth = readAuth();
|
||||
return auth?.remember ? decode(auth.password) : '';
|
||||
});
|
||||
const [remember, setRemember] = useState(() => readAuth()?.remember || false);
|
||||
const [autoLogin, setAutoLogin] = useState(() => readAuth()?.autoLogin || false);
|
||||
const saved = readAuth();
|
||||
|
||||
const [username, setUsername] = useState(saved?.username || '');
|
||||
// 密码不存 state,直接从 localStorage 读取,避免 stale closure 问题
|
||||
const [remember, setRemember] = useState(saved?.remember || false);
|
||||
const [autoLogin, setAutoLogin] = useState(saved?.autoLogin || false);
|
||||
|
||||
// 勾选"记住密码"变化时持久化
|
||||
const handleRememberChange = useCallback((checked: boolean) => {
|
||||
setRemember(checked);
|
||||
if (!checked) {
|
||||
// 取消记住密码 → 同时取消自动登录
|
||||
setAutoLogin(false);
|
||||
clearAuth();
|
||||
clearSavedAuth();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 勾选"自动登录"变化时持久化
|
||||
const handleAutoLoginChange = useCallback((checked: boolean) => {
|
||||
setAutoLogin(checked);
|
||||
}, []);
|
||||
|
||||
// 登录成功后保存
|
||||
/**
|
||||
* 登录成功后调用。
|
||||
* 参数由调用方(LoginPage)直接从表单值传入,避免 useCallback 的 stale closure。
|
||||
*
|
||||
* @param user - 用户名
|
||||
* @param password - 明文密码(仅在"记住密码"勾选时存储,Base64 编码)
|
||||
* @param doRemember - 是否勾选"记住密码"
|
||||
* @param doAutoLogin - 是否勾选"自动登录"
|
||||
*/
|
||||
const saveAuth = useCallback(
|
||||
(user: string, pass: string) => {
|
||||
(user: string, password: string, doRemember: boolean, doAutoLogin: boolean) => {
|
||||
setUsername(user);
|
||||
if (remember) {
|
||||
setPassword(pass);
|
||||
writeAuth({
|
||||
if (doRemember || doAutoLogin) {
|
||||
const payload: StoredAuth = {
|
||||
username: user,
|
||||
password: encode(pass),
|
||||
remember,
|
||||
autoLogin,
|
||||
});
|
||||
remember: doRemember,
|
||||
autoLogin: doAutoLogin,
|
||||
};
|
||||
// 仅在勾选"记住密码"时存储密码
|
||||
if (doRemember && password) {
|
||||
try {
|
||||
payload.passwordBase64 = btoa(password);
|
||||
} catch {
|
||||
/* Base64 编码失败则放弃存储密码 */
|
||||
}
|
||||
}
|
||||
writeAuth(payload);
|
||||
}
|
||||
},
|
||||
[remember, autoLogin],
|
||||
[],
|
||||
);
|
||||
|
||||
// 清除保存的认证信息
|
||||
const clearSavedAuth = useCallback(() => {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setRemember(false);
|
||||
setAutoLogin(false);
|
||||
clearAuth();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
remember,
|
||||
autoLogin,
|
||||
setUsername,
|
||||
setPassword,
|
||||
handleRememberChange,
|
||||
handleAutoLoginChange,
|
||||
saveAuth,
|
||||
clearSavedAuth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import { useAppContext } from '@/contexts/app-context.ts';
|
||||
import { useTheme } from '@/hooks/use-theme.ts';
|
||||
import { LoginForm } from './components/LoginForm';
|
||||
import { RegisterForm } from './components/RegisterForm';
|
||||
import { useAuthState } from './hooks/use-auth-state';
|
||||
import { useAuthState, getStoredPassword } from './hooks/use-auth-state';
|
||||
import {AuthAPI, LoginResponseBody} from '@/services/modules';
|
||||
import { getDeviceId } from '@/utils/device';
|
||||
import type { LoginFormValues, RegisterFormValues } from './types';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -41,14 +43,23 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
async (values: LoginFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// TODO: 接入登录 API
|
||||
console.log('[Login]', values);
|
||||
authState.saveAuth(values.username, values.password);
|
||||
message.success('登录成功(Demo)');
|
||||
login();
|
||||
const auth:LoginResponseBody = await AuthAPI.login({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
device_id: getDeviceId(),
|
||||
user_ip: '',
|
||||
});
|
||||
|
||||
// 记住偏好(密码 Base64 编码,自动登录靠 refresh_token)
|
||||
authState.saveAuth(values.username, values.password, values.remember, values.autoLogin);
|
||||
|
||||
// 更新全局登录态(内部完成 setToken + emit LOGIN_SUCCESS)
|
||||
login(auth);
|
||||
|
||||
message.success(`登录成功,欢迎 ${auth.display_name || auth.username}`);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || '登录失败');
|
||||
message.error((err as Error).message || '登录失败,请检查用户名和密码');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -62,8 +73,17 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
async (values: RegisterFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
console.log('[Register]', values);
|
||||
message.success('注册成功(Demo),请登录');
|
||||
await AuthAPI.register({
|
||||
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');
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || '注册失败');
|
||||
@@ -75,9 +95,13 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
);
|
||||
|
||||
const handleSendSms = useCallback(
|
||||
(phone: string) => {
|
||||
console.log('[SMS]', phone);
|
||||
message.info(`验证码已发送到 ${phone}(Demo)`);
|
||||
async (phone: string) => {
|
||||
try {
|
||||
await AuthAPI.sendSms({ phone });
|
||||
message.success('验证码已发送');
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || '验证码发送失败');
|
||||
}
|
||||
},
|
||||
[message],
|
||||
);
|
||||
@@ -87,7 +111,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
const loginTab = (
|
||||
<LoginForm
|
||||
initialUsername={authState.username}
|
||||
initialPassword={authState.password}
|
||||
initialPassword={getStoredPassword()}
|
||||
initialRemember={authState.remember}
|
||||
initialAutoLogin={authState.autoLogin}
|
||||
submitting={submitting}
|
||||
|
||||
55
src/pages/settings/SettingsPage.tsx
Normal file
55
src/pages/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
src/pages/settings/StoragePathSetting.tsx
Normal file
188
src/pages/settings/StoragePathSetting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/pages/settings/SystemInfoSetting.tsx
Normal file
65
src/pages/settings/SystemInfoSetting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/pages/settings/ThemeSetting.tsx
Normal file
34
src/pages/settings/ThemeSetting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/pages/settings/UpdateSetting.tsx
Normal file
122
src/pages/settings/UpdateSetting.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// ============================================================
|
||||
// UpdateSetting — 版本更新区块
|
||||
// ============================================================
|
||||
|
||||
import { Card, Button, Typography, Progress, Tag } from 'antd';
|
||||
import {
|
||||
SyncOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
RocketOutlined,
|
||||
DownloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useUpdater } from '@/hooks/use-updater';
|
||||
import { isDev } from '@/utils/platform';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function UpdateSetting() {
|
||||
const {
|
||||
status,
|
||||
info,
|
||||
progress,
|
||||
error,
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
} = useUpdater();
|
||||
|
||||
return (
|
||||
<Card size="small" title="版本更新" className="rounded-md!">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* ---- 状态展示 ---- */}
|
||||
{status === 'idle' && (
|
||||
<Text type="secondary" className="text-sm">
|
||||
点击下方按钮检查是否有新版本
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{status === 'checking' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<SyncOutlined spin style={{ color: 'var(--color-primary, #4F46E5)' }} />
|
||||
<Text type="secondary">正在检查更新...</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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' || status === 'downloading') && (
|
||||
<>
|
||||
<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 && (
|
||||
<Text type="secondary" className="text-xs whitespace-pre-line">
|
||||
{info.releaseNotes}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- 下载进度 ---- */}
|
||||
{status === 'downloading' && (
|
||||
<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'}
|
||||
>
|
||||
检查更新
|
||||
</Button>
|
||||
{isDev() && (
|
||||
<Text type="secondary" className="text-xs">
|
||||
生产环境启动 5 秒后自动检查
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
9
src/pages/settings/index.ts
Normal file
9
src/pages/settings/index.ts
Normal 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
26
src/router/index.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
src/services/auth-token.ts
Normal file
70
src/services/auth-token.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// ============================================================
|
||||
// auth-token — Token 生命周期管理(存取 refresh_token + 注册刷新回调)
|
||||
//
|
||||
// 职责:
|
||||
// - 持久化 / 清除 refresh_token
|
||||
// - 向 request.ts 注册 handle401 触发的 token 刷新回调
|
||||
// - request.ts 不依赖此模块(通过 setTokenRefreshHandler 解耦)
|
||||
// ============================================================
|
||||
|
||||
import { setToken, setTokenRefreshHandler } from './request';
|
||||
import { AuthAPI } from './modules';
|
||||
import type { RefreshTokenResponseBody } from './modules';
|
||||
|
||||
const REFRESH_KEY = 'ele-heixiu-refresh-token';
|
||||
|
||||
// ---------- 存取 ----------
|
||||
|
||||
export function getStoredRefreshToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredRefreshToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(REFRESH_KEY, token);
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredRefreshToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 初始化(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
|
||||
setToken(res.access_token);
|
||||
setStoredRefreshToken(res.refresh_token);
|
||||
|
||||
return res.access_token;
|
||||
} catch {
|
||||
// 刷新失败 → 清除残留
|
||||
clearStoredRefreshToken();
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,14 @@
|
||||
|
||||
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
|
||||
*/
|
||||
export function fetchAnnouncements(): Promise<AnnouncementListResponse> {
|
||||
return get<AnnouncementListResponse>('/api/v1/announcements');
|
||||
return get<AnnouncementListResponse>(AnnouncementUrlObj.list);
|
||||
}
|
||||
|
||||
190
src/services/modules/auth.ts
Normal file
190
src/services/modules/auth.ts
Normal 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;
|
||||
// 设备标识(网卡 MAC),8~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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// ============================================================
|
||||
// API 模块统一导出
|
||||
// 后续新增模块(如 auth、user、billing 等)在此追加
|
||||
// ============================================================
|
||||
|
||||
export {
|
||||
@@ -9,3 +8,17 @@ export {
|
||||
type AnnouncementType,
|
||||
type AnnouncementListResponse,
|
||||
} 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';
|
||||
|
||||
@@ -16,19 +16,28 @@ import axios, {
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
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 { logger } from '../utils/logger';
|
||||
|
||||
// ---------- 配置 ----------
|
||||
|
||||
/** API 基础地址(可通过 Vite 环境变量覆盖) */
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
/** 请求超时时间(毫秒) */
|
||||
const TIMEOUT = 30000;
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
/** localStorage 中 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 */
|
||||
@@ -58,6 +67,30 @@ export function clearToken(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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 实例 ----------
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
@@ -84,6 +117,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(
|
||||
@@ -92,51 +180,94 @@ instance.interceptors.response.use(
|
||||
|
||||
// HTTP 200 但业务码非 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,
|
||||
traceId: data.traceId,
|
||||
});
|
||||
|
||||
// 特殊业务码处理
|
||||
if (data.code === 401) {
|
||||
clearToken();
|
||||
emit(EVENTS.AUTH_REQUIRED);
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
return Promise.reject(
|
||||
new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
|
||||
code: data.code,
|
||||
traceId: data.traceId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return response;
|
||||
},
|
||||
(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)) {
|
||||
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') {
|
||||
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) {
|
||||
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 状态码错误
|
||||
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 = '请求失败';
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
errMsg = '请求参数有误';
|
||||
break;
|
||||
case 401:
|
||||
errMsg = '登录已过期,请重新登录';
|
||||
clearToken();
|
||||
emit(EVENTS.AUTH_REQUIRED);
|
||||
break;
|
||||
case 403:
|
||||
errMsg = '没有访问权限';
|
||||
break;
|
||||
@@ -154,12 +285,19 @@ instance.interceptors.response.use(
|
||||
break;
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
|
||||
httpStatus: status,
|
||||
code: data?.code,
|
||||
}),
|
||||
);
|
||||
const httpErr = new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
|
||||
httpStatus: status,
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ export enum RequestErrorType {
|
||||
BUSINESS = 'BUSINESS',
|
||||
/** 请求被取消 */
|
||||
CANCELLED = 'CANCELLED',
|
||||
/** 认证过期(refresh_token 失效,需重新登录) */
|
||||
AUTH_EXPIRED = 'AUTH_EXPIRED',
|
||||
}
|
||||
|
||||
/** 请求错误 */
|
||||
@@ -69,3 +71,23 @@ export class RequestError extends Error {
|
||||
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
29
src/utils/device.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,21 @@ type Listener = (...args: unknown[]) => void;
|
||||
|
||||
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 {
|
||||
if (!listeners.has(event)) {
|
||||
@@ -22,6 +37,16 @@ export function off(event: string, fn: Listener): void {
|
||||
|
||||
/** 触发事件 */
|
||||
export function emit(event: string, ...args: unknown[]): void {
|
||||
// ① 通知日志监听器(在触发业务监听器之前记录)
|
||||
if (eventLogListener) {
|
||||
try {
|
||||
eventLogListener(event, ...args);
|
||||
} catch {
|
||||
/* 日志钩子异常不影响事件投递 */
|
||||
}
|
||||
}
|
||||
|
||||
// ② 通知业务监听器
|
||||
listeners.get(event)?.forEach((fn) => {
|
||||
try {
|
||||
fn(...args);
|
||||
|
||||
140
src/utils/logger.ts
Normal file
140
src/utils/logger.ts
Normal 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();
|
||||
@@ -8,13 +8,25 @@ import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
// ============================================================
|
||||
// 热更新说明
|
||||
// ============================================================
|
||||
// 修改 src/ → React Fast Refresh(组件级热替换,不丢失状态)
|
||||
// 修改 electron/ → 主进程重建 + Electron 自动重启(预期行为)
|
||||
// 修改 shared/ → 因为 shared/ 被主进程引用,也会触发重启
|
||||
// 如需避免,可把仅渲染进程用的类型放到 src/types/
|
||||
// ============================================================
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
electron({
|
||||
main: {
|
||||
// 主进程入口
|
||||
entry: 'electron/main.ts',
|
||||
// 只监听 electron/ 目录,避免 shared/ 变更误触重启
|
||||
// (默认会监听 entry 文件的所有依赖,包括 shared/)
|
||||
},
|
||||
preload: {
|
||||
input: path.join(__dirname, 'electron/preload.ts'),
|
||||
@@ -36,6 +48,29 @@ export default defineConfig({
|
||||
'@shared': path.resolve(__dirname, 'shared'),
|
||||
},
|
||||
},
|
||||
// ============================================================
|
||||
// 开发服务器 & 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/**'],
|
||||
},
|
||||
// HMR 配置
|
||||
hmr: {
|
||||
// 在浏览器控制台显示 HMR 覆盖层(错误/警告时)
|
||||
overlay: true,
|
||||
// 协议默认 ws://,localhost 下无需额外配置
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {},
|
||||
|
||||
Reference in New Issue
Block a user