357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
/**
|
||
* 管理后台布局
|
||
*
|
||
* 结构:左侧深色栏(品牌=收起/展开按钮 → 菜单 → 底部固定用户胶囊)+
|
||
* 右侧内容区(顶部只有语言与明暗开关,页面信息统一由页面自己的 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<string, React.ReactNode> = {
|
||
dashboard: <DashboardOutlined />,
|
||
database: <DatabaseOutlined />,
|
||
planet: <GlobalOutlined />,
|
||
data: <DatabaseOutlined />,
|
||
download: <DownloadOutlined />,
|
||
settings: <SettingOutlined />,
|
||
users: <TeamOutlined />,
|
||
sliders: <ControlOutlined />,
|
||
profile: <IdcardOutlined />,
|
||
star: <StarOutlined />,
|
||
rocket: <RocketOutlined />,
|
||
schedule: <ScheduleOutlined />,
|
||
// 后端菜单里也直接存了组件名,这里一并兼容
|
||
DashboardOutlined: <DashboardOutlined />,
|
||
DatabaseOutlined: <DatabaseOutlined />,
|
||
DownloadOutlined: <DownloadOutlined />,
|
||
SettingOutlined: <SettingOutlined />,
|
||
TeamOutlined: <TeamOutlined />,
|
||
ControlOutlined: <ControlOutlined />,
|
||
IdcardOutlined: <IdcardOutlined />,
|
||
StarOutlined: <StarOutlined />,
|
||
RocketOutlined: <RocketOutlined />,
|
||
CalendarOutlined: <CalendarOutlined />,
|
||
ClockCircleOutlined: <ClockCircleOutlined />,
|
||
ScheduleOutlined: <ScheduleOutlined />,
|
||
GlobalOutlined: <GlobalOutlined />,
|
||
};
|
||
|
||
function roleLabelOf(user: ReturnType<typeof auth.getUser>): 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<MenuNode[]>([]);
|
||
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<MenuProps['items']>(
|
||
() =>
|
||
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 = (
|
||
<div style={{ width: 232 }}>
|
||
<div className="adm-user-menu-head">
|
||
<Avatar size={38} src={avatarUrl} icon={<UserOutlined />} />
|
||
<div className="adm-user-menu-head-meta">
|
||
<strong>{displayName}</strong>
|
||
<span>{user?.email || t(roleLabelOf(user))}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="adm-user-menu-group">{t('个人资料')}</div>
|
||
<button
|
||
type="button"
|
||
className={`adm-user-menu-item ${location.pathname.endsWith('/profile') ? 'is-active' : ''}`}
|
||
onClick={() => go('/user/profile')}
|
||
>
|
||
<IdcardOutlined />
|
||
{t('个人资料')}
|
||
</button>
|
||
|
||
<div className="adm-user-menu-divider" />
|
||
<button type="button" className="adm-user-menu-item" onClick={() => go('/')}>
|
||
<HomeOutlined />
|
||
{t('返回可视化首页')}
|
||
</button>
|
||
<button type="button" className="adm-user-menu-item is-danger" onClick={() => void handleLogout()}>
|
||
<LogoutOutlined />
|
||
{t('退出登录')}
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<ConfigProvider theme={themeMode === 'dark' ? adminThemeDark : adminThemeLight} locale={locale}>
|
||
<Layout className="adm-shell" data-adm-theme={themeMode}>
|
||
<Sider
|
||
width={236}
|
||
theme={themeMode === 'dark' ? 'dark' : 'light'}
|
||
trigger={null}
|
||
collapsible
|
||
collapsed={collapsed}
|
||
className={`adm-sider ${collapsed ? 'adm-sider--collapsed' : ''}`}
|
||
>
|
||
{/* 品牌区 = 收起 / 展开按钮:收起后悬停显示展开图标 */}
|
||
<button
|
||
type="button"
|
||
className="adm-brand"
|
||
onClick={() => setCollapsed((value) => !value)}
|
||
aria-label={collapsed ? t('展开菜单') : t('收起菜单')}
|
||
title={collapsed ? t('展开菜单') : t('收起菜单')}
|
||
>
|
||
<span className="adm-brand-mark">🌌</span>
|
||
<MenuUnfoldOutlined className="adm-brand-hover-icon" />
|
||
{!collapsed && (
|
||
<>
|
||
<span className="adm-brand-text">
|
||
<strong>COSMO</strong>
|
||
<span>{isAdmin ? t('管理后台') : t('用户中心')}</span>
|
||
</span>
|
||
<MenuFoldOutlined className="adm-brand-toggle" />
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
<div className="adm-menu">
|
||
{loadingMenus ? (
|
||
<div style={{ display: 'flex', justifyContent: 'center', padding: '24px 0' }}>
|
||
<Spin size="small" />
|
||
</div>
|
||
) : (
|
||
<Menu
|
||
theme={themeMode === 'dark' ? 'dark' : 'light'}
|
||
mode="inline"
|
||
selectedKeys={[location.pathname]}
|
||
defaultOpenKeys={menus.filter((menu) => menu.children?.length).map((menu) => `menu-${menu.id}`)}
|
||
items={menuItems}
|
||
onClick={({ key }) => navigate(key)}
|
||
style={{ background: 'transparent', borderInlineEnd: 'none' }}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 用户胶囊:位于菜单下方,点击展开二级菜单 */}
|
||
<div className={`adm-user-dock ${collapsed ? 'adm-user-dock--collapsed' : ''}`}>
|
||
<Popover
|
||
open={userMenuOpen}
|
||
onOpenChange={setUserMenuOpen}
|
||
trigger="click"
|
||
placement="topLeft"
|
||
arrow={false}
|
||
overlayClassName="adm-user-menu"
|
||
content={userMenuContent}
|
||
>
|
||
<button type="button" className="adm-user-pill" aria-expanded={userMenuOpen}>
|
||
<Avatar size={30} src={avatarUrl} icon={<UserOutlined />} />
|
||
{!collapsed && (
|
||
<>
|
||
<span className="adm-user-pill-meta">
|
||
<strong>{displayName}</strong>
|
||
<span>{t(roleLabelOf(user))}</span>
|
||
</span>
|
||
<DownOutlined className="adm-user-pill-arrow" />
|
||
</>
|
||
)}
|
||
</button>
|
||
</Popover>
|
||
</div>
|
||
</Sider>
|
||
|
||
<Layout>
|
||
{/* 统一框架 Header:左侧当前分组,右侧全局开关 */}
|
||
<header className="adm-header">
|
||
<div className="adm-header-left">
|
||
<span className="adm-header-section">{t(headerSection)}</span>
|
||
</div>
|
||
<div className="adm-header-right">
|
||
<div className="adm-switch-group" role="group" aria-label={t('语言切换')}>
|
||
<button
|
||
type="button"
|
||
className={lang === 'zh' ? 'is-active' : ''}
|
||
onClick={() => setLang('zh')}
|
||
title="中文"
|
||
>
|
||
中文
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={lang === 'en' ? 'is-active' : ''}
|
||
onClick={() => setLang('en')}
|
||
title="English"
|
||
>
|
||
EN
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="adm-icon-switch"
|
||
onClick={() => setThemeMode(themeMode === 'dark' ? 'light' : 'dark')}
|
||
aria-label={t('主题切换')}
|
||
title={themeMode === 'dark' ? t('浅色') : t('深色')}
|
||
>
|
||
{themeMode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<Content className="adm-content">
|
||
<Outlet />
|
||
</Content>
|
||
</Layout>
|
||
</Layout>
|
||
</ConfigProvider>
|
||
);
|
||
}
|
||
|
||
export function AdminLayout() {
|
||
return (
|
||
<AdminPrefsProvider>
|
||
<AdminShell />
|
||
</AdminPrefsProvider>
|
||
);
|
||
}
|