feat: 添加管理端前端 (HMS 基座 React 管理面板)
- 从 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:
375
apps/web/src/layouts/MainLayout.tsx
Normal file
375
apps/web/src/layouts/MainLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user