新增: - AlertList 告警列表页: 状态筛选/确认/忽略操作 - AlertRuleList 告警规则页: 创建/编辑/启停管理 - alerts + deviceReadings 前端 API 层 - App.tsx 路由注册 + MainLayout 标题 fallback - wiki/frontend.md 更新页面清单 修复: - ArticleEditor: 修复 unused variable 构建错误 - FollowUpTaskList: 修复 filter(Boolean) 类型窄化问题
536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
import { useCallback, useState, memo, useEffect, useMemo } from 'react';
|
||
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, 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,
|
||
TrophyOutlined,
|
||
ShopOutlined,
|
||
FileTextOutlined,
|
||
DashboardOutlined,
|
||
RobotOutlined,
|
||
HistoryOutlined,
|
||
BarChartOutlined,
|
||
} 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 NotificationPanel from '../components/NotificationPanel';
|
||
|
||
const { Header, Sider, Content, Footer } = Layout;
|
||
|
||
interface MenuItem {
|
||
key: string;
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
}
|
||
|
||
// 完整图标映射表
|
||
const iconMap: Record<string, React.ReactNode> = {
|
||
HomeOutlined: <HomeOutlined />,
|
||
UserOutlined: <UserOutlined />,
|
||
SafetyOutlined: <SafetyOutlined />,
|
||
ApartmentOutlined: <ApartmentOutlined />,
|
||
SettingOutlined: <SettingOutlined />,
|
||
PartitionOutlined: <PartitionOutlined />,
|
||
MessageOutlined: <MessageOutlined />,
|
||
AppstoreOutlined: <AppstoreOutlined />,
|
||
TeamOutlined: <TeamOutlined />,
|
||
TableOutlined: <TableOutlined />,
|
||
TagsOutlined: <TagsOutlined />,
|
||
HeartOutlined: <HeartOutlined />,
|
||
CalendarOutlined: <CalendarOutlined />,
|
||
PhoneOutlined: <PhoneOutlined />,
|
||
CommentOutlined: <CommentOutlined />,
|
||
MedicineBoxOutlined: <MedicineBoxOutlined />,
|
||
TrophyOutlined: <TrophyOutlined />,
|
||
ShopOutlined: <ShopOutlined />,
|
||
FileTextOutlined: <FileTextOutlined />,
|
||
DashboardOutlined: <DashboardOutlined />,
|
||
RobotOutlined: <RobotOutlined />,
|
||
HistoryOutlined: <HistoryOutlined />,
|
||
BarChartOutlined: <BarChartOutlined />,
|
||
};
|
||
|
||
function getIcon(name?: string): React.ReactNode {
|
||
if (!name) return <AppstoreOutlined />;
|
||
return iconMap[name] || <AppstoreOutlined />;
|
||
}
|
||
|
||
// 路由标题 fallback(给动态参数路由用)
|
||
const routeTitleFallback: Record<string, string> = {
|
||
'/health/patients/:id': '患者详情',
|
||
'/health/follow-up-records': '随访记录',
|
||
'/health/consultations/:id': '咨询详情',
|
||
'/health/points-rules': '积分规则管理',
|
||
'/health/offline-events': '线下活动管理',
|
||
'/health/articles': '内容管理',
|
||
'/health/articles/new': '新建文章',
|
||
'/health/articles/:id/edit': '编辑文章',
|
||
'/health/article-categories': '分类管理',
|
||
'/health/article-tags': '标签管理',
|
||
'/health/alerts': '告警列表',
|
||
'/health/alert-rules': '告警规则',
|
||
};
|
||
|
||
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);
|
||
}, [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, 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 || '/';
|
||
|
||
// 动态菜单状态
|
||
const [dynamicMenus, setDynamicMenus] = useState<MenuInfo[]>([]);
|
||
const [menuLoading, setMenuLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const menus = await getMenusForUser();
|
||
if (!cancelled) setDynamicMenus(menus);
|
||
} catch {
|
||
// fallback: 使用空数组,保留插件菜单
|
||
}
|
||
if (!cancelled) setMenuLoading(false);
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
// 加载插件菜单
|
||
useEffect(() => {
|
||
fetchPlugins(1, 'running');
|
||
}, [fetchPlugins]);
|
||
|
||
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;
|
||
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>
|
||
|
||
{/* 动态菜单 */}
|
||
{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 ${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'}`}>
|
||
{headerTitle}
|
||
</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>
|
||
);
|
||
}
|