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

View File

@@ -16,7 +16,7 @@ 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';
// ---------- 配置 ----------
@@ -24,11 +24,19 @@ import { emit, EVENTS } from '../utils/event-bus';
/** 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 +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 实例 ----------
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(
@@ -92,23 +179,33 @@ instance.interceptors.response.use(
// HTTP 200 但业务码非 0 → 业务错误
if (data && data.code !== 0) {
const err = new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
code: data.code,
traceId: data.traceId,
});
// 特殊业务码处理
if (data.code === 401) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
// 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(err);
return Promise.reject(
new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
code: data.code,
traceId: data.traceId,
}),
);
}
return response;
},
(error) => {
// 全局捕获 RefreshError → 自动触发登录 Modal直接返回不继续走后续判断
if (isRefreshError(error)) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(error);
}
// 请求已被取消
if (axios.isCancel(error)) {
return Promise.reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
@@ -126,17 +223,21 @@ instance.interceptors.response.use(
// HTTP 状态码错误
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 = '请求失败';
switch (status) {
case 400:
errMsg = '请求参数有误';
break;
case 401:
errMsg = '登录已过期,请重新登录';
clearToken();
emit(EVENTS.AUTH_REQUIRED);
break;
case 403:
errMsg = '没有访问权限';
break;