feat(config): 菜单动态化改造 — 侧边栏从后端 API 加载
- 新增 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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user