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

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