cosmo/frontend/src/pages/admin/AdminLayout.tsx

357 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 管理后台布局
*
* 结构:左侧深色栏(品牌=收起/展开按钮 → 菜单 → 底部固定用户胶囊)+
* 右侧内容区(顶部只有语言与明暗开关,页面信息统一由页面自己的 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>
);
}