diff --git a/apps/web/src/api/menus.ts b/apps/web/src/api/menus.ts index 772f4f9..55eac10 100644 --- a/apps/web/src/api/menus.ts +++ b/apps/web/src/api/menus.ts @@ -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 }); } diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 82491f9..731aca2 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -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: , label: '工作台' }, - { key: '/users', icon: , label: '用户管理' }, - { key: '/roles', icon: , label: '权限管理' }, - { key: '/organizations', icon: , label: '组织架构' }, -]; +// 完整图标映射表 +const iconMap: Record = { + HomeOutlined: , + UserOutlined: , + SafetyOutlined: , + ApartmentOutlined: , + SettingOutlined: , + PartitionOutlined: , + MessageOutlined: , + AppstoreOutlined: , + TeamOutlined: , + TableOutlined: , + TagsOutlined: , + HeartOutlined: , + CalendarOutlined: , + PhoneOutlined: , + CommentOutlined: , + MedicineBoxOutlined: , + TrophyOutlined: , + ShopOutlined: , + FileTextOutlined: , + DashboardOutlined: , + RobotOutlined: , + HistoryOutlined: , + BarChartOutlined: , +}; -const bizMenuItems: MenuItem[] = [ - { key: '/workflow', icon: , label: '工作流' }, - { key: '/messages', icon: , label: '消息中心' }, -]; +function getIcon(name?: string): React.ReactNode { + if (!name) return ; + return iconMap[name] || ; +} -const healthMenuItems: MenuItem[] = [ - { key: '/health/statistics', icon: , label: '统计报表' }, - { key: '/health/patients', icon: , label: '患者管理' }, - { key: '/health/doctors', icon: , label: '医护管理' }, - { key: '/health/appointments', icon: , label: '预约排班' }, - { key: '/health/schedules', icon: , label: '排班管理' }, - { key: '/health/follow-up-tasks', icon: , label: '随访管理' }, - { key: '/health/consultations', icon: , label: '咨询管理' }, - { key: '/health/tags', icon: , label: '标签管理' }, - { key: '/health/points-rules', icon: , label: '积分规则' }, - { key: '/health/points-products', icon: , label: '商品管理' }, - { key: '/health/points-orders', icon: , label: '订单管理' }, - { key: '/health/offline-events', icon: , label: '线下活动' }, - { key: '/health/ai-prompts', icon: , label: 'AI Prompt 管理' }, - { key: '/health/ai-analysis', icon: , label: 'AI 分析历史' }, - { key: '/health/ai-usage', icon: , label: 'AI 用量统计' }, -]; - -const sysMenuItems: MenuItem[] = [ - { key: '/settings', icon: , label: '系统设置' }, - { key: '/plugins/admin', icon: , label: '插件管理' }, -]; - -const routeTitleMap: Record = { - '/': '工作台', - '/users': '用户管理', - '/roles': '权限管理', - '/organizations': '组织架构', - '/workflow': '工作流', - '/messages': '消息中心', - '/settings': '系统设置', - '/plugins/admin': '插件管理', - '/health/statistics': '统计报表', - '/health/patients': '患者管理', +// 路由标题 fallback(给动态参数路由用) +const routeTitleFallback: Record = { '/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 = { - AppstoreOutlined: , - team: , - TeamOutlined: , - user: , - UserOutlined: , - message: , - MessageOutlined: , - tags: , - TagsOutlined: , - apartment: , - ApartmentOutlined: , - TableOutlined: , - DashboardOutlined: , -}; - -function getPluginIcon(iconName: string): React.ReactNode { - return pluginIconMap[iconName] || ; -} - -// 插件子菜单组 — 可折叠二级标题 + 三级菜单项 +// 插件子菜单组 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 ( @@ -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 ( +
+ {!collapsed &&
{menu.title}
} +
+ {menu.children + ?.filter((child) => child.visible !== false) + .map((child) => ( + onNavigate(child.path || child.id)} + /> + ))} +
+
+ ); + } + if (menu.menu_type === 'menu' && menu.visible !== false) { + return ( + 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([]); + 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 }) )} - {/* 菜单组:基础模块 */} - {!sidebarCollapsed &&
基础模块
} -
- {mainMenuItems.map((item) => ( - navigate(item.key)} - /> - ))} -
+ {/* 动态菜单 */} + {menuLoading ? ( +
+ +
+ ) : ( + navigate(key)} + /> + )} - {/* 菜单组:业务模块 */} - {!sidebarCollapsed &&
业务模块
} -
- {bizMenuItems.map((item) => ( - navigate(item.key)} - /> - ))} -
- - {/* 菜单组:健康管理 */} - {!sidebarCollapsed &&
健康管理
} -
- {healthMenuItems.map((item) => ( - navigate(item.key)} - /> - ))} -
- - {/* 菜单组:插件 */} + {/* 插件菜单 */} {pluginMenuGroups.length > 0 && ( <> {!sidebarCollapsed &&
插件
} @@ -339,20 +367,6 @@ export default function MainLayout({ children }: { children: React.ReactNode }) )} - - {/* 菜单组:系统 */} - {!sidebarCollapsed &&
系统
} -
- {sysMenuItems.map((item) => ( - navigate(item.key)} - /> - ))} -
{/* 右侧主区域 */} @@ -368,9 +382,7 @@ export default function MainLayout({ children }: { children: React.ReactNode }) {sidebarCollapsed ? : } - {routeTitleMap[currentPath] || - pluginMenuItems.find((p) => p.key === currentPath)?.label || - '页面'} + {headerTitle} diff --git a/crates/erp-config/src/handler/menu_handler.rs b/crates/erp-config/src/handler/menu_handler.rs index cc2dedf..f45db78 100644 --- a/crates/erp-config/src/handler/menu_handler.rs +++ b/crates/erp-config/src/handler/menu_handler.rs @@ -236,6 +236,39 @@ where })) } +#[utoipa::path( + get, + path = "/api/v1/menus/user", + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "菜单管理" +)] +/// GET /api/v1/menus/user +/// +/// 获取当前用户可见的菜单树(无需 menu.list 权限,仅需登录)。 +pub async fn get_user_menus( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let role_ids: Vec = ctx + .roles + .iter() + .filter_map(|r| Uuid::parse_str(r).ok()) + .collect(); + + // 如果用户有角色关联菜单,按角色过滤;否则返回全部(admin 兜底) + let menus = MenuService::get_menu_tree(ctx.tenant_id, &role_ids, &state.db).await?; + + Ok(JsonResponse(ApiResponse::ok(menus))) +} + /// 删除菜单的乐观锁版本号请求体。 #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct DeleteMenuVersionReq { diff --git a/crates/erp-config/src/module.rs b/crates/erp-config/src/module.rs index d58d953..1131bde 100644 --- a/crates/erp-config/src/module.rs +++ b/crates/erp-config/src/module.rs @@ -63,6 +63,11 @@ impl ConfigModule { "/config/menus/{id}", put(menu_handler::update_menu).delete(menu_handler::delete_menu), ) + // User menu tree (no special permission required) + .route( + "/menus/user", + get(menu_handler::get_user_menus), + ) // Setting routes .route( "/config/settings/{key}", diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index c79f73e..ab9d940 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -58,6 +58,7 @@ mod m20260425_000055_points_checkin_standard_fields; mod m20260426_000056_create_diagnosis; mod m20260426_000057_rename_points_transaction_type_column; mod m20260426_000058_merge_daily_monitoring_into_vital_signs; +mod m20260426_000059_seed_menus; pub struct Migrator; @@ -123,6 +124,7 @@ impl MigratorTrait for Migrator { Box::new(m20260426_000056_create_diagnosis::Migration), Box::new(m20260426_000057_rename_points_transaction_type_column::Migration), Box::new(m20260426_000058_merge_daily_monitoring_into_vital_signs::Migration), + Box::new(m20260426_000059_seed_menus::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260426_000059_seed_menus.rs b/crates/erp-server/migration/src/m20260426_000059_seed_menus.rs new file mode 100644 index 0000000..af8326f --- /dev/null +++ b/crates/erp-server/migration/src/m20260426_000059_seed_menus.rs @@ -0,0 +1,128 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 软删除已有菜单数据 + db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "UPDATE menus SET deleted_at = NOW() WHERE deleted_at IS NULL".to_string(), + )) + .await?; + + // 获取默认租户 ID + let result = db.query_one(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "SELECT id::text FROM tenant LIMIT 1".to_string(), + )) + .await?; + + let tid = match result { + Some(row) => row.try_get_by_index::(0).unwrap_or_default(), + None => return Ok(()), + }; + + let sys = "00000000-0000-0000-0000-000000000000"; + let nil = "NULL"; + + // === Directory 节点 === + insert_dir(db, &tid, "a0000000-0000-0000-0000-000000000001", "基础模块", 1, sys).await?; + insert_dir(db, &tid, "a0000000-0000-0000-0000-000000000002", "业务模块", 2, sys).await?; + insert_dir(db, &tid, "a0000000-0000-0000-0000-000000000003", "健康管理", 3, sys).await?; + insert_dir(db, &tid, "a0000000-0000-0000-0000-000000000004", "系统", 4, sys).await?; + + // === 基础模块菜单 === + let d1 = "a0000000-0000-0000-0000-000000000001"; + insert_menu(db, &tid, d1, "b0000001-0000-0000-0000-000000000001", "工作台", "/", "HomeOutlined", 0, sys).await?; + insert_menu(db, &tid, d1, "b0000001-0000-0000-0000-000000000002", "用户管理", "/users", "UserOutlined", 1, sys).await?; + insert_menu(db, &tid, d1, "b0000001-0000-0000-0000-000000000003", "权限管理", "/roles", "SafetyOutlined", 2, sys).await?; + insert_menu(db, &tid, d1, "b0000001-0000-0000-0000-000000000004", "组织架构", "/organizations", "ApartmentOutlined", 3, sys).await?; + + // === 业务模块菜单 === + let d2 = "a0000000-0000-0000-0000-000000000002"; + insert_menu(db, &tid, d2, "b0000002-0000-0000-0000-000000000001", "工作流", "/workflow", "PartitionOutlined", 0, sys).await?; + insert_menu(db, &tid, d2, "b0000002-0000-0000-0000-000000000002", "消息中心", "/messages", "MessageOutlined", 1, sys).await?; + + // === 健康管理菜单 === + let d3 = "a0000000-0000-0000-0000-000000000003"; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000001", "统计报表", "/health/statistics", "DashboardOutlined", 0, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000002", "患者管理", "/health/patients", "TeamOutlined", 1, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000003", "医护管理", "/health/doctors", "MedicineBoxOutlined", 2, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000004", "预约排班", "/health/appointments", "CalendarOutlined", 3, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000005", "排班管理", "/health/schedules", "HeartOutlined", 4, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000006", "随访管理", "/health/follow-up-tasks", "PhoneOutlined", 5, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000007", "咨询管理", "/health/consultations", "CommentOutlined", 6, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000008", "标签管理", "/health/tags", "TagsOutlined", 7, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000009", "积分规则", "/health/points-rules", "TrophyOutlined", 8, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000010", "商品管理", "/health/points-products", "ShopOutlined", 9, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000011", "订单管理", "/health/points-orders", "FileTextOutlined", 10, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000012", "线下活动", "/health/offline-events", "CalendarOutlined", 11, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000013", "AI Prompt 管理", "/health/ai-prompts", "RobotOutlined", 12, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000014", "AI 分析历史", "/health/ai-analysis", "HistoryOutlined", 13, sys).await?; + insert_menu(db, &tid, d3, "b0000003-0000-0000-0000-000000000015", "AI 用量统计", "/health/ai-usage", "BarChartOutlined", 14, sys).await?; + + // === 系统菜单 === + let d4 = "a0000000-0000-0000-0000-000000000004"; + insert_menu(db, &tid, d4, "b0000004-0000-0000-0000-000000000001", "系统设置", "/settings", "SettingOutlined", 0, sys).await?; + insert_menu(db, &tid, d4, "b0000004-0000-0000-0000-000000000002", "插件管理", "/plugins/admin", "AppstoreOutlined", 1, sys).await?; + + let _ = nil; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "DELETE FROM menus WHERE id LIKE 'a0000000-0000-0000-0000-00000000000%' OR id LIKE 'b000000%-0000-0000-0000-00000000000%'".to_string(), + )) + .await?; + Ok(()) + } +} + +async fn insert_dir( + db: &sea_orm_migration::SchemaManagerConnection<'_>, + tenant_id: &str, + id: &str, + title: &str, + sort: i32, + sys: &str, +) -> Result<(), DbErr> { + db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + format!( + "INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, visible, menu_type, permission, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + VALUES ('{id}', '{tenant_id}', NULL, '{title}', NULL, NULL, {sort}, true, 'directory', NULL, NOW(), NOW(), '{sys}', '{sys}', NULL, 1) ON CONFLICT (id) DO NOTHING" + ), + )) + .await?; + Ok(()) +} + +async fn insert_menu( + db: &sea_orm_migration::SchemaManagerConnection<'_>, + tenant_id: &str, + parent_id: &str, + id: &str, + title: &str, + path: &str, + icon: &str, + sort: i32, + sys: &str, +) -> Result<(), DbErr> { + db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + format!( + "INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, visible, menu_type, permission, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + VALUES ('{id}', '{tenant_id}', '{parent_id}', '{title}', '{path}', '{icon}', {sort}, true, 'menu', NULL, NOW(), NOW(), '{sys}', '{sys}', NULL, 1) ON CONFLICT (id) DO NOTHING" + ), + )) + .await?; + Ok(()) +}