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

357 lines
12 KiB
TypeScript
Raw Normal View History

2025-11-29 15:10:00 +00:00
/**
2026-09-20 05:48:43 +00:00
*
*
* =/ +
* Header
*
2025-11-29 15:10:00 +00:00
*/
2026-09-20 05:48:43 +00:00
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';
2025-11-29 15:10:00 +00:00
import {
2026-09-20 05:48:43 +00:00
CalendarOutlined,
ClockCircleOutlined,
ControlOutlined,
2025-11-29 15:10:00 +00:00
DashboardOutlined,
DatabaseOutlined,
2026-09-20 05:48:43 +00:00
DownOutlined,
2025-11-29 15:10:00 +00:00
DownloadOutlined,
2026-09-20 05:48:43 +00:00
GlobalOutlined,
HomeOutlined,
IdcardOutlined,
2025-11-29 15:10:00 +00:00
LogoutOutlined,
2026-09-20 05:48:43 +00:00
MenuFoldOutlined,
MenuUnfoldOutlined,
MoonOutlined,
2025-11-29 15:10:00 +00:00
RocketOutlined,
2026-09-20 05:48:43 +00:00
ScheduleOutlined,
2025-11-30 02:43:47 +00:00
SettingOutlined,
2025-12-26 01:21:15 +00:00
StarOutlined,
2026-09-20 05:48:43 +00:00
SunOutlined,
TeamOutlined,
UserOutlined,
2025-11-29 15:10:00 +00:00
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
2026-09-20 05:48:43 +00:00
2026-07-21 12:09:51 +00:00
import { authAPI } from '../../utils/request';
2025-11-29 15:10:00 +00:00
import { auth } from '../../utils/auth';
import { useToast } from '../../contexts/ToastContext';
2026-09-20 05:48:43 +00:00
import { adminThemeDark, adminThemeLight } from './adminTheme';
import { AdminPrefsProvider, useAdminPrefs } from './AdminPrefsContext';
import './admin.css';
const { Sider, Content } = Layout;
2025-11-29 15:10:00 +00:00
2026-09-20 05:48:43 +00:00
interface MenuNode {
id: number;
parent_id: number | null;
name: string;
title: string;
icon?: string | null;
path?: string | null;
children?: MenuNode[];
}
2025-11-29 15:10:00 +00:00
2026-09-20 05:48:43 +00:00
const iconMap: Record<string, React.ReactNode> = {
2025-11-29 15:10:00 +00:00
dashboard: <DashboardOutlined />,
database: <DatabaseOutlined />,
2026-09-20 05:48:43 +00:00
planet: <GlobalOutlined />,
2025-11-29 15:10:00 +00:00
data: <DatabaseOutlined />,
download: <DownloadOutlined />,
2025-11-30 02:43:47 +00:00
settings: <SettingOutlined />,
users: <TeamOutlined />,
sliders: <ControlOutlined />,
2025-12-26 01:21:15 +00:00
profile: <IdcardOutlined />,
star: <StarOutlined />,
2026-07-21 12:09:51 +00:00
rocket: <RocketOutlined />,
2026-09-20 05:48:43 +00:00
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 />,
2025-11-29 15:10:00 +00:00
};
2026-09-20 05:48:43 +00:00
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() {
2025-11-29 15:10:00 +00:00
const [collapsed, setCollapsed] = useState(false);
2026-09-20 05:48:43 +00:00
const [menus, setMenus] = useState<MenuNode[]>([]);
const [loadingMenus, setLoadingMenus] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [user, setUser] = useState(() => auth.getUser());
2025-11-29 15:10:00 +00:00
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
2026-09-20 05:48:43 +00:00
const { lang, themeMode, setLang, setThemeMode, t } = useAdminPrefs();
const locale = lang === 'en' ? enUS : zhCN;
2025-11-29 15:10:00 +00:00
2026-09-20 05:48:43 +00:00
const isAdmin = ((user?.roles as string[] | undefined) ?? []).includes('admin');
// 用户信息可能在其它标签页或个人信息页被更新,这里保持同步。
2026-01-18 02:54:03 +00:00
useEffect(() => {
2026-09-20 05:48:43 +00:00
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'cosmo_user') {
setUser(event.newValue ? JSON.parse(event.newValue) : null);
2026-01-18 02:54:03 +00:00
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
2026-09-20 05:48:43 +00:00
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]);
2026-01-18 02:54:03 +00:00
2025-11-29 15:10:00 +00:00
useEffect(() => {
2026-09-20 05:48:43 +00:00
void loadMenus();
}, [loadMenus]);
2025-11-29 15:10:00 +00:00
2026-09-20 05:48:43 +00:00
// 切换页面时收起用户二级菜单,避免菜单悬停在旧页面上。
2025-12-26 01:21:15 +00:00
useEffect(() => {
2026-09-20 05:48:43 +00:00
setUserMenuOpen(false);
}, [location.pathname]);
2025-12-26 01:21:15 +00:00
2026-09-20 05:48:43 +00:00
// 访问 /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 });
2025-11-29 15:10:00 +00:00
}
2026-09-20 05:48:43 +00:00
}, [menus, location.pathname, navigate]);
2025-11-29 15:10:00 +00:00
2026-09-20 05:48:43 +00:00
const menuItems = useMemo<MenuProps['items']>(
() =>
menus.map((menu) => ({
key: menu.path || `menu-${menu.id}`,
icon: iconMap[menu.icon || ''] ?? undefined,
2025-11-29 15:10:00 +00:00
label: menu.title,
2026-09-20 05:48:43 +00:00
children: menu.children?.length
? menu.children.map((child) => ({
key: child.path || `menu-${child.id}`,
label: child.title,
}))
: undefined,
})),
[menus],
);
2025-11-29 15:10:00 +00:00
2026-09-20 05:48:43 +00:00
/** 顶栏左侧显示当前所属分组(页面标题由页面自己的 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]);
2025-11-29 15:10:00 +00:00
2026-09-20 05:48:43 +00:00
const handleLogout = useCallback(async () => {
setUserMenuOpen(false);
2025-11-29 15:10:00 +00:00
try {
await authAPI.logout();
2026-09-20 05:48:43 +00:00
} catch {
// 后端不可用时也要清理本地登录态。
2025-11-29 15:10:00 +00:00
}
2026-09-20 05:48:43 +00:00
auth.logout();
toast.success('已退出登录');
navigate('/login', { replace: true });
}, [navigate, toast]);
const go = (path: string) => {
setUserMenuOpen(false);
navigate(path);
2025-11-29 15:10:00 +00:00
};
2026-09-20 05:48:43 +00:00
const avatarUrl = user?.avatar_url ? `/upload/${user.avatar_url}` : undefined;
const displayName = (user?.full_name as string) || (user?.username as string) || '未登录';
2025-11-29 15:10:00 +00:00
2026-09-20 05:48:43 +00:00
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>
2025-11-29 15:10:00 +00:00
</div>
2026-09-20 05:48:43 +00:00
</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' : ''}`}
2025-11-29 15:10:00 +00:00
>
2026-09-20 05:48:43 +00:00
{/* 品牌区 = 收起 / 展开按钮:收起后悬停显示展开图标 */}
<button
type="button"
className="adm-brand"
onClick={() => setCollapsed((value) => !value)}
aria-label={collapsed ? t('展开菜单') : t('收起菜单')}
title={collapsed ? t('展开菜单') : t('收起菜单')}
2025-11-29 15:10:00 +00:00
>
2026-09-20 05:48:43 +00:00
<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>
2025-11-29 15:10:00 +00:00
</div>
2026-09-20 05:48:43 +00:00
</Sider>
2025-11-29 15:10:00 +00:00
2026-09-20 05:48:43 +00:00
<Layout>
{/* 统一框架 Header左侧当前分组右侧全局开关 */}
<header className="adm-header">
<div className="adm-header-left">
<span className="adm-header-section">{t(headerSection)}</span>
2025-11-29 15:10:00 +00:00
</div>
2026-09-20 05:48:43 +00:00
<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>
2025-11-29 15:10:00 +00:00
</Layout>
2026-09-20 05:48:43 +00:00
</ConfigProvider>
);
}
export function AdminLayout() {
return (
<AdminPrefsProvider>
<AdminShell />
</AdminPrefsProvider>
2025-11-29 15:10:00 +00:00
);
}