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>

View File

@@ -236,6 +236,39 @@ where
}))
}
#[utoipa::path(
get,
path = "/api/v1/menus/user",
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<MenuResp>>),
(status = 401, description = "未授权"),
),
security(("bearer_auth" = [])),
tag = "菜单管理"
)]
/// GET /api/v1/menus/user
///
/// 获取当前用户可见的菜单树(无需 menu.list 权限,仅需登录)。
pub async fn get_user_menus<S>(
State(state): State<ConfigState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<JsonResponse<ApiResponse<Vec<MenuResp>>>, AppError>
where
ConfigState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let role_ids: Vec<Uuid> = 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 {

View File

@@ -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}",

View File

@@ -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),
]
}
}

View File

@@ -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::<String>(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(())
}