286 lines
8.8 KiB
TypeScript
286 lines
8.8 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
|
import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid } from 'antd';
|
|
import {
|
|
DashboardOutlined,
|
|
TeamOutlined,
|
|
HomeOutlined,
|
|
SwapOutlined,
|
|
DollarOutlined,
|
|
FileTextOutlined,
|
|
LogoutOutlined,
|
|
UserOutlined,
|
|
MenuFoldOutlined,
|
|
MenuUnfoldOutlined,
|
|
AppstoreOutlined,
|
|
AuditOutlined,
|
|
SettingOutlined,
|
|
WalletOutlined,
|
|
ReadOutlined,
|
|
TagsOutlined,
|
|
FileProtectOutlined,
|
|
CalendarOutlined,
|
|
SafetyOutlined,
|
|
KeyOutlined,
|
|
CheckCircleOutlined,
|
|
LaptopOutlined,
|
|
BellOutlined,
|
|
ApiOutlined,
|
|
RobotOutlined,
|
|
} from '@ant-design/icons';
|
|
import { usePermission } from '../hooks/usePermission';
|
|
import api from '../api';
|
|
import { writePermissions } from '../auth/permission-store';
|
|
import NotificationBell from '../components/NotificationBell';
|
|
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
|
|
|
const { Header, Sider, Content } = Layout;
|
|
|
|
const iconMap: Record<string, React.ReactNode> = {
|
|
dashboard: <DashboardOutlined />,
|
|
calendar: <CalendarOutlined />,
|
|
workspace: <LaptopOutlined />,
|
|
attendance: <CheckCircleOutlined />,
|
|
academic: <TeamOutlined />,
|
|
students: <TeamOutlined />,
|
|
classes: <TeamOutlined />,
|
|
teachers: <UserOutlined />,
|
|
home: <HomeOutlined />,
|
|
overview: <AppstoreOutlined />,
|
|
occupancy: <SwapOutlined />,
|
|
expense: <DollarOutlined />,
|
|
bill: <FileTextOutlined />,
|
|
deposit: <WalletOutlined />,
|
|
wallet: <WalletOutlined />,
|
|
classroom: <ReadOutlined />,
|
|
rental: <FileProtectOutlined />,
|
|
organization: <TagsOutlined />,
|
|
settings: <SettingOutlined />,
|
|
users: <UserOutlined />,
|
|
role: <SafetyOutlined />,
|
|
permission: <KeyOutlined />,
|
|
log: <AuditOutlined />,
|
|
integration: <ApiOutlined />,
|
|
ai: <RobotOutlined />,
|
|
notification: <BellOutlined />,
|
|
};
|
|
|
|
const MainLayout: React.FC = () => {
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
|
const prevPathname = useRef('');
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [user, setUser] = useState<{ name?: string; username?: string; roles?: string[] }>(() =>
|
|
JSON.parse(localStorage.getItem('user') || '{}'),
|
|
);
|
|
const { permissions, hasPermission } = usePermission();
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
api.get<{ id: number; username: string; permissions: string[]; roles?: string[] }>('/auth/profile')
|
|
.then((profile) => {
|
|
if (cancelled) return;
|
|
writePermissions(profile.permissions || []);
|
|
const cachedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
|
const nextUser = { ...cachedUser, ...profile };
|
|
localStorage.setItem('user', JSON.stringify(nextUser));
|
|
setUser(nextUser);
|
|
})
|
|
.catch(() => {
|
|
// The API interceptor handles expired/invalid sessions.
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
const screens = Grid.useBreakpoint();
|
|
const isMobile = !screens.sm; // < 576px (仅 xs)
|
|
const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px
|
|
const isDesktop = !!screens.lg; // >= 992px
|
|
|
|
const menuItems = useMemo(
|
|
() => buildMenu(user.roles ?? [], permissions),
|
|
[user.roles, permissions],
|
|
);
|
|
|
|
const handleLogout = useCallback(() => {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
localStorage.removeItem('permissions');
|
|
navigate('/login');
|
|
}, [navigate]);
|
|
|
|
const handleMenuClick = useCallback((key: string) => {
|
|
navigate(key);
|
|
if (isMobile) setDrawerOpen(false);
|
|
}, [navigate, isMobile]);
|
|
|
|
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
|
for (const item of items) {
|
|
if (item.key === pathname) return [item.key];
|
|
if (item.children) {
|
|
const found = findSelectedKeys(item.children, pathname);
|
|
if (found.length > 0) return found;
|
|
}
|
|
}
|
|
return [pathname];
|
|
};
|
|
|
|
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
|
for (const item of items) {
|
|
if (item.children) {
|
|
if (item.children.some((c) => c.key === pathname || (c.children && c.children.some((gc) => gc.key === pathname)))) {
|
|
return [item.key];
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const selectedKeys = useMemo(() => findSelectedKeys(menuItems, location.pathname), [menuItems, location.pathname]);
|
|
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
|
|
useEffect(() => {
|
|
if (location.pathname !== prevPathname.current) {
|
|
prevPathname.current = location.pathname;
|
|
setOpenKeys(findOpenKeys(menuItems, location.pathname));
|
|
}
|
|
}, [location.pathname, menuItems]);
|
|
|
|
const handleOpenChange = useCallback((keys: string[]) => {
|
|
// 只保留最新打开的一个子菜单
|
|
const latestKey = keys[keys.length - 1];
|
|
setOpenKeys(latestKey ? [latestKey] : []);
|
|
}, []);
|
|
|
|
|
|
|
|
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
|
|
return items.map((item) => ({
|
|
key: item.key,
|
|
icon: item.icon ? iconMap[item.icon] : undefined,
|
|
label: item.label,
|
|
children: item.children ? transformToMenuItems(item.children) : undefined,
|
|
}));
|
|
};
|
|
const menuContent = useMemo(() => (
|
|
<Menu
|
|
theme="light"
|
|
mode="inline"
|
|
selectedKeys={selectedKeys}
|
|
openKeys={openKeys}
|
|
onOpenChange={handleOpenChange}
|
|
items={transformToMenuItems(menuItems)}
|
|
onClick={({ key }) => handleMenuClick(key)}
|
|
style={{ border: 'none' }}
|
|
/>
|
|
), [selectedKeys, openKeys, menuItems, handleMenuClick]);
|
|
|
|
return (
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
{!isMobile && (
|
|
<Sider
|
|
trigger={null}
|
|
collapsible
|
|
collapsed={isTablet ? true : collapsed}
|
|
theme="light"
|
|
style={{ background: '#fff', borderRight: '1px solid #e5e5e7' }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: 64,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: '#1d1d1f',
|
|
fontSize: (isTablet || collapsed) ? 16 : 17,
|
|
fontWeight: 600,
|
|
borderBottom: '1px solid #e5e5e7',
|
|
}}
|
|
>
|
|
{(isTablet || collapsed) ? '恭' : '恭学教育基地'}
|
|
</div>
|
|
{menuContent}
|
|
</Sider>
|
|
)}
|
|
{isMobile && (
|
|
<Drawer
|
|
placement="left"
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
size={240}
|
|
styles={{ body: { padding: 0 } }}
|
|
title="恭学教育基地"
|
|
>
|
|
{menuContent}
|
|
</Drawer>
|
|
)}
|
|
<Layout style={{ background: '#f5f5f7' }}>
|
|
<Header
|
|
style={{
|
|
padding: '0 16px',
|
|
background: '#fff',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
borderBottom: '1px solid #e5e5e7',
|
|
boxShadow: 'none',
|
|
}}
|
|
>
|
|
<Button
|
|
type="text"
|
|
aria-label={isMobile || isTablet ? '打开菜单' : collapsed ? '展开侧边栏' : '收起侧边栏'}
|
|
icon={
|
|
isMobile || isTablet ? (
|
|
<MenuUnfoldOutlined />
|
|
) : collapsed ? (
|
|
<MenuUnfoldOutlined />
|
|
) : (
|
|
<MenuFoldOutlined />
|
|
)
|
|
}
|
|
onClick={() => (isMobile || isTablet ? setDrawerOpen(true) : setCollapsed(!collapsed))}
|
|
/>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
{hasPermission('notification:view') && <NotificationBell />}
|
|
<Dropdown
|
|
menu={{
|
|
items: [
|
|
{
|
|
key: 'logout',
|
|
icon: <LogoutOutlined />,
|
|
label: '退出登录',
|
|
onClick: handleLogout,
|
|
},
|
|
],
|
|
}}
|
|
>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label="用户菜单"
|
|
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}
|
|
>
|
|
<Avatar icon={<UserOutlined />} />
|
|
{isDesktop && <span>{user.name || user.username || '用户'}</span>}
|
|
</div>
|
|
</Dropdown>
|
|
</div>
|
|
</Header>
|
|
<Content
|
|
style={{
|
|
margin: isMobile ? 12 : isTablet ? 16 : 24,
|
|
padding: isMobile ? 12 : isTablet ? 16 : 24,
|
|
background: '#fff',
|
|
borderRadius: 12,
|
|
}}
|
|
>
|
|
<Outlet />
|
|
</Content>
|
|
</Layout>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default MainLayout;
|