feat(server): 侧边栏菜单按业务流程重组 — 3 目录 → 7 目录

将"健康业务"(30+ 项扁平列表)拆分为 5 个业务域顶级目录:
- 患者管理(患者/标签/医护)
- 诊疗服务(随访/咨询/诊断/同意/监测)
- 健康监测(实时监控/告警/设备/网关/危急值)
- 运营管理(文章/积分/媒体/轮播图)
- AI 助手(对话/Prompt/分析/知识库/用量/配置)

系统管理吸收 OAuth 合作方,工作台保持不变。
重建 menu_roles 按 doctor/nurse/health_manager/operator 精确绑定。
新增迁移 163,菜单系统 100% 数据库驱动,前端无需改动。
This commit is contained in:
iven
2026-05-21 07:20:21 +08:00
parent c5caed73b3
commit a3c84fc12a
2 changed files with 420 additions and 0 deletions

View File

@@ -164,6 +164,7 @@ mod m20260521_000159_patient_phone_and_consent_seed;
mod m20260521_000160_follow_up_task_template_id_and_record_form_data;
mod m20260521_000161_consultation_media_id_and_suggestion_references;
mod m20260521_000162_consultation_session_rating_feedback;
mod m20260521_000163_reorganize_menus_by_business_flow;
pub struct Migrator;
@@ -335,6 +336,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260521_000160_follow_up_task_template_id_and_record_form_data::Migration),
Box::new(m20260521_000161_consultation_media_id_and_suggestion_references::Migration),
Box::new(m20260521_000162_consultation_session_rating_feedback::Migration),
Box::new(m20260521_000163_reorganize_menus_by_business_flow::Migration),
]
}
}

View File

@@ -0,0 +1,418 @@
//! 侧边栏菜单按业务流程重组
//!
//! 将"健康业务"及其子目录30+ 项)重组为 5 个顶级业务域目录:
//! 患者管理 / 诊疗服务 / 健康监测 / 运营管理 / AI 助手
//!
//! 注意menus 表有 trg_enforce_version 触发器,所有 UPDATE 必须带 version = version + 1
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
const DIR_PATIENT_MGMT: &str = "a0000000-0000-0000-0000-000000000005";
const DIR_CLINICAL: &str = "a0000000-0000-0000-0000-000000000006";
const DIR_MONITORING: &str = "a0000000-0000-0000-0000-000000000007";
const DIR_OPERATIONS: &str = "a0000000-0000-0000-0000-000000000008";
const DIR_AI: &str = "a0000000-0000-0000-0000-000000000009";
const SYS: &str = "00000000-0000-0000-0000-000000000000";
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// ================================================================
// Part 1: 创建 5 个新顶级 directory多租户安全 + 幂等)
// ================================================================
let directories: &[(&str, &str, i32)] = &[
(DIR_PATIENT_MGMT, "患者管理", 2),
(DIR_CLINICAL, "诊疗服务", 3),
(DIR_MONITORING, "健康监测", 4),
(DIR_OPERATIONS, "运营管理", 5),
(DIR_AI, "AI 助手", 6),
];
for &(id, title, sort) in directories {
db.execute_unprepared(&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) \
SELECT '{id}', t.id, NULL, '{title}', NULL, NULL, {sort}, true, 'directory', NULL, NOW(), NOW(), '{SYS}', '{SYS}', NULL, 1 \
FROM tenant t \
ON CONFLICT (id) DO NOTHING"
)).await?;
}
// 更新工作台 sort=1, 系统管理 sort=7
db.execute_unprepared(
"UPDATE menus SET sort_order = 1, version = version + 1 WHERE title = '工作台' AND menu_type = 'directory' AND deleted_at IS NULL",
).await?;
db.execute_unprepared(
"UPDATE menus SET sort_order = 7, version = version + 1 WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL",
).await?;
// ================================================================
// Part 2: 移动叶子菜单到新目录(按 path 匹配,无视当前 parent_id
// 所有 UPDATE 必须 version = version + 1乐观锁触发器要求
// ================================================================
// 患者管理
for &(path, sort) in &[
("/health/patients", 0),
("/health/tags", 1),
("/health/doctors", 2),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_PATIENT_MGMT}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 诊疗服务
for &(path, sort) in &[
("/health/schedules", 0),
("/health/appointments", 1),
("/health/follow-up-tasks", 2),
("/health/follow-up-templates", 3),
("/health/consultations", 4),
("/health/action-inbox", 5),
("/health/diagnoses", 6),
("/health/consents", 7),
("/health/daily-monitoring", 8),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_CLINICAL}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 健康监测
for &(path, sort) in &[
("/health/realtime-monitor", 0),
("/health/alert-dashboard", 1),
("/health/alerts", 2),
("/health/alert-rules", 3),
("/health/devices", 4),
("/health/ble-gateways", 5),
("/health/critical-value-thresholds", 6),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_MONITORING}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// 运营管理
for &(path, sort) in &[
("/health/articles", 0),
("/health/article-categories", 1),
("/health/article-tags", 2),
("/health/points-rules", 3),
("/health/points-products", 4),
("/health/points-orders", 5),
("/health/offline-events", 6),
("/health/media-library", 7),
("/health/banners", 8),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_OPERATIONS}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// AI 助手
for &(path, sort) in &[
("/ai/chat", 0),
("/health/ai-prompts", 1),
("/health/ai-analysis", 2),
("/health/ai-knowledge", 3),
("/health/ai-usage", 4),
("/health/ai-config", 5),
] {
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = '{DIR_AI}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL"
)).await?;
}
// ================================================================
// Part 3: 移动 OAuth 到系统管理
// ================================================================
db.execute_unprepared(
"UPDATE menus SET parent_id = (SELECT id FROM menus WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1), sort_order = 7, version = version + 1 WHERE path = '/health/oauth-clients' AND deleted_at IS NULL"
).await?;
// ================================================================
// Part 4: 清理旧目录
// ================================================================
// 软删除"健康业务"目录及其子目录
db.execute_unprepared(
"UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE title = '健康业务' AND menu_type = 'directory' AND deleted_at IS NULL"
).await?;
// 软删除"健康业务"下的旧子目录c0000003-xxx
db.execute_unprepared(
"UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE parent_id = (SELECT id FROM menus WHERE title = '健康业务' AND menu_type = 'directory' LIMIT 1) AND menu_type = 'directory' AND deleted_at IS NULL"
).await?;
// 通过 title 匹配也清理(防止 parent_id 引用失效后遗漏)
for old_dir in &["患者医护", "随访咨询", "积分运营", "内容运营", "AI 分析"]
{
db.execute_unprepared(&format!(
"UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE title = '{old_dir}' AND menu_type = 'directory' AND deleted_at IS NULL"
)).await?;
}
// 软删除"配置"目录(如果还存在且已空)
db.execute_unprepared(
"UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE title = '配置' AND menu_type = 'directory' AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM menus c WHERE c.parent_id = menus.id AND c.deleted_at IS NULL AND c.visible = true)"
).await?;
// ================================================================
// Part 5: 重建 menu_roles
// ================================================================
for code in &["doctor", "nurse", "health_manager", "operator", "admin"] {
db.execute_unprepared(&format!(
"UPDATE menu_roles SET deleted_at = NOW(), version = version + 1 WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}' AND deleted_at IS NULL) AND deleted_at IS NULL"
)).await?;
}
assign_menus_for_role(
db,
"doctor",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/doctors",
"/health/follow-up-tasks",
"/health/follow-up-templates",
"/health/consultations",
"/health/action-inbox",
"/health/diagnoses",
"/health/consents",
"/health/daily-monitoring",
"/health/alert-dashboard",
"/health/alerts",
"/health/ai-analysis",
"/health/ai-usage",
"/ai/chat",
"/messages",
],
)
.await?;
assign_menus_for_role(
db,
"nurse",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/follow-up-tasks",
"/health/consultations",
"/health/action-inbox",
"/health/diagnoses",
"/health/consents",
"/health/daily-monitoring",
"/health/alert-dashboard",
"/health/alerts",
"/messages",
],
)
.await?;
assign_menus_for_role(
db,
"health_manager",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/doctors",
"/health/tags",
"/health/follow-up-tasks",
"/health/follow-up-templates",
"/health/consultations",
"/health/action-inbox",
"/health/diagnoses",
"/health/consents",
"/health/daily-monitoring",
"/health/realtime-monitor",
"/health/alert-dashboard",
"/health/alerts",
"/health/alert-rules",
"/health/devices",
"/health/critical-value-thresholds",
"/health/ai-prompts",
"/health/ai-analysis",
"/health/ai-knowledge",
"/health/ai-usage",
"/health/ai-config",
"/ai/chat",
"/messages",
],
)
.await?;
assign_menus_for_role(
db,
"operator",
&[
"/",
"/health/statistics",
"/health/patients",
"/health/tags",
"/health/devices",
"/health/alert-dashboard",
"/health/alerts",
"/health/articles",
"/health/article-categories",
"/health/article-tags",
"/health/points-rules",
"/health/points-products",
"/health/points-orders",
"/health/offline-events",
"/health/media-library",
"/health/banners",
"/health/ai-usage",
"/messages",
],
)
.await?;
assign_admin_all_menus(db).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 恢复"健康业务"目录
db.execute_unprepared(
"UPDATE menus SET deleted_at = NULL, version = version + 1 WHERE title = '健康业务' AND menu_type = 'directory'"
).await?;
// 将所有健康相关叶子菜单移回"健康业务"
let health_dir = "(SELECT id FROM menus WHERE title = '健康业务' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1)";
let paths_to_move: &[&str] = &[
"/health/patients",
"/health/doctors",
"/health/tags",
"/health/diagnoses",
"/health/schedules",
"/health/appointments",
"/health/follow-up-tasks",
"/health/follow-up-templates",
"/health/consultations",
"/health/action-inbox",
"/health/daily-monitoring",
"/health/consents",
"/health/realtime-monitor",
"/health/alert-dashboard",
"/health/alerts",
"/health/alert-rules",
"/health/devices",
"/health/ble-gateways",
"/health/critical-value-thresholds",
"/health/articles",
"/health/article-categories",
"/health/article-tags",
"/health/points-rules",
"/health/points-products",
"/health/points-orders",
"/health/offline-events",
"/health/media-library",
"/health/banners",
"/health/ai-prompts",
"/health/ai-analysis",
"/health/ai-knowledge",
"/health/ai-usage",
"/health/ai-config",
"/ai/chat",
];
let paths_csv: String = paths_to_move
.iter()
.map(|p| format!("'{p}'"))
.collect::<Vec<_>>()
.join(",");
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = {health_dir}, version = version + 1 WHERE path IN ({paths_csv}) AND deleted_at IS NULL"
)).await?;
// OAuth 移回健康业务
db.execute_unprepared(&format!(
"UPDATE menus SET parent_id = {health_dir}, sort_order = 60, version = version + 1 WHERE path = '/health/oauth-clients' AND deleted_at IS NULL"
)).await?;
// 删除新建的 5 个目录
for id in &[
DIR_PATIENT_MGMT,
DIR_CLINICAL,
DIR_MONITORING,
DIR_OPERATIONS,
DIR_AI,
] {
db.execute_unprepared(&format!(
"UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE id = '{id}'"
))
.await?;
}
// 恢复目录排序
db.execute_unprepared(
"UPDATE menus SET sort_order = 1, version = version + 1 WHERE title = '工作台' AND menu_type = 'directory' AND deleted_at IS NULL",
).await?;
db.execute_unprepared(
"UPDATE menus SET sort_order = 2, version = version + 1 WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL",
).await?;
db.execute_unprepared(
"UPDATE menus SET sort_order = 3, version = version + 1 WHERE title = '健康业务' AND menu_type = 'directory' AND deleted_at IS NULL",
).await?;
Ok(())
}
}
async fn assign_menus_for_role(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
role_code: &str,
menu_paths: &[&str],
) -> Result<(), DbErr> {
let paths_csv: String = menu_paths
.iter()
.map(|p| format!("'{p}'"))
.collect::<Vec<_>>()
.join(",");
db.execute_unprepared(&format!(
"INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN menus m ON m.tenant_id = r.tenant_id AND m.path IN ({paths_csv}) AND m.deleted_at IS NULL AND m.visible = true \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (id) DO NOTHING"
)).await?;
db.execute_unprepared(&format!(
"INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN menus m ON m.tenant_id = r.tenant_id AND m.menu_type = 'directory' AND m.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (id) DO NOTHING"
)).await?;
Ok(())
}
async fn assign_admin_all_menus(
db: &sea_orm_migration::SchemaManagerConnection<'_>,
) -> Result<(), DbErr> {
db.execute_unprepared(
"INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN menus m ON m.tenant_id = r.tenant_id AND m.deleted_at IS NULL AND m.visible = true \
WHERE r.code = 'admin' AND r.deleted_at IS NULL \
ON CONFLICT (id) DO NOTHING"
).await?;
Ok(())
}