Files
gongxue-base/apps/admin/src/layouts/MainLayout.tsx

386 lines
12 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } 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,
TrophyOutlined,
ApiOutlined,
RobotOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { usePermission } from '../hooks/usePermission';
import api from '../api';
import { useAppStore } from '../store/app/appStore';
import { usePermissionStore } from '../store/permission/permissionStore';
import { useUserStore } from '../store/user/userStore';
import NotificationBell from '../components/NotificationBell';
import RouteDock from '../components/RouteDock';
import RouteKeeper from '../components/RouteKeeper';
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer'));
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 />,
exam: <TrophyOutlined />,
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 prevPathname = useRef('');
const navigate = useNavigate();
const location = useLocation();
const user = useUserStore((state) => state.user);
const updateUser = useUserStore((state) => state.updateUser);
const logoutUser = useUserStore((state) => state.logout);
const collapsed = useAppStore((state) => state.sidebarCollapsed);
const drawerOpen = useAppStore((state) => state.mobileDrawerOpen);
const aiChatOpen = useAppStore((state) => state.aiChatOpen);
const aiWorking = useAppStore((state) => state.aiWorking);
const openKeys = useAppStore((state) => state.menuOpenKeys);
const toggleSidebar = useAppStore((state) => state.toggleSidebar);
const setDrawerOpen = useAppStore((state) => state.setMobileDrawerOpen);
const setAiChatOpen = useAppStore((state) => state.setAiChatOpen);
const setAiWorking = useAppStore((state) => state.setAiWorking);
const setOpenKeys = useAppStore((state) => state.setMenuOpenKeys);
const { permissions, hasPermission } = usePermission();
useEffect(() => {
let cancelled = false;
let retryTimer: number | undefined;
let verificationInFlight = false;
const verifyPermissions = () => {
if (cancelled || verificationInFlight || !useUserStore.getState().token) return;
if (retryTimer !== undefined) {
window.clearTimeout(retryTimer);
retryTimer = undefined;
}
verificationInFlight = true;
usePermissionStore.getState().beginPermissionVerification();
api
.get<{ id: number; username: string; permissions: string[]; roles?: string[] }>(
'/auth/profile',
)
.then((profile) => {
if (cancelled) return;
verificationInFlight = false;
usePermissionStore.getState().writePermissions(profile.permissions || []);
updateUser(profile);
})
.catch(() => {
verificationInFlight = false;
if (cancelled || !useUserStore.getState().token) return;
retryTimer = window.setTimeout(verifyPermissions, 5_000);
});
};
const handleStorage = (event: StorageEvent) => {
if (event.key !== 'token' && event.key !== 'permissions') return;
usePermissionStore.getState().beginPermissionVerification();
window.location.reload();
};
const handleOnline = () => verifyPermissions();
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') verifyPermissions();
};
verifyPermissions();
window.addEventListener('storage', handleStorage);
window.addEventListener('online', handleOnline);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cancelled = true;
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
window.removeEventListener('storage', handleStorage);
window.removeEventListener('online', handleOnline);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [updateUser]);
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 usesDrawer = !isDesktop;
const menuItems = useMemo(
() => buildMenu(user?.roles ?? [], permissions),
[user, permissions],
);
const handleLogout = useCallback(() => {
logoutUser();
usePermissionStore.getState().clearPermissions();
navigate('/login');
}, [logoutUser, navigate]);
const handleMenuClick = useCallback(
(key: string) => {
navigate(key);
if (usesDrawer) setDrawerOpen(false);
},
[navigate, usesDrawer],
);
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;
const routeOpenKeys = findOpenKeys(menuItems, location.pathname);
setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]);
}
}, [location.pathname, menuItems]);
const handleOpenChange = useCallback((keys: string[]) => {
setOpenKeys(keys);
}, []);
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 className="app-shell" style={{ minHeight: '100vh' }}>
{isDesktop && (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
theme="light"
className="app-sidebar"
style={{ background: '#fff', borderRight: '1px solid #e5e5e7' }}
>
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#1d1d1f',
fontSize: collapsed ? 16 : 17,
fontWeight: 600,
borderBottom: '1px solid #e5e5e7',
}}
>
{collapsed ? '学' : '学生管理系统'}
</div>
{menuContent}
</Sider>
)}
{usesDrawer && (
<Drawer
placement="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
size={240}
styles={{ body: { padding: 0 } }}
className="app-navigation-drawer"
title="学生管理系统"
>
{menuContent}
</Drawer>
)}
<Layout className="app-main" style={{ background: '#f5f5f7' }}>
<Header
className="app-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={usesDrawer ? '打开菜单' : collapsed ? '展开侧边栏' : '收起侧边栏'}
icon={
usesDrawer ? (
<MenuUnfoldOutlined />
) : collapsed ? (
<MenuUnfoldOutlined />
) : (
<MenuFoldOutlined />
)
}
onClick={() => (usesDrawer ? setDrawerOpen(true) : toggleSidebar())}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{hasPermission('ai:chat:use') && (
<Tooltip title={aiWorking ? 'AI 处理中' : 'AI 助理'}>
<Badge dot={aiWorking} color="#007AFF" offset={[-3, 5]}>
<Button
type="text"
aria-label={aiWorking ? 'AI 助理处理中' : '打开 AI 助理'}
icon={
aiWorking ? (
<LoadingOutlined style={{ fontSize: 17, color: '#007AFF' }} spin />
) : (
<RobotOutlined style={{ fontSize: 17 }} />
)
}
onClick={() => setAiChatOpen(true)}
/>
</Badge>
</Tooltip>
)}
{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>
<RouteDock
location={location}
menuItems={menuItems}
onNavigate={navigate}
draggable={isDesktop}
/>
<Content
className="app-content"
style={{
margin: isMobile ? 8 : isTablet ? 16 : 24,
padding: isMobile ? 12 : isTablet ? 16 : 24,
background: '#fff',
borderRadius: 12,
}}
>
<RouteKeeper />
</Content>
</Layout>
{hasPermission('ai:chat:use') && (
<React.Suspense fallback={null}>
<AiChatDrawer
open={aiChatOpen}
onClose={() => setAiChatOpen(false)}
onRequestingChange={setAiWorking}
/>
</React.Suspense>
)}
</Layout>
);
};
export default MainLayout;