Files
hms/apps/web/src/layouts/MainLayout.tsx
iven d44c6167b1 fix: E2E 测试发现的 10 项 BUG 修复 — 全栈验证通过
P0 修复:
- 侧边栏路由不稳定: Content 区域添加 key={currentPath} 强制重渲染
- 轮播图缩略图不显示: BannerManage 导入 resolveMediaUrl + 反斜杠转正斜杠
- 超长名称导致 500: patient_handler 添加 name.len() > 255 校验
- 迁移 m20260515_000146: version 乐观锁 version+1 修复

P1 修复:
- 排班路由被冻结: routeConfig.ts 移除 /health/schedules 的 frozen 标记
- 轮播图 Switch 切换无效: 切换前先 GET 最新 version 避免乐观锁冲突
- thumbnail_url 反斜杠: media_service 存储时统一 replace('\', '/')

P2 修复:
- 预约类型 follow_up 未映射: APPOINTMENT_TYPE_MAP 补充 '随访'
- 日期选择器未汉化: DatePicker.RangePicker 添加中文 placeholder
- 轮播图 title 必填校验: banner_handler 添加空标题拒绝
- 文章分类重名: article_category_service 添加同名检查
2026-05-15 21:13:49 +08:00

508 lines
16 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 { 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<string, string> = {
// 动态参数路由
'/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 (
<Tooltip title={collapsed ? item.label : ''} placement="right">
<div
onClick={onClick}
className={`erp-sidebar-item ${isActive ? 'erp-sidebar-item-active' : ''} ${indented ? 'erp-sidebar-item-indented' : ''}`}
>
<span className="erp-sidebar-item-icon">{item.icon}</span>
{!collapsed && <span className="erp-sidebar-item-label">{item.label}</span>}
</div>
</Tooltip>
);
});
// 可折叠子分组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 (
<Tooltip title={directory.title} placement="right">
<div
onClick={() => {
const first = visibleChildren[0];
if (first) onNavigate(first.path || first.id);
}}
className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
>
<span className="erp-sidebar-item-icon">{getIcon(directory.icon)}</span>
</div>
</Tooltip>
);
}
return (
<div>
<div
className={`erp-sidebar-submenu-title ${hasActive ? 'erp-sidebar-submenu-title-active' : ''}`}
onClick={() => setExpanded((e) => !e)}
>
<span className="erp-sidebar-submenu-arrow">
<RightOutlined
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
</span>
<span className="erp-sidebar-submenu-label">{directory.title}</span>
</div>
{expanded && visibleChildren.map((child) => (
<SidebarMenuItem
key={child.id}
item={{
key: child.path || child.id,
icon: getIcon(child.icon),
label: child.title,
}}
isActive={currentPath === (child.path || child.id)}
collapsed={collapsed}
onClick={() => onNavigate(child.path || child.id)}
indented
/>
))}
</div>
);
});
// 插件子菜单组
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 (
<Tooltip title={tooltipContent} placement="right">
<div
onClick={() => {
const first = group.items[0];
if (first) onNavigate(first.key);
}}
className={`erp-sidebar-item ${hasActive ? 'erp-sidebar-item-active' : ''}`}
>
<span className="erp-sidebar-item-icon"><AppstoreOutlined /></span>
</div>
</Tooltip>
);
}
return (
<div>
<div
className={`erp-sidebar-submenu-title ${hasActive ? 'erp-sidebar-submenu-title-active' : ''}`}
onClick={() => setExpanded((e) => !e)}
>
<span className="erp-sidebar-submenu-arrow">
<RightOutlined
style={{ fontSize: 10, transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
</span>
<span className="erp-sidebar-submenu-label">{group.pluginName}</span>
</div>
{expanded && group.items.map((item) => (
<SidebarMenuItem
key={item.key}
item={{
key: item.key,
icon: getIcon(item.icon),
label: item.label,
}}
isActive={currentPath === item.key}
collapsed={collapsed}
onClick={() => onNavigate(item.key)}
indented
/>
))}
</div>
);
});
// 判断是否为可点击的叶子菜单类型
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 (
<div key={menu.id}>
{!collapsed && <div className="erp-sidebar-group">{menu.title}</div>}
<div className="erp-sidebar-menu">
{visibleChildren.map((child) => {
if (child.menu_type === 'directory') {
return (
<CollapsibleSubGroup
key={child.id}
directory={child}
collapsed={collapsed}
currentPath={currentPath}
onNavigate={onNavigate}
/>
);
}
if (isLeafType(child.menu_type)) {
return (
<SidebarMenuItem
key={child.id}
item={{
key: child.path || child.id,
icon: getIcon(child.icon),
label: child.title,
}}
isActive={currentPath === (child.path || child.id)}
collapsed={collapsed}
onClick={() => onNavigate(child.path || child.id)}
/>
);
}
return null;
})}
</div>
</div>
);
}
if (isLeafType(menu.menu_type) && menu.visible !== false) {
return (
<SidebarMenuItem
key={menu.id}
item={{
key: menu.path || menu.id,
icon: getIcon(menu.icon),
label: menu.title,
}}
isActive={currentPath === (menu.path || menu.id)}
collapsed={collapsed}
onClick={() => 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<MenuInfo[]>([]);
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: <UserOutlined />,
label: user?.display_name || user?.username || '用户',
disabled: true,
},
{ type: 'divider' as const },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
onClick: handleLogout,
},
];
const sidebarWidth = sidebarCollapsed ? 72 : 240;
return (
<Layout style={{ minHeight: '100vh' }}>
{/* 侧边栏 */}
<Sider
trigger={null}
collapsible
collapsed={sidebarCollapsed}
width={240}
collapsedWidth={72}
className="erp-sider-dark"
>
{/* Logo 区域 */}
<div className="erp-sidebar-logo" onClick={() => navigate('/')}>
<div className="erp-sidebar-logo-icon">H</div>
{!sidebarCollapsed && (
<span className="erp-sidebar-logo-text">{themeConfig?.brand_name || 'HMS 健康'}</span>
)}
</div>
{/* 动态菜单 */}
{menuLoading ? (
<div style={{ padding: 16, textAlign: 'center' }}>
<Spin size="small" />
</div>
) : (
<DynamicMenuSection
menus={dynamicMenus}
collapsed={sidebarCollapsed}
currentPath={currentPath}
onNavigate={(key) => navigate(key)}
/>
)}
{/* 插件菜单 */}
{pluginMenuGroups.length > 0 && (
<>
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{pluginMenuGroups.map((group) => (
<SidebarSubMenu
key={group.pluginId}
group={group}
collapsed={sidebarCollapsed}
currentPath={currentPath}
onNavigate={(key) => navigate(key)}
/>
))}
</div>
</>
)}
</Sider>
{/* 右侧主区域 */}
<Layout
className="erp-main-layout"
style={{ marginLeft: sidebarWidth }}
>
{/* 顶部导航栏 */}
<Header className="erp-header">
{/* 左侧:折叠按钮 + 标题 */}
<Space size="middle" style={{ alignItems: 'center' }}>
<div className="erp-header-btn" onClick={toggleSidebar}>
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<span className="erp-header-title">
{headerTitle}
</span>
</Space>
{/* 右侧:搜索 + 主题切换 + 通知 + 用户 */}
<Space size={4} style={{ alignItems: 'center' }}>
<Tooltip title="搜索">
<div className="erp-header-btn">
<SearchOutlined style={{ fontSize: 16 }} />
</div>
</Tooltip>
<ThemeSwitcher />
<NotificationPanel />
<div className="erp-header-divider" />
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}>
<div className="erp-header-user">
<Avatar
size={30}
className="erp-user-avatar"
>
{(user?.display_name?.[0] || user?.username?.[0] || 'U').toUpperCase()}
</Avatar>
{!sidebarCollapsed && (
<span className="erp-user-name">
{user?.display_name || user?.username || 'User'}
</span>
)}
</div>
</Dropdown>
</Space>
</Header>
{/* 内容区域 */}
<Content style={{ padding: 20, minHeight: 'calc(100vh - 56px - 48px)' }}>
<div key={currentPath}>{children}</div>
</Content>
{/* 底部 */}
<Footer className="erp-footer">
{themeConfig?.brand_copyright || 'HMS 健康管理平台'}
</Footer>
</Layout>
</Layout>
);
}