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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user