Files
gongxue-base/apps/admin/src/layouts/MainLayout.tsx
wangziqi bc6b8b0095 fix: 修复入住管理白屏、重构首页工作台、拍平教务菜单
- Occupancies: 补齐缺失的 Alert import(渲染时 ReferenceError 导致白屏),
  并为两处 api.get 补泛型消除既有类型错误
- Dashboard: 改为待办优先的工作台(待办异常区 + 核心 KPI + 更多指标折叠 +
  图表按重要性重排),修复 fetchData 无限请求循环(loadedRef),
  重活图表用 IntersectionObserver(callback ref)懒加载
- MainLayout: 将三层嵌套的"教室管理"提升为一级菜单,全站菜单统一为两层
2026-07-10 11:16:30 +08:00

332 lines
11 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,
} from '@ant-design/icons';
import { usePermission } from '../hooks/usePermission';
import NotificationBell from '../components/NotificationBell';
const { Header, Sider, Content } = Layout;
interface MenuItemType {
key: string;
icon: React.ReactNode;
label: string;
permission?: string;
children?: MenuItemType[];
}
const allMenuItems: MenuItemType[] = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '数据面板',
permission: 'dashboard:view',
},
{
key: 'dorm-group',
icon: <HomeOutlined />,
label: '宿舍运营',
permission: 'room:view',
children: [
{ key: '/room-visual', icon: <AppstoreOutlined />, label: '宿舍总览', permission: 'room:view' },
{ key: '/rooms', icon: <HomeOutlined />, label: '宿舍管理', permission: 'room:view' },
{ key: '/occupancies', icon: <SwapOutlined />, label: '入住管理', permission: 'occupancy:view' },
],
},
{
key: 'student-group',
icon: <TeamOutlined />,
label: '学员管理',
permission: 'student:view',
children: [
{ key: '/students', icon: <TeamOutlined />, label: '学生管理', permission: 'student:view' },
{ key: '/classes', icon: <TeamOutlined />, label: '班级管理', permission: 'class:view' },
{ key: '/attendance', icon: <CheckCircleOutlined />, label: '考勤管理', permission: 'attendance:view' },
],
},
{
key: 'academic-group',
icon: <CalendarOutlined />,
label: '教务管理',
permission: 'schedule:view',
children: [
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class:view' },
],
},
{
key: 'classroom-group',
icon: <ReadOutlined />,
label: '教室管理',
permission: 'classroom:view',
children: [
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'classroom:view' },
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
],
},
{
key: 'finance-group',
icon: <DollarOutlined />,
label: '财务管理',
permission: 'expense:view',
children: [
{ key: '/expenses', icon: <DollarOutlined />, label: '费用录入', permission: 'expense:view' },
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
],
},
{
key: 'system-group',
icon: <SettingOutlined />,
label: '系统管理',
permission: 'log:view',
children: [
{ key: '/notifications', icon: <BellOutlined />, label: '通知中心', permission: 'notification:view' },
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },
{ key: '/integration-config', icon: <ApiOutlined />, label: '钉钉集成配置', permission: 'integration:read' },
{ key: '/users', icon: <SettingOutlined />, label: '账号管理', permission: 'user:view' },
],
},
];
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 = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []);
const { hasPermission } = usePermission();
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
// 按 permission 过滤菜单
const filterByPermission = (items: MenuItemType[]): MenuItemType[] => {
return items
.map((item) => {
if (item.children) {
const kids = filterByPermission(item.children);
if (kids.length === 0) return null;
return { ...item, children: kids };
}
if (!item.permission) return item;
return hasPermission(item.permission) ? item : null;
})
.filter(Boolean) as MenuItemType[];
};
const menuItems = useMemo(() => filterByPermission(allMenuItems), [hasPermission]);
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: MenuItemType[], 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: MenuItemType[], 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: MenuItemType[]): any[] => {
return items.map((item) => ({
key: item.key,
icon: item.icon,
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 }}>
<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;