storage 事件里 permissions 变更改为原地写入权限 store, 仅 gongxue-auth token 实际变化(登录/退出/切换账号)才 reload, 避免多标签页相互触发刷新风暴
439 lines
14 KiB
TypeScript
439 lines
14 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||
import { useNavigate, useLocation } from 'react-router';
|
||
import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } from 'antd';
|
||
import { BrandLogo } from '../components/BrandLogo';
|
||
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 { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/persist';
|
||
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 === PERMISSION_STORAGE_NAME) {
|
||
// 其他标签页的权限更新:原地应用,避免整页刷新造成刷新风暴。
|
||
try {
|
||
if (event.newValue === null) {
|
||
usePermissionStore.getState().clearPermissions();
|
||
return;
|
||
}
|
||
const parsed = JSON.parse(event.newValue) as {
|
||
state?: { permissions?: string[] };
|
||
};
|
||
const permissions = parsed?.state?.permissions;
|
||
if (Array.isArray(permissions)) {
|
||
usePermissionStore.getState().writePermissions(permissions);
|
||
}
|
||
} catch {
|
||
// 忽略无法解析的跨标签页权限写入
|
||
}
|
||
return;
|
||
}
|
||
if (event.key === AUTH_STORAGE_NAME) {
|
||
// 仅当登录态(token)确实变化时才整页刷新:登录、退出或切换账号。
|
||
const currentToken = useUserStore.getState().token;
|
||
let otherToken: string | null = null;
|
||
if (event.newValue) {
|
||
try {
|
||
const parsed = JSON.parse(event.newValue) as {
|
||
state?: { token?: string | null };
|
||
};
|
||
otherToken = parsed?.state?.token ?? null;
|
||
} catch {
|
||
otherToken = null;
|
||
}
|
||
}
|
||
if (otherToken === currentToken) return;
|
||
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, setDrawerOpen],
|
||
);
|
||
|
||
const findSelectedKeys = useCallback(
|
||
(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, findSelectedKeys],
|
||
);
|
||
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
|
||
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, setOpenKeys]);
|
||
|
||
const handleOpenChange = useCallback((keys: string[]) => {
|
||
setOpenKeys(keys);
|
||
}, [setOpenKeys]);
|
||
|
||
const transformToMenuItems = useCallback((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, handleOpenChange, transformToMenuItems],
|
||
);
|
||
|
||
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',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<BrandLogo size={collapsed ? 26 : 30} />
|
||
{!collapsed && <span>学生管理系统</span>}
|
||
</div>
|
||
</div>
|
||
{menuContent}
|
||
</Sider>
|
||
)}
|
||
{usesDrawer && (
|
||
<Drawer
|
||
placement="left"
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
size={240}
|
||
styles={{ body: { padding: 0 } }}
|
||
className="app-navigation-drawer"
|
||
title={
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||
<BrandLogo size={24} />
|
||
学生管理系统
|
||
</span>
|
||
}
|
||
>
|
||
{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;
|