Files
ele-HeiXiu/src/components/navbar/NavBar.tsx
YoungestSongMo 3becdb85d4 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
2026-06-03 19:25:15 +08:00

138 lines
4.6 KiB
TypeScript

// ============================================================
// NavBar — 自定义顶部导航栏(替代系统菜单)
//
// 逻辑:
// - 所有导航项默认全部显示(不再按登录状态隐藏)
// - 未登录时点击需登录的项 → 弹出提示"请先登录"并触发登录 Modal
// - 登录/注册 与 退出登录 仍按登录状态互斥显示
// ============================================================
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { App } from 'antd';
import { useAppContext } from '@/contexts/app-context';
import { useTheme } from '@/hooks/use-theme';
import { emit, EVENTS } from '@/utils/event-bus.ts';
import { NavItem } from './NavItem';
import { AnnouncementDrawer } from './AnnouncementDrawer';
import { personalLeft, personalRight, enterpriseLeft, enterpriseRight } from './nav-config';
import type { NavItemConfig } from './types';
const NAVBAR_HEIGHT = 56;
export function NavBar() {
const { platform, edition, isLoggedIn, logout } = useAppContext();
const { isDark } = useTheme();
const { message } = App.useApp();
const navigate = useNavigate();
const [drawerOpen, setDrawerOpen] = useState(false);
// 根据版本选择导航项
const { leftItems, rightItems } = useMemo(() => {
const isEnterprise = edition === 'enterprise';
return {
leftItems: isEnterprise ? enterpriseLeft : personalLeft,
rightItems: isEnterprise ? enterpriseRight : personalRight,
};
}, [edition]);
// 过滤规则:
// requireGuest → 仅未登录时可见(登录/注册)
// "退出登录" → 仅已登录时可见
// 其他所有项 → 始终可见,未登录点击时弹"请先登录"
const visibleLeft = useMemo(
() =>
leftItems.filter((item) => {
if (item.requireGuest && isLoggedIn) return false;
return !(item.key === 'logout' && !isLoggedIn);
}),
[leftItems, isLoggedIn],
);
const visibleRight = useMemo(
() =>
rightItems.filter((item) => {
if (item.requireGuest && isLoggedIn) return false;
return !(item.key === 'logout' && !isLoggedIn);
}),
[rightItems, isLoggedIn],
);
// 点击导航项
const handleNavClick = useCallback(
(item: NavItemConfig) => {
// 需要登录但未登录 → 提示并弹出登录 Modal
if (item.requireLogin && !isLoggedIn) {
message.warning('请先登录');
emit(EVENTS.SHOW_LOGIN);
return;
}
switch (item.action) {
case 'drawer':
if (item.key === 'announcements') {
setDrawerOpen(true);
}
break;
case 'button':
if (item.key === 'logout') {
logout();
}
break;
case 'external':
if (item.target) {
window.open(item.target, '_blank');
}
break;
case 'navigate':
case 'spa':
if (item.key === 'auth') {
emit(EVENTS.SHOW_LOGIN);
} else if (item.target) {
navigate(item.target);
}
break;
}
},
[isLoggedIn, logout, message],
);
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
return (
<>
<div
className="navbar-drag-region flex items-center justify-between select-none"
style={{
height: NAVBAR_HEIGHT,
paddingLeft: macPaddingLeft,
paddingRight: 12,
background: isDark
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`,
boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(99,102,241,0.06)',
}}
>
<div className="navbar-no-drag flex items-center gap-0.5">
{visibleLeft.map((item) => (
<NavItem key={item.key} item={item} onClick={handleNavClick} />
))}
</div>
<div className="navbar-no-drag flex items-center gap-0.5">
{visibleRight.map((item) => (
<NavItem key={item.key} item={item} onClick={handleNavClick} />
))}
</div>
</div>
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</>
);
}