feat(config): 菜单动态化改造 — 侧边栏从后端 API 加载
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

- 新增 seed 迁移插入完整菜单树(4 directory + 23 menu = 27 条)
- 新增 GET /api/v1/menus/user 端点(仅需登录,无需 menu.list 权限)
- MainLayout 从 API 动态获取菜单树替换硬编码数组
- 扩展图标映射表覆盖 22 个 Ant Design 图标
- Header 标题从动态菜单数据查找,保留 fallback
This commit is contained in:
iven
2026-04-26 01:55:01 +08:00
parent 2539e5fc44
commit e3177f262c
6 changed files with 323 additions and 138 deletions

View File

@@ -33,6 +33,11 @@ export async function getMenus() {
return data.data;
}
export async function getMenusForUser() {
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/menus/user');
return data.data;
}
export async function batchSaveMenus(menus: MenuItemReq[]) {
await client.put('/config/menus', { menus });
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useState, memo, useEffect } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
import { useCallback, useState, memo, useEffect, useMemo } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, Spin, theme } from 'antd';
import {
HomeOutlined,
UserOutlined,
@@ -37,6 +37,7 @@ 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;
@@ -47,68 +48,59 @@ interface MenuItem {
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 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 />,
};
const bizMenuItems: MenuItem[] = [
{ key: '/workflow', icon: <PartitionOutlined />, label: '工作流' },
{ key: '/messages', icon: <MessageOutlined />, label: '消息中心' },
];
function getIcon(name?: string): React.ReactNode {
if (!name) return <AppstoreOutlined />;
return iconMap[name] || <AppstoreOutlined />;
}
const healthMenuItems: MenuItem[] = [
{ key: '/health/statistics', icon: <DashboardOutlined />, label: '统计报表' },
{ 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: '标签管理' },
{ key: '/health/points-rules', icon: <TrophyOutlined />, label: '积分规则' },
{ key: '/health/points-products', icon: <ShopOutlined />, label: '商品管理' },
{ key: '/health/points-orders', icon: <FileTextOutlined />, label: '订单管理' },
{ key: '/health/offline-events', icon: <CalendarOutlined />, label: '线下活动' },
{ key: '/health/ai-prompts', icon: <RobotOutlined />, label: 'AI Prompt 管理' },
{ key: '/health/ai-analysis', icon: <HistoryOutlined />, label: 'AI 分析历史' },
{ key: '/health/ai-usage', icon: <BarChartOutlined />, label: 'AI 用量统计' },
];
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/statistics': '统计报表',
'/health/patients': '患者管理',
// 路由标题 fallback给动态参数路由用
const routeTitleFallback: Record<string, string> = {
'/health/patients/:id': '患者详情',
'/health/tags': '标签管理',
'/health/doctors': '医护管理',
'/health/appointments': '预约排班',
'/health/schedules': '排班管理',
'/health/follow-up-tasks': '随访管理',
'/health/follow-up-records': '随访记录',
'/health/consultations': '咨询管理',
'/health/consultations/:id': '咨询详情',
'/health/points-rules': '积分规则管理',
'/health/points-products': '商品管理',
'/health/points-orders': '订单管理',
'/health/offline-events': '线下活动管理',
};
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
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,
@@ -135,28 +127,7 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
);
});
// 动态图标映射
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,
@@ -172,7 +143,6 @@ const SidebarSubMenu = memo(function SidebarSubMenu({
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">
@@ -207,7 +177,7 @@ const SidebarSubMenu = memo(function SidebarSubMenu({
key={item.key}
item={{
key: item.key,
icon: getPluginIcon(item.icon),
icon: getIcon(item.icon),
label: item.label,
}}
isActive={currentPath === item.key}
@@ -220,6 +190,66 @@ const SidebarSubMenu = memo(function SidebarSubMenu({
);
});
// 动态菜单渲染
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') {
return (
<div key={menu.id}>
{!collapsed && <div className="erp-sidebar-group">{menu.title}</div>}
<div className="erp-sidebar-menu">
{menu.children
?.filter((child) => child.visible !== false)
.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)}
/>
))}
</div>
</div>
);
}
if (menu.menu_type === 'menu' && 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();
@@ -231,6 +261,24 @@ export default function MainLayout({ children }: { children: React.ReactNode })
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');
@@ -241,6 +289,14 @@ export default function MainLayout({ children }: { children: React.ReactNode })
navigate('/login');
}, [logout, navigate]);
// 标题查找:先从动态菜单查找,再 fallback
const headerTitle = useMemo(() => {
return getTitleFromMenus(currentPath, dynamicMenus)
|| routeTitleFallback[currentPath]
|| pluginMenuItems.find((p) => p.key === currentPath)?.label
|| '页面';
}, [currentPath, dynamicMenus, pluginMenuItems]);
const userMenuItems = [
{
key: 'profile',
@@ -280,49 +336,21 @@ export default function MainLayout({ children }: { children: React.ReactNode })
)}
</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>
{/* 动态菜单 */}
{menuLoading ? (
<div style={{ padding: 16, textAlign: 'center' }}>
<Spin size="small" />
</div>
) : (
<DynamicMenuSection
menus={dynamicMenus}
collapsed={sidebarCollapsed}
currentPath={currentPath}
onNavigate={(key) => navigate(key)}
/>
)}
{/* 菜单组:业务模块 */}
{!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>}
@@ -339,20 +367,6 @@ export default function MainLayout({ children }: { children: React.ReactNode })
</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>
{/* 右侧主区域 */}
@@ -368,9 +382,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
{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 ||
'页面'}
{headerTitle}
</span>
</Space>