Files
gongxue-base/apps/admin/src/layouts/MainLayout.tsx
wangziqi d9c541dacc fix(admin): 跨标签页权限写入不再整页刷新
storage 事件里 permissions 变更改为原地写入权限 store,
仅 gongxue-auth token 实际变化(登录/退出/切换账号)才 reload,
避免多标签页相互触发刷新风暴
2026-08-06 14:53:22 +08:00

439 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;