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

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