Files
hms/apps/web/src/layouts/MainLayout.tsx
iven 7a2d8e4664
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(web): 前端功能验证修复 — 移除硬编码假数据/修正系统信息/修复dev.ps1环境变量
- refactor(Home): 待办任务改为从工作流API获取真实数据
- refactor(Home): 最近动态改为从审计日志API获取真实操作记录
- refactor(Home): 移除硬编码的sparkline趋势图和假统计数据
- fix(Home): 系统信息 PostgreSQL 16→18,模块数量 5→6
- fix(Login): 移除硬编码版本号 v0.1.0
- fix(MainLayout): Footer 更新为 "HMS 健康管理平台"
- fix(dev.ps1): 添加缺失的 WECHAT/HEALTH 环境变量
2026-04-25 10:53:58 +08:00

406 lines
13 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 } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
import {
HomeOutlined,
UserOutlined,
SafetyOutlined,
ApartmentOutlined,
SettingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PartitionOutlined,
LogoutOutlined,
MessageOutlined,
SearchOutlined,
BulbOutlined,
BulbFilled,
AppstoreOutlined,
TeamOutlined,
TableOutlined,
TagsOutlined,
RightOutlined,
HeartOutlined,
CalendarOutlined,
PhoneOutlined,
CommentOutlined,
MedicineBoxOutlined,
} 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 NotificationPanel from '../components/NotificationPanel';
const { Header, Sider, Content, Footer } = Layout;
interface MenuItem {
key: string;
icon: React.ReactNode;
label: string;
}
const mainMenuItems: MenuItem[] = [
{ key: '/', icon: <HomeOutlined />, label: '工作台' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/roles', icon: <SafetyOutlined />, label: '权限管理' },
{ key: '/organizations', icon: <ApartmentOutlined />, label: '组织架构' },
];
const bizMenuItems: MenuItem[] = [
{ key: '/workflow', icon: <PartitionOutlined />, label: '工作流' },
{ key: '/messages', icon: <MessageOutlined />, label: '消息中心' },
];
const healthMenuItems: MenuItem[] = [
{ key: '/health/patients', icon: <TeamOutlined />, label: '患者管理' },
{ key: '/health/doctors', icon: <MedicineBoxOutlined />, label: '医护管理' },
{ key: '/health/appointments', icon: <CalendarOutlined />, label: '预约排班' },
{ key: '/health/schedules', icon: <HeartOutlined />, label: '排班管理' },
{ key: '/health/follow-up-tasks', icon: <PhoneOutlined />, label: '随访管理' },
{ key: '/health/consultations', icon: <CommentOutlined />, label: '咨询管理' },
{ key: '/health/tags', icon: <TagsOutlined />, label: '标签管理' },
];
const sysMenuItems: MenuItem[] = [
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/plugins/admin', icon: <AppstoreOutlined />, label: '插件管理' },
];
const routeTitleMap: Record<string, string> = {
'/': '工作台',
'/users': '用户管理',
'/roles': '权限管理',
'/organizations': '组织架构',
'/workflow': '工作流',
'/messages': '消息中心',
'/settings': '系统设置',
'/plugins/admin': '插件管理',
'/health/patients': '患者管理',
'/health/patients/:id': '患者详情',
'/health/tags': '标签管理',
'/health/doctors': '医护管理',
'/health/appointments': '预约排班',
'/health/schedules': '排班管理',
'/health/follow-up-tasks': '随访管理',
'/health/follow-up-records': '随访记录',
'/health/consultations': '咨询管理',
'/health/consultations/:id': '咨询详情',
};
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
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>
);
});
// 动态图标映射
const pluginIconMap: Record<string, React.ReactNode> = {
AppstoreOutlined: <AppstoreOutlined />,
team: <TeamOutlined />,
TeamOutlined: <TeamOutlined />,
user: <UserOutlined />,
UserOutlined: <UserOutlined />,
message: <MessageOutlined />,
MessageOutlined: <MessageOutlined />,
tags: <TagsOutlined />,
TagsOutlined: <TagsOutlined />,
apartment: <ApartmentOutlined />,
ApartmentOutlined: <ApartmentOutlined />,
TableOutlined: <TableOutlined />,
DashboardOutlined: <AppstoreOutlined />,
};
function getPluginIcon(iconName: string): React.ReactNode {
return pluginIconMap[iconName] || <AppstoreOutlined />;
}
// 插件子菜单组 — 可折叠二级标题 + 三级菜单项
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) {
// 折叠模式显示插件图标Tooltip 列出所有子项
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: getPluginIcon(item.icon),
label: item.label,
}}
isActive={currentPath === item.key}
collapsed={collapsed}
onClick={() => onNavigate(item.key)}
indented
/>
))}
</div>
);
});
export default function MainLayout({ children }: { children: React.ReactNode }) {
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
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 || '/';
// 加载插件菜单
useEffect(() => {
fetchPlugins(1, 'running');
}, [fetchPlugins]);
const handleLogout = useCallback(async () => {
await logout();
navigate('/login');
}, [logout, navigate]);
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;
const isDark = themeMode === 'dark';
return (
<Layout style={{ minHeight: '100vh' }}>
{/* 现代深色侧边栏 */}
<Sider
trigger={null}
collapsible
collapsed={sidebarCollapsed}
width={240}
collapsedWidth={72}
className={isDark ? 'erp-sider-dark' : 'erp-sider-dark erp-sider-default'}
>
{/* Logo 区域 */}
<div className="erp-sidebar-logo" onClick={() => navigate('/')}>
<div className="erp-sidebar-logo-icon">E</div>
{!sidebarCollapsed && (
<span className="erp-sidebar-logo-text">ERP Platform</span>
)}
</div>
{/* 菜单组:基础模块 */}
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{mainMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={item}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
</div>
{/* 菜单组:业务模块 */}
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{bizMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={item}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
</div>
{/* 菜单组:健康管理 */}
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{healthMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={item}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
</div>
{/* 菜单组:插件 */}
{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>
</>
)}
{/* 菜单组:系统 */}
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{sysMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={item}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
</div>
</Sider>
{/* 右侧主区域 */}
<Layout
className={`erp-main-layout ${isDark ? 'erp-main-layout-dark' : 'erp-main-layout-light'}`}
style={{ marginLeft: sidebarWidth }}
>
{/* 顶部导航栏 */}
<Header className={`erp-header ${isDark ? 'erp-header-dark' : 'erp-header-light'}`}>
{/* 左侧:折叠按钮 + 标题 */}
<Space size="middle" style={{ alignItems: 'center' }}>
<div className="erp-header-btn" onClick={toggleSidebar}>
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<span className={`erp-header-title ${isDark ? 'erp-text-dark' : 'erp-text-light'}`}>
{routeTitleMap[currentPath] ||
pluginMenuItems.find((p) => p.key === currentPath)?.label ||
'页面'}
</span>
</Space>
{/* 右侧:搜索 + 通知 + 主题切换 + 用户 */}
<Space size={4} style={{ alignItems: 'center' }}>
<Tooltip title="搜索">
<div className="erp-header-btn">
<SearchOutlined style={{ fontSize: 16 }} />
</div>
</Tooltip>
<Tooltip title={isDark ? '切换亮色模式' : '切换暗色模式'}>
<div className="erp-header-btn" onClick={() => setTheme(isDark ? 'light' : 'dark')}>
{isDark ? <BulbFilled style={{ fontSize: 16 }} /> : <BulbOutlined style={{ fontSize: 16 }} />}
</div>
</Tooltip>
<NotificationPanel />
<div className={`erp-header-divider ${isDark ? 'erp-header-divider-dark' : 'erp-header-divider-light'}`} />
<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 ${isDark ? 'erp-text-dark-secondary' : 'erp-text-light-secondary'}`}>
{user?.display_name || user?.username || 'User'}
</span>
)}
</div>
</Dropdown>
</Space>
</Header>
{/* 内容区域 */}
<Content style={{ padding: 20, minHeight: 'calc(100vh - 56px - 48px)' }}>
{children}
</Content>
{/* 底部 */}
<Footer className={`erp-footer ${isDark ? 'erp-footer-dark' : 'erp-footer-light'}`}>
HMS
</Footer>
</Layout>
</Layout>
);
}