import { useCallback, useState, memo, useEffect, useMemo } from 'react'; import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme } from 'antd'; import { MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, SearchOutlined, AppstoreOutlined, RightOutlined, UserOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAppStore } from '../stores/app'; import { useAuthStore } from '../stores/auth'; import { usePluginStore } from '../stores/plugin'; import type { PluginMenuGroup } from '../stores/plugin'; import { getMenusForUser, type MenuInfo } from '../api/menus'; import { getIcon } from '../utils/iconRegistry'; import NotificationPanel from '../components/NotificationPanel'; import ThemeSwitcher from '../components/ThemeSwitcher'; const { Header, Sider, Content, Footer } = Layout; interface MenuItem { key: string; icon: React.ReactNode; label: string; } // 路由标题 fallback — 仅保留后端菜单无法覆盖的路由 // 1. 动态参数路由(:id/:id/edit)— 菜单表不会存储这些路径 // 2. 无后端菜单记录的静态页面路由 const routeTitleFallback: Record = { // 动态参数路由 '/health/patients/:id': '患者详情', '/health/consultations/:id': '咨询详情', '/health/articles/new': '新建文章', '/health/articles/:id/edit': '编辑文章', '/health/care-plans/:id': '护理计划详情', '/health/shifts/:id': '班次详情', '/health/ble-gateways/:id': '网关详情', // 无后端菜单的静态路由 '/health/follow-up-records': '随访记录', '/health/article-categories': '分类管理', '/health/article-tags': '标签管理', }; function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { for (const m of menus) { if (m.path === path) return m.title; if (m.children) { const found = getTitleFromMenus(path, m.children); if (found) return found; } } return undefined; } // 侧边栏菜单项 const SidebarMenuItem = memo(function SidebarMenuItem({ item, isActive, collapsed, onClick, indented, }: { item: MenuItem; isActive: boolean; collapsed: boolean; onClick: () => void; indented?: boolean; }) { return (
{item.icon} {!collapsed && {item.label}}
); }); // 可折叠子分组(3 级菜单) const CollapsibleSubGroup = memo(function CollapsibleSubGroup({ directory, collapsed, currentPath, onNavigate, }: { directory: MenuInfo; collapsed: boolean; currentPath: string; onNavigate: (key: string) => void; }) { const [expanded, setExpanded] = useState(false); const visibleChildren = directory.children?.filter((c) => c.visible !== false) || []; const hasActive = visibleChildren.some((c) => currentPath === (c.path || c.id)); useEffect(() => { if (hasActive) setExpanded(true); // eslint-disable-line react-hooks/set-state-in-effect -- 初始展开包含活跃菜单的分组 }, [hasActive]); if (collapsed) { return (
{ const first = visibleChildren[0]; if (first) onNavigate(first.path || first.id); }} className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`} > {getIcon(directory.icon)}
); } return (
setExpanded((e) => !e)} > {directory.title}
{expanded && visibleChildren.map((child) => ( onNavigate(child.path || child.id)} indented /> ))}
); }); // 插件子菜单组 const SidebarSubMenu = memo(function SidebarSubMenu({ group, collapsed, currentPath, onNavigate, }: { group: PluginMenuGroup; collapsed: boolean; currentPath: string; onNavigate: (key: string) => void; }) { const [expanded, setExpanded] = useState(true); const hasActive = group.items.some((item) => currentPath === item.key); if (collapsed) { const tooltipContent = group.items.map((item) => item.label).join(' / '); return (
{ const first = group.items[0]; if (first) onNavigate(first.key); }} className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`} >
); } return (
setExpanded((e) => !e)} > {group.pluginName}
{expanded && group.items.map((item) => ( onNavigate(item.key)} indented /> ))}
); }); // 判断是否为可点击的叶子菜单类型 function isLeafType(menuType: string): boolean { return menuType === 'menu' || menuType === 'page'; } // 动态菜单渲染(支持多级嵌套) const DynamicMenuSection = memo(function DynamicMenuSection({ menus, collapsed, currentPath, onNavigate, }: { menus: MenuInfo[]; collapsed: boolean; currentPath: string; onNavigate: (key: string) => void; }) { return ( <> {menus.map((menu) => { if (menu.menu_type === 'directory') { const visibleChildren = menu.children?.filter((c) => c.visible !== false) || []; return (
{!collapsed &&
{menu.title}
}
{visibleChildren.map((child) => { if (child.menu_type === 'directory') { return ( ); } if (isLeafType(child.menu_type)) { return ( onNavigate(child.path || child.id)} /> ); } return null; })}
); } if (isLeafType(menu.menu_type) && menu.visible !== false) { return ( onNavigate(menu.path || menu.id)} /> ); } return null; })} ); }); export default function MainLayout({ children }: { children: React.ReactNode }) { const { sidebarCollapsed, toggleSidebar } = useAppStore(); const themeConfig = useAppStore((s) => s.themeConfig); const loadThemeConfig = useAppStore((s) => s.loadThemeConfig); const { user, logout } = useAuthStore(); const pluginMenuItems = usePluginStore((s) => s.pluginMenuItems); const pluginMenuGroups = usePluginStore((s) => s.pluginMenuGroups); const fetchPlugins = usePluginStore((s) => s.fetchPlugins); theme.useToken(); const navigate = useNavigate(); const location = useLocation(); const currentPath = location.pathname || '/'; // 动态菜单状态 const [dynamicMenus, setDynamicMenus] = useState([]); const [menuLoading, setMenuLoading] = useState(true); useEffect(() => { let cancelled = false; (async () => { try { const menus = await getMenusForUser(); if (!cancelled) { // 根据用户权限过滤菜单:菜单项声明 permission 时,用户必须有对应权限 const perms = useAuthStore.getState().permissions; const isAdmin = useAuthStore.getState().user?.roles?.some((r) => typeof r === 'object' && r.code === 'admin') ?? false; if (isAdmin) { setDynamicMenus(menus); } else { const filterByPerm = (items: MenuInfo[]): MenuInfo[] => items .map((m) => ({ ...m, children: m.children ? filterByPerm(m.children) : undefined, })) .filter((m) => { if (m.menu_type === 'directory') return true; if (!m.permission) return false; return perms.includes(m.permission); }) .filter((m) => m.menu_type === 'directory' || (m.children && m.children.length > 0) || (m.permission && perms.includes(m.permission))); setDynamicMenus(filterByPerm(menus)); } } } catch { // fallback: 使用空数组,保留插件菜单 } if (!cancelled) setMenuLoading(false); })(); return () => { cancelled = true; }; }, []); // 加载插件菜单 useEffect(() => { fetchPlugins(1, 'running'); }, [fetchPlugins]); // 加载主题配置 useEffect(() => { loadThemeConfig(); }, [loadThemeConfig]); const handleLogout = useCallback(async () => { await logout(); navigate('/login'); }, [logout, navigate]); // 标题查找:先从动态菜单查找,再 fallback(支持动态路径参数匹配) const headerTitle = useMemo(() => { const fromMenus = getTitleFromMenus(currentPath, dynamicMenus); if (fromMenus) return fromMenus; // 尝试模式匹配 routeTitleFallback 的 key(如 /health/patients/:id) for (const [pattern, title] of Object.entries(routeTitleFallback)) { const regex = new RegExp('^' + pattern.replace(/:[^/]+/g, '[^/]+') + '$'); if (regex.test(currentPath)) return title; } return pluginMenuItems.find((p) => p.key === currentPath)?.label || '页面'; }, [currentPath, dynamicMenus, pluginMenuItems]); const userMenuItems = [ { key: 'profile', icon: , label: user?.display_name || user?.username || '用户', disabled: true, }, { type: 'divider' as const }, { key: 'logout', icon: , label: '退出登录', danger: true, onClick: handleLogout, }, ]; const sidebarWidth = sidebarCollapsed ? 72 : 240; return ( {/* 侧边栏 */} {/* Logo 区域 */}
navigate('/')}>
H
{!sidebarCollapsed && ( {themeConfig?.brand_name || 'HMS 健康'} )}
{/* 动态菜单 */} {menuLoading ? (
) : ( navigate(key)} /> )} {/* 插件菜单 */} {pluginMenuGroups.length > 0 && ( <> {!sidebarCollapsed &&
插件
}
{pluginMenuGroups.map((group) => ( navigate(key)} /> ))}
)}
{/* 右侧主区域 */} {/* 顶部导航栏 */}
{/* 左侧:折叠按钮 + 标题 */}
{sidebarCollapsed ? : }
{headerTitle}
{/* 右侧:搜索 + 主题切换 + 通知 + 用户 */}
{(user?.display_name?.[0] || user?.username?.[0] || 'U').toUpperCase()} {!sidebarCollapsed && ( {user?.display_name || user?.username || 'User'} )}
{/* 内容区域 */}
{children}
{/* 底部 */}
); }