feat: 添加管理端前端 (HMS 基座 React 管理面板)
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

- 从 HMS 基座复制 apps/web/ (React + Ant Design + Vite + TypeScript)
- 管理端自动代理 API 到 localhost:3000 (vite.config.ts)
- 更新 scripts/dev.sh 支持三端启动: backend/admin/app
- 登录验证通过, 用户管理/角色权限/审计日志等页面正常
- 添加 .gitignore 排除 node_modules/dist
This commit is contained in:
iven
2026-06-02 10:03:13 +08:00
parent 181bfb1f3e
commit 8111471e93
341 changed files with 72102 additions and 1059 deletions

View File

@@ -0,0 +1,375 @@
import { useCallback, useState, useEffect, useMemo } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme, Menu } from 'antd';
import type { MenuItemType, SubMenuType } from 'antd/es/menu/hooks/useItems';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
LogoutOutlined,
SearchOutlined,
AppstoreOutlined,
UserOutlined,
RobotOutlined,
} 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';
import AiSidebar from '../components/ai/AiSidebar';
const { Header, Sider, Content, Footer } = Layout;
// 路由标题 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': '标签管理',
'/health/schedules': '排班管理',
'/health/appointments': '预约管理',
};
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;
}
// 将后端 MenuInfo 树转为 Ant Design Menu 的 items 格式
type AntMenuItem = MenuItemType | SubMenuType;
function buildMenuItems(menus: MenuInfo[]): AntMenuItem[] {
return menus
.filter((m) => m.visible !== false && m.menu_type !== 'button')
.map((m) => {
const visibleChildren = m.children?.filter((c) => c.visible !== false && c.menu_type !== 'button') || [];
if ((m.menu_type === 'directory') && visibleChildren.length > 0) {
return {
key: m.id,
icon: getIcon(m.icon),
label: m.title,
children: buildMenuItems(visibleChildren),
};
}
return {
key: m.path || m.id,
icon: getIcon(m.icon),
label: m.title,
};
});
}
// 查找包含指定 path 的所有父级 key用于自动展开 openKeys
function findParentKeys(menus: MenuInfo[], targetPath: string): string[] {
const keys: string[] = [];
function walk(items: MenuInfo[], parents: string[]): boolean {
for (const m of items) {
if (m.path === targetPath) {
keys.push(...parents);
return true;
}
if (m.children) {
if (walk(m.children, [...parents, m.id])) return true;
}
}
return false;
}
walk(menus, []);
return keys;
}
// 插件菜单也纳入 Menu items
function buildPluginItems(groups: PluginMenuGroup[]): AntMenuItem[] {
return groups.map((g) => ({
key: `plugin-${g.pluginId}`,
icon: <AppstoreOutlined />,
label: g.pluginName,
children: g.items.map((item) => ({
key: item.key,
icon: getIcon(item.icon),
label: item.label,
})),
}));
}
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);
const [aiSidebarOpen, setAiSidebarOpen] = useState(false);
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; };
}, []);
// 合并动态菜单 + 插件菜单为 Ant Design Menu items
const allMenuItems = useMemo(() => {
const items = buildMenuItems(dynamicMenus);
if (pluginMenuGroups.length > 0) {
items.push(...buildPluginItems(pluginMenuGroups));
}
return items;
}, [dynamicMenus, pluginMenuGroups]);
// openKeys: 自动展开包含当前路由的父级
const autoExpandKeys = useMemo(() => {
const keys = findParentKeys(dynamicMenus, currentPath);
for (const g of pluginMenuGroups) {
if (g.items.some((it) => it.key === currentPath)) {
keys.push(`plugin-${g.pluginId}`);
}
}
return keys;
}, [currentPath, dynamicMenus, pluginMenuGroups]);
const [openKeys, setOpenKeys] = useState<string[]>([]);
const [lastExpandedPath, setLastExpandedPath] = useState(currentPath);
if (currentPath !== lastExpandedPath) {
setLastExpandedPath(currentPath);
if (autoExpandKeys.length > 0) {
setOpenKeys((prev) => [...new Set([...prev, ...autoExpandKeys])]);
}
}
// 加载插件菜单
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>
) : (
<Menu
mode="inline"
inlineCollapsed={sidebarCollapsed}
items={allMenuItems}
selectedKeys={[currentPath]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
onClick={({ key }) => navigate(key)}
className="erp-sidebar-menu"
/>
)}
</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>
{/* AI 助手浮动按钮 + 侧边栏 */}
<Tooltip title="AI 健康助手" placement="left">
<div
onClick={() => setAiSidebarOpen(true)}
style={{
position: 'fixed',
right: 24,
bottom: 32,
width: 48,
height: 48,
borderRadius: '50%',
background: 'linear-gradient(135deg, #1677ff 0%, #722ed1 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(22, 119, 255, 0.4)',
zIndex: 1000,
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.1)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(22, 119, 255, 0.6)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(22, 119, 255, 0.4)';
}}
>
<RobotOutlined style={{ color: '#fff', fontSize: 22 }} />
</div>
</Tooltip>
<AiSidebar open={aiSidebarOpen} onClose={() => setAiSidebarOpen(false)} />
</Layout>
);
}