/** * 管理后台布局 * * 结构:左侧深色栏(品牌=收起/展开按钮 → 菜单 → 底部固定用户胶囊)+ * 右侧内容区(顶部只有语言与明暗开关,页面信息统一由页面自己的 Header 卡片承载)。 * 管理员与普通用户共用同一套布局。 */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Avatar, ConfigProvider, Layout, Menu, Popover, Spin } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import enUS from 'antd/locale/en_US'; import { CalendarOutlined, ClockCircleOutlined, ControlOutlined, DashboardOutlined, DatabaseOutlined, DownOutlined, DownloadOutlined, GlobalOutlined, HomeOutlined, IdcardOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, RocketOutlined, ScheduleOutlined, SettingOutlined, StarOutlined, SunOutlined, TeamOutlined, UserOutlined, } from '@ant-design/icons'; import type { MenuProps } from 'antd'; import { authAPI } from '../../utils/request'; import { auth } from '../../utils/auth'; import { useToast } from '../../contexts/ToastContext'; import { adminThemeDark, adminThemeLight } from './adminTheme'; import { AdminPrefsProvider, useAdminPrefs } from './AdminPrefsContext'; import './admin.css'; const { Sider, Content } = Layout; interface MenuNode { id: number; parent_id: number | null; name: string; title: string; icon?: string | null; path?: string | null; children?: MenuNode[]; } const iconMap: Record = { dashboard: , database: , planet: , data: , download: , settings: , users: , sliders: , profile: , star: , rocket: , schedule: , // 后端菜单里也直接存了组件名,这里一并兼容 DashboardOutlined: , DatabaseOutlined: , DownloadOutlined: , SettingOutlined: , TeamOutlined: , ControlOutlined: , IdcardOutlined: , StarOutlined: , RocketOutlined: , CalendarOutlined: , ClockCircleOutlined: , ScheduleOutlined: , GlobalOutlined: , }; function roleLabelOf(user: ReturnType): string { const roles = (user?.roles as string[] | undefined) ?? []; if (roles.includes('admin')) return '管理员'; if (roles.length > 0) return '普通用户'; return '已登录用户'; } function AdminShell() { const [collapsed, setCollapsed] = useState(false); const [menus, setMenus] = useState([]); const [loadingMenus, setLoadingMenus] = useState(true); const [userMenuOpen, setUserMenuOpen] = useState(false); const [user, setUser] = useState(() => auth.getUser()); const navigate = useNavigate(); const location = useLocation(); const toast = useToast(); const { lang, themeMode, setLang, setThemeMode, t } = useAdminPrefs(); const locale = lang === 'en' ? enUS : zhCN; const isAdmin = ((user?.roles as string[] | undefined) ?? []).includes('admin'); // 用户信息可能在其它标签页或个人信息页被更新,这里保持同步。 useEffect(() => { const handleStorageChange = (event: StorageEvent) => { if (event.key === 'cosmo_user') { setUser(event.newValue ? JSON.parse(event.newValue) : null); } }; window.addEventListener('storage', handleStorageChange); return () => window.removeEventListener('storage', handleStorageChange); }, []); const loadMenus = useCallback(async () => { setLoadingMenus(true); try { const { data } = await authAPI.getMenus(); setMenus(Array.isArray(data) ? data : []); } catch { setMenus([]); toast.error('加载菜单失败'); } finally { setLoadingMenus(false); } }, [toast]); useEffect(() => { void loadMenus(); }, [loadMenus]); // 切换页面时收起用户二级菜单,避免菜单悬停在旧页面上。 useEffect(() => { setUserMenuOpen(false); }, [location.pathname]); // 访问 /admin 或 /user 根路径时跳到第一个可用菜单。 useEffect(() => { if (menus.length === 0) return; if (location.pathname === '/admin' || location.pathname === '/user') { const firstPath = menus.find((menu) => menu.path)?.path; if (firstPath) navigate(firstPath, { replace: true }); } }, [menus, location.pathname, navigate]); const menuItems = useMemo( () => menus.map((menu) => ({ key: menu.path || `menu-${menu.id}`, icon: iconMap[menu.icon || ''] ?? undefined, label: menu.title, children: menu.children?.length ? menu.children.map((child) => ({ key: child.path || `menu-${child.id}`, label: child.title, })) : undefined, })), [menus], ); /** 顶栏左侧显示当前所属分组(页面标题由页面自己的 Header 卡片展示)。 */ const headerSection = useMemo(() => { for (const menu of menus) { if (menu.children?.some((child) => child.path === location.pathname)) return menu.title; } if (location.pathname.endsWith('/profile')) return '账号'; if (location.pathname.startsWith('/user')) return '用户中心'; return 'COSMO 管理后台'; }, [menus, location.pathname]); const handleLogout = useCallback(async () => { setUserMenuOpen(false); try { await authAPI.logout(); } catch { // 后端不可用时也要清理本地登录态。 } auth.logout(); toast.success('已退出登录'); navigate('/login', { replace: true }); }, [navigate, toast]); const go = (path: string) => { setUserMenuOpen(false); navigate(path); }; const avatarUrl = user?.avatar_url ? `/upload/${user.avatar_url}` : undefined; const displayName = (user?.full_name as string) || (user?.username as string) || '未登录'; const userMenuContent = (
} />
{displayName} {user?.email || t(roleLabelOf(user))}
{t('个人资料')}
); return ( {/* 品牌区 = 收起 / 展开按钮:收起后悬停显示展开图标 */}
{loadingMenus ? (
) : ( menu.children?.length).map((menu) => `menu-${menu.id}`)} items={menuItems} onClick={({ key }) => navigate(key)} style={{ background: 'transparent', borderInlineEnd: 'none' }} /> )}
{/* 用户胶囊:位于菜单下方,点击展开二级菜单 */}
{/* 统一框架 Header:左侧当前分组,右侧全局开关 */}
{t(headerSection)}
); } export function AdminLayout() { return ( ); }