feat: 实现设置页面 + 路由系统 + JWT认证体系 + 公告优化 + 错误类型系统

## 新增功能

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

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

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

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

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

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

## 依赖
- 新增 react-router-dom
This commit is contained in:
2026-06-03 19:25:15 +08:00
parent 767bb8b297
commit 3becdb85d4
34 changed files with 2253 additions and 1880 deletions

203
ARCHITECTURE.md Normal file
View 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 重试 │
├─────────────────────────────────────────────────┤
│ 存储层 │
│ localStorageaccess_token / refresh_token / │
│ device-id / auth 偏好) │
└─────────────────────────────────────────────────┘
```
### 层间依赖规则
- **UI 层** → 可以 import 状态层 + 服务层
- **状态层** → 可以 import 服务层
- **服务层** → 可以 import 工具层event-bus不 import UI 或状态层
- **request.ts** → 通用 HTTP 层,**不 import 任何 auth 模块**(通过 `setTokenRefreshHandler` 解耦)
---
## 核心流程
### 1. 登录
```
LoginForm.onSubmit(values)
→ LoginPage.handleLogin
→ AuthAPI.login({ username, password, device_id, user_ip })
→ AppProvider.login(auth)
├─ setToken(access_token) → localStorage
├─ setStoredRefreshToken(...) → localStorage
├─ setUser(auth) → state
├─ setIsLoggedIn(true) → state
└─ emit(LOGIN_SUCCESS, auth) → 事件总线
→ onClose() → 关闭登录弹窗
```
### 2. 自动登录(静默 refresh
```
App 启动 → AppProvider useEffect
→ getAutoLoginFlag() === true ?
→ 是 → getStoredRefreshToken()
→ AuthAPI.refreshToken({ refresh_token })
✅ 成功 → setToken + setUser + emit(LOGIN_SUCCESS) (无弹窗)
❌ 失败 → clearStoredRefreshToken + clearSavedAuth (下次需手动登录)
→ 否 → 跳过
```
### 3. 401 → 刷新 Token → 自动重试
```
任意请求发起
→ 服务端返回 401业务码 401 或 HTTP 401
→ request.ts handle401(config)
→ refreshHandler 已注册?
→ 是 → 调 refreshHandler()
→ auth-token.ts 回调
→ AuthAPI.refreshToken({ refresh_token })
✅ 成功 → 用新 token 重试原请求(用户完全无感)
❌ 失败 → clearToken + emit(AUTH_REQUIRED) → 弹出登录框
→ 否 → clearToken + emit(AUTH_REQUIRED) → 弹出登录框
并发 401 处理:
请求 A 收到 401 → 开始 refresh
请求 B 同时收到 401 → 发现 isRefreshing=true → 加入队列等待
refresh 完成 → 新 token 广播给所有排队请求 → 全部重试
```
### 4. 退出登录
```
NavBar / 设置页 → AppProvider.logout()
├─ clearToken()
├─ clearStoredRefreshToken()
├─ setUser(null)
├─ setIsLoggedIn(false)
└─ emit(LOGOUT)
```
---
## 关键模块说明
### request.ts通用 HTTP 层)
- 职责Axios 实例 + 拦截器 + `get/post/put/del` + 错误分类
- **不依赖**任何 auth 模块
- 暴露 `setTokenRefreshHandler(fn)` 供 auth-token.ts 注册 401 回调
- 暴露 `getToken()/setToken()/clearToken()` 供外部存取 access_token
- 401 拦截逻辑内置重试队列,支持并发请求去重
### auth-token.tsToken 生命周期)
- 职责refresh_token 的存取 + 注册 `handle401` 回调
- `initAuthRefresh()` 在 AppProvider 挂载时调用一次
- 回调内部调用 `AuthAPI.refreshToken()`,成则更新双 token败则清除
### event-bus.ts事件总线
- 纯发布-订阅,不依赖任何框架
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT`
### API 模块规范
每个 `services/modules/*.ts` 遵循统一范式:
1. `XxxUrlObj` — 模块路由常量(不 export模块私有
2. DTO 类型 — 请求体/响应体 interface
3. `XxxAPI` class — 静态方法,调用方式 `XxxAPI.method()`
示例:
```ts
// auth.ts
const AuthUrlObj = { login: '/api/v1/auth/login', ... } as const;
export interface LoginRequestBody { ... }
export interface LoginResponseBody { ... }
export class AuthAPI {
static login(data: LoginRequestBody): Promise<LoginResponseBody> {
return post(AuthUrlObj.login, data);
}
}
```
---
## 数据存储localStorage
| Key | 类型 | 说明 |
|-----|------|------|
| `ele-heixiu-token` | `string` | JWT access_token |
| `ele-heixiu-refresh-token` | `string` | JWT refresh_token |
| `ele-heixiu-device-id` | `string`UUID v4 | 设备唯一标识,首次自动生成 |
| `ele-heixiu-auth` | `{ username, remember, autoLogin }` | 记住账号偏好(**不存密码** |
---
## 环境变量
| 变量 | 开发值 | 生产值 |
|------|--------|--------|
| `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` |

View File

@@ -23,8 +23,8 @@
``` ```
| 角色 | 技术 | 职责 | | 角色 | 技术 | 职责 |
| ------ | -------------------------- | ------------------------------------------ | |-----|--------------------|---------------------------------|
| 客户端 | Electron + `net.request()` | 调用 API → 下载安装包 → 校验 SHA512 → 安装 | | 客户端 | Electron + `axios` | 调用 API → 下载安装包 → 校验 SHA512 → 安装 |
| ECS | 任意后端语言 | 提供版本检查 API | | ECS | 任意后端语言 | 提供版本检查 API |
| OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage | | OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage |
@@ -61,7 +61,7 @@ node scripts/publish-release.mjs <平台> <版本类型> [版本号] [--url <url
### `build.mjs` 操作组合 ### `build.mjs` 操作组合
| 操作 | 说明 | | 操作 | 说明 |
| ----------- | -------------------------------------------------------- | |-------------|------------------------------------------------------|
| `--build` | 构建tsc + vite + electron-builder + update-info.json | | `--build` | 构建tsc + vite + electron-builder + update-info.json |
| `--clean` | 清理旧产物 | | `--clean` | 清理旧产物 |
| `--sign` | 代码签名(配合 `--cert` 或环境变量) | | `--sign` | 代码签名(配合 `--cert` 或环境变量) |
@@ -128,7 +128,7 @@ node build.mjs --win --personal --upload --publish
### 证书获取 ### 证书获取
| 平台 | 格式 | 渠道 | 年费 | | 平台 | 格式 | 渠道 | 年费 |
| ------- | ----------------- | ------------------------------- | -------- | |---------|-------------------|---------------------------------|----------|
| Windows | `.pfx` / `.p12` | DigiCert / Sectigo / GlobalSign | $200-700 | | Windows | `.pfx` / `.p12` | DigiCert / Sectigo / GlobalSign | $200-700 |
| macOS | `.p12` / Keychain | Apple Developer Program | $99 | | macOS | `.p12` / Keychain | Apple Developer Program | $99 |
@@ -176,7 +176,12 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
**无更新** **无更新**
```json ```json
{ "code": 0, "data": { "hasUpdate": false } } {
"code": 0,
"data": {
"hasUpdate": false
}
}
``` ```
### `POST /api/v1/releases`(发布用) ### `POST /api/v1/releases`(发布用)
@@ -203,7 +208,8 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
## 六、数据库 ## 六、数据库
```sql ```sql
CREATE TABLE releases ( CREATE TABLE releases
(
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux', platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux',
edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise', edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise',
@@ -229,7 +235,7 @@ CREATE TABLE releases (
## 七、客户端行为 ## 七、客户端行为
| 场景 | 行为 | | 场景 | 行为 |
| -------- | ---------------------------------------------------- | |------|-----------------------------------|
| 启动 | 5 秒后静默检查更新(仅生产环境) | | 启动 | 5 秒后静默检查更新(仅生产环境) |
| 手动检查 | 点击按钮 → 调用 API → 反馈结果 | | 手动检查 | 点击按钮 → 调用 API → 反馈结果 |
| 下载 | 流式写入临时目录SHA512 实时校验,每 250ms 推送进度 | | 下载 | 流式写入临时目录SHA512 实时校验,每 250ms 推送进度 |
@@ -242,7 +248,7 @@ CREATE TABLE releases (
## 八、环境变量速查 ## 八、环境变量速查
| 变量 | 默认值 | 用途 | | 变量 | 默认值 | 用途 |
| ----------------------- | ------------------------------------------------------ | ----------------------- | |-------------------------|--------------------------------------------------------|----------------------|
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API 基地址 | | `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API 基地址 |
| `UPDATE_API_KEY` | — | 发布 API 鉴权 Key | | `UPDATE_API_KEY` | — | 发布 API 鉴权 Key |
| `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | 预判 URL 基地址 | | `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | 预判 URL 基地址 |

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
@@ -8,6 +8,7 @@ import { getWindowIconPath } from './main/utils/logo';
import { buildWindowTitle, parseEdition } from '../shared/constants/app'; import { buildWindowTitle, parseEdition } from '../shared/constants/app';
import { initUpdater, registerUpdateIpcHandlers } from './main/updater'; import { initUpdater, registerUpdateIpcHandlers } from './main/updater';
import { setupAppMenu } from './main/menu'; import { setupAppMenu } from './main/menu';
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
createRequire(import.meta.url); createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -119,6 +120,16 @@ app.whenReady().then(() => {
app.setName(WINDOW_TITLE); app.setName(WINDOW_TITLE);
registerUpdateIpcHandlers(); registerUpdateIpcHandlers();
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
if (!mainWindow) return { canceled: true, filePaths: [] };
return dialog.showOpenDialog(mainWindow, {
title: options?.title || '选择文件夹',
defaultPath: options?.defaultPath || app.getPath('home'),
properties: options?.properties || ['openDirectory'],
});
});
// 始终打开主窗口 // 始终打开主窗口
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口 // 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
const win = createMainWindow(); const win = createMainWindow();

View File

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

1356
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,8 @@
"antd": "^6.4.3", "antd": "^6.4.3",
"axios": "^1.16.1", "axios": "^1.16.1",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7",
"react-router-dom": "^7.16.0"
}, },
"build": { "build": {
"appId": "com.heixiu.electron", "appId": "com.heixiu.electron",

View File

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

View File

@@ -5,11 +5,22 @@
import { useState, useCallback, useEffect, type ReactNode } from 'react'; import { useState, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types'; import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '../utils/platform'; import { getPlatform, isDev } from '@/utils/platform';
import { parseEdition } from '@shared/constants/app'; import { parseEdition } from '@shared/constants/app';
import { safeIpcOn, safeIpcOff } from '../utils/ipc'; import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
import { AppContext } from '../contexts/app-context'; import { AppContext } from '@/contexts/app-context';
import type { AppContextValue } from '../contexts/app-context'; import type { AppContextValue } from '@/contexts/app-context';
import type { LoginResponseBody } from '@/services/modules';
import { setToken, clearToken } from '@/services/request';
import { emit, EVENTS } from '@/utils/event-bus';
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 { interface AppProviderProps {
children: ReactNode; children: ReactNode;
@@ -21,18 +32,79 @@ export function AppProvider({ children }: AppProviderProps) {
return parseEdition(import.meta.env.VITE_EDITION); return parseEdition(import.meta.env.VITE_EDITION);
}); });
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState<LoginResponseBody | null>(null);
const login = useCallback(() => { // ---------- 登录 / 退出 ----------
// TODO: 接入真实登录流程
const login = useCallback((auth: LoginResponseBody) => {
setToken(auth.access_token);
setStoredRefreshToken(auth.refresh_token);
setUser(auth);
setIsLoggedIn(true); setIsLoggedIn(true);
emit(EVENTS.LOGIN_SUCCESS, auth);
}, []); }, []);
const logout = useCallback(() => { const logout = useCallback(() => {
// TODO: 清理 token、用户状态等 clearToken();
clearStoredRefreshToken();
setUser(null);
setIsLoggedIn(false); 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);
// 补全用户信息
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(() => {
clearStoredRefreshToken();
// 仅清除自动登录标记,保留用户名+密码供下次表单回填
clearAutoLoginFlag();
});
}, []);
// ---------- 监听主进程推送的平台/版本信息 ----------
useEffect(() => { useEffect(() => {
const handler = (_event: unknown, info: unknown) => { const handler = (_event: unknown, info: unknown) => {
const data = info as { platform?: string; edition?: string } | undefined; const data = info as { platform?: string; edition?: string } | undefined;
@@ -52,6 +124,7 @@ export function AppProvider({ children }: AppProviderProps) {
platform, platform,
edition, edition,
isLoggedIn, isLoggedIn,
user,
login, login,
logout, logout,
isDevMode: isDev(), isDevMode: isDev(),

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { App as AntdApp } from 'antd';
import App from './App'; import App from './App';
import { ThemeProvider } from './components/ThemeProvider'; import { ThemeProvider } from './components/ThemeProvider';
import { AppProvider } from './components/AppProvider'; import { AppProvider } from './components/AppProvider';
import { SettingsProvider } from './components/SettingsProvider';
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置) // 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
import './theme/globals.css'; import './theme/globals.css';
@@ -14,10 +15,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<AppProvider> <AppProvider>
{/* ThemeProvider 内部管理 ConfigProvider亮色/暗色切换) */} {/* ThemeProvider 内部管理 ConfigProvider亮色/暗色切换) */}
<ThemeProvider> <ThemeProvider>
{/* SettingsProvider — 集中管理设置项(存储路径等) */}
<SettingsProvider>
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */} {/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
<AntdApp> <AntdApp>
<App /> <App />
</AntdApp> </AntdApp>
</SettingsProvider>
</ThemeProvider> </ThemeProvider>
</AppProvider> </AppProvider>
</React.StrictMode>, </React.StrictMode>,

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
}

View File

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

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

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

View File

@@ -0,0 +1,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;
}
});
}

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ import axios, {
type InternalAxiosRequestConfig, type InternalAxiosRequestConfig,
} from 'axios'; } from 'axios';
import type { ApiResponse } from './types'; import type { ApiResponse } from './types';
import { RequestError, RequestErrorType } from './types'; import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types';
import { emit, EVENTS } from '../utils/event-bus'; import { emit, EVENTS } from '../utils/event-bus';
// ---------- 配置 ---------- // ---------- 配置 ----------
@@ -24,11 +24,19 @@ import { emit, EVENTS } from '../utils/event-bus';
/** API 基础地址(可通过 Vite 环境变量覆盖) */ /** API 基础地址(可通过 Vite 环境变量覆盖) */
const BASE_URL = import.meta.env.VITE_API_BASE_URL; const BASE_URL = import.meta.env.VITE_API_BASE_URL;
/** 请求超时时间(毫秒) */ /** 请求超时时间(毫秒) */
const TIMEOUT = 30000; const TIMEOUT = 30_000;
/** localStorage 中 Token 的键名 */ /** localStorage 中 Token 的键名 */
const TOKEN_KEY = 'ele-heixiu-token'; const TOKEN_KEY = 'ele-heixiu-token';
/** refresh token 接口路径(该接口 401 时不应触发 handle401否则死循环 */
const REFRESH_TOKEN_URL = '/api/v1/auth/refresh';
/** 判断请求是否为 refresh token 接口 */
function isRefreshRequest(config: InternalAxiosRequestConfig): boolean {
return config.url?.includes(REFRESH_TOKEN_URL) ?? false;
}
// ---------- Token 管理 ---------- // ---------- Token 管理 ----------
/** 获取存储的 Token */ /** 获取存储的 Token */
@@ -58,6 +66,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 实例 ---------- // ---------- Axios 实例 ----------
const instance: AxiosInstance = axios.create({ const instance: AxiosInstance = axios.create({
@@ -84,6 +116,61 @@ instance.interceptors.request.use(
}, },
); );
// ---------- 401 → 刷新 Token + 重试 ----------
function handle401(config: InternalAxiosRequestConfig): Promise<AxiosResponse<ApiResponse>> {
// 已经在刷新中 → 排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(instance(config));
},
reject,
});
});
}
// 没有注册刷新回调 → 直接要求重新登录
if (!refreshHandler) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(new RefreshError());
}
// 开始刷新
isRefreshing = true;
refreshPromise = refreshHandler();
return new Promise((resolve, reject) => {
refreshPromise!
.then((newToken) => {
if (newToken) {
processQueue(null, newToken);
config.headers.Authorization = `Bearer ${newToken}`;
resolve(instance(config));
} else {
const err = new RefreshError();
processQueue(err, null);
clearToken();
emit(EVENTS.AUTH_REQUIRED);
reject(err);
}
isRefreshing = false;
refreshPromise = null;
})
.catch((err) => {
processQueue(err, null);
isRefreshing = false;
refreshPromise = null;
clearToken();
emit(EVENTS.AUTH_REQUIRED);
reject(new RefreshError());
});
});
}
// ---------- 响应拦截器 ---------- // ---------- 响应拦截器 ----------
instance.interceptors.response.use( instance.interceptors.response.use(
@@ -92,23 +179,33 @@ instance.interceptors.response.use(
// HTTP 200 但业务码非 0 → 业务错误 // HTTP 200 但业务码非 0 → 业务错误
if (data && data.code !== 0) { if (data && data.code !== 0) {
const err = new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', { // 401 → 尝试刷新 token 并重试(但 refresh 接口自身 401 必须直接拒绝,否则死循环)
if (data.code === 401 && response.config) {
if (isRefreshRequest(response.config)) {
return Promise.reject(new RefreshError());
}
return handle401(response.config);
}
return Promise.reject(
new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
code: data.code, code: data.code,
traceId: data.traceId, traceId: data.traceId,
}); }),
);
// 特殊业务码处理
if (data.code === 401) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
} }
return Promise.reject(err);
}
return response; return response;
}, },
(error) => { (error) => {
// 全局捕获 RefreshError → 自动触发登录 Modal直接返回不继续走后续判断
if (isRefreshError(error)) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(error);
}
// 请求已被取消 // 请求已被取消
if (axios.isCancel(error)) { if (axios.isCancel(error)) {
return Promise.reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消')); return Promise.reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
@@ -126,17 +223,21 @@ instance.interceptors.response.use(
// HTTP 状态码错误 // HTTP 状态码错误
const { status, data } = error.response; const { status, data } = error.response;
// 401 → 尝试刷新 token 并重试(但 refresh 接口自身 401 必须直接拒绝,否则死循环)
if (status === 401 && error.config) {
if (isRefreshRequest(error.config)) {
return Promise.reject(new RefreshError());
}
return handle401(error.config);
}
let errMsg = '请求失败'; let errMsg = '请求失败';
switch (status) { switch (status) {
case 400: case 400:
errMsg = '请求参数有误'; errMsg = '请求参数有误';
break; break;
case 401:
errMsg = '登录已过期,请重新登录';
clearToken();
emit(EVENTS.AUTH_REQUIRED);
break;
case 403: case 403:
errMsg = '没有访问权限'; errMsg = '没有访问权限';
break; break;

View File

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

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

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

View File

@@ -8,13 +8,25 @@ import { visualizer } from 'rollup-plugin-visualizer';
const __dirname = import.meta.dirname; const __dirname = import.meta.dirname;
// ============================================================
// 热更新说明
// ============================================================
// 修改 src/ → React Fast Refresh组件级热替换不丢失状态
// 修改 electron/ → 主进程重建 + Electron 自动重启(预期行为)
// 修改 shared/ → 因为 shared/ 被主进程引用,也会触发重启
// 如需避免,可把仅渲染进程用的类型放到 src/types/
// ============================================================
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
tailwindcss(), tailwindcss(),
electron({ electron({
main: { main: {
// 主进程入口
entry: 'electron/main.ts', entry: 'electron/main.ts',
// 只监听 electron/ 目录,避免 shared/ 变更误触重启
// (默认会监听 entry 文件的所有依赖,包括 shared/
}, },
preload: { preload: {
input: path.join(__dirname, 'electron/preload.ts'), input: path.join(__dirname, 'electron/preload.ts'),
@@ -36,6 +48,29 @@ export default defineConfig({
'@shared': path.resolve(__dirname, 'shared'), '@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: { build: {
rollupOptions: { rollupOptions: {
output: {}, output: {},