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

@@ -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';