From 8763e10d6e529f5c5430be95b0854490ffdd3fdc Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 15 May 2026 19:00:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=A8=E5=B1=80=E6=9D=83=E9=99=90?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20=E2=80=94=207=20=E9=A1=B9=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 菜单权限修复:补充 10 个菜单的 permission 字段 + 修复 menu_service 回退逻辑(admin 直接跳过过滤,非 admin 无关联则不显示)+ 收紧前端过滤 2. 管理员重置密码:新增 POST /users/{id}/reset-password 端点 + 前端按钮 3. 告警处理人姓名:AlertResponse 添加 acknowledged_by_name 字段 4. Tab 权限过滤:PatientDetail 6 个 Tab 按权限过滤 + 状态字段 Tooltip 5. 消息中心 UI:添加 Popconfirm/AuthButton,移除 inline isDark --- apps/web/src/api/health/alerts.ts | 1 + apps/web/src/api/users.ts | 4 + apps/web/src/layouts/MainLayout.tsx | 9 +- apps/web/src/pages/Users.tsx | 84 +++++++++++++++++++ apps/web/src/pages/health/PatientDetail.tsx | 40 ++++++++- .../health/components/AlertDetailPanel.tsx | 2 +- .../src/pages/messages/MessageTemplates.tsx | 30 +++---- .../src/pages/messages/NotificationList.tsx | 58 ++++++------- crates/erp-auth/src/dto.rs | 9 ++ crates/erp-auth/src/handler/user_handler.rs | 51 ++++++++++- crates/erp-auth/src/module.rs | 10 +++ crates/erp-auth/src/service/user_service.rs | 61 ++++++++++++++ crates/erp-config/src/service/menu_service.rs | 35 +++++--- crates/erp-health/src/dto/alert_dto.rs | 1 + .../erp-health/src/service/alert_service.rs | 55 +++++++++++- crates/erp-server/migration/src/lib.rs | 2 + ...515_000146_seed_menu_permissions_phase2.rs | 68 +++++++++++++++ 17 files changed, 451 insertions(+), 69 deletions(-) create mode 100644 crates/erp-server/migration/src/m20260515_000146_seed_menu_permissions_phase2.rs diff --git a/apps/web/src/api/health/alerts.ts b/apps/web/src/api/health/alerts.ts index 005db29..f1f6f1c 100644 --- a/apps/web/src/api/health/alerts.ts +++ b/apps/web/src/api/health/alerts.ts @@ -12,6 +12,7 @@ export interface Alert { detail?: Record; status: string; acknowledged_by?: string; + acknowledged_by_name?: string; acknowledged_at?: string; resolved_at?: string; created_at: string; diff --git a/apps/web/src/api/users.ts b/apps/web/src/api/users.ts index a2b243a..64b93c6 100644 --- a/apps/web/src/api/users.ts +++ b/apps/web/src/api/users.ts @@ -48,3 +48,7 @@ export async function deleteUser(id: string) { export async function assignRoles(userId: string, roleIds: string[]) { await client.post(`/users/${userId}/roles`, { role_ids: roleIds }); } + +export async function resetPassword(id: string, req: { new_password: string; version: number }) { + await client.post(`/users/${id}/reset-password`, req); +} diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 21b6811..ba0f478 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -179,7 +179,7 @@ const CollapsibleSubGroup = memo(function CollapsibleSubGroup({ const hasActive = visibleChildren.some((c) => currentPath === (c.path || c.id)); useEffect(() => { - if (hasActive) setExpanded(true); + if (hasActive) setExpanded(true); // eslint-disable-line react-hooks/set-state-in-effect -- 初始展开包含活跃菜单的分组 }, [hasActive]); if (collapsed) { @@ -397,7 +397,7 @@ export default function MainLayout({ children }: { children: React.ReactNode }) if (!cancelled) { // 根据用户权限过滤菜单:菜单项声明 permission 时,用户必须有对应权限 const perms = useAuthStore.getState().permissions; - const isAdmin = useAuthStore.getState().user?.roles?.some((r: string) => r === 'admin') ?? false; + const isAdmin = useAuthStore.getState().user?.roles?.some((r) => typeof r === 'object' && r.code === 'admin') ?? false; if (isAdmin) { setDynamicMenus(menus); } else { @@ -408,10 +408,11 @@ export default function MainLayout({ children }: { children: React.ReactNode }) children: m.children ? filterByPerm(m.children) : undefined, })) .filter((m) => { - if (!m.permission) return true; + if (m.menu_type === 'directory') return true; + if (!m.permission) return false; return perms.includes(m.permission); }) - .filter((m) => m.menu_type === 'directory' || !m.children || m.children.length > 0 || !m.permission || perms.includes(m.permission)); + .filter((m) => m.menu_type === 'directory' || (m.children && m.children.length > 0) || (m.permission && perms.includes(m.permission))); setDynamicMenus(filterByPerm(menus)); } } diff --git a/apps/web/src/pages/Users.tsx b/apps/web/src/pages/Users.tsx index 17f0221..f728f78 100644 --- a/apps/web/src/pages/Users.tsx +++ b/apps/web/src/pages/Users.tsx @@ -8,6 +8,8 @@ import { Tag, Popconfirm, Checkbox, + Modal, + message, } from 'antd'; import { PlusOutlined, @@ -17,6 +19,7 @@ import { SafetyCertificateOutlined, StopOutlined, CheckCircleOutlined, + KeyOutlined, } from '@ant-design/icons'; import { listUsers, @@ -24,6 +27,7 @@ import { updateUser, deleteUser, assignRoles, + resetPassword, type CreateUserRequest, type UpdateUserRequest, } from '../api/users'; @@ -31,6 +35,7 @@ import { listRoles, type RoleInfo } from '../api/roles'; import type { UserInfo } from '../api/auth'; import { PageContainer } from '../components/PageContainer'; import { DrawerForm } from '../components/DrawerForm'; +import { AuthButton } from '../components/AuthButton'; import { useCrudDrawer } from '../hooks/useCrudDrawer'; import { usePaginatedData } from '../hooks/usePaginatedData'; import { useApiRequest } from '../hooks/useApiRequest'; @@ -101,6 +106,39 @@ export default function Users() { setRoleDrawerOpen(true); }; + // 重置密码 + const [resetModalOpen, setResetModalOpen] = useState(false); + const [resetTarget, setResetTarget] = useState(null); + const [resetLoading, setResetLoading] = useState(false); + const [resetForm] = Form.useForm(); + + const openResetModal = (user: UserInfo) => { + setResetTarget(user); + resetForm.resetFields(); + setResetModalOpen(true); + }; + + const handleResetPassword = async () => { + try { + const values = await resetForm.validateFields(); + if (!resetTarget) return; + setResetLoading(true); + await resetPassword(resetTarget.id, { + new_password: values.new_password, + version: resetTarget.version, + }); + message.success('密码已重置'); + setResetModalOpen(false); + resetForm.resetFields(); + refresh(); + } catch (error) { + if (error && typeof error === 'object' && 'errorFields' in error) return; // 表单校验失败,忽略 + throw error; + } finally { + setResetLoading(false); + } + }; + const columns = [ { title: '用户', dataIndex: 'username', key: 'username', @@ -145,6 +183,9 @@ export default function Users() { + + + -
+ `共 ${t} 条记录`, }} /> - + { setLoading(true); @@ -93,7 +92,7 @@ export default function NotificationList({ queryFilter }: Props) { content: (
{record.body} -
+
{formatDateTime(record.created_at)}
@@ -114,7 +113,7 @@ export default function NotificationList({ queryFilter }: Props) { style={{ fontWeight: record.is_read ? 400 : 600, cursor: 'pointer', - color: record.is_read ? (isDark ? '#94a3b8' : '#475569') : 'inherit', + color: record.is_read ? 'var(--ant-color-text-secondary)' : 'inherit', }} onClick={() => showDetail(record)} > @@ -156,7 +155,7 @@ export default function NotificationList({ queryFilter }: Props) { dataIndex: 'sender_type', key: 'sender_type', width: 80, - render: (s: string) => {s === 'system' ? '系统' : '用户'}, + render: (s: string) => {s === 'system' ? '系统' : '用户'}, }, { title: '状态', @@ -165,9 +164,9 @@ export default function NotificationList({ queryFilter }: Props) { width: 80, render: (r: boolean) => ( {r ? '已读' : '未读'} @@ -180,7 +179,7 @@ export default function NotificationList({ queryFilter }: Props) { key: 'created_at', width: 180, render: (v: string) => ( - {formatDateTime(v)} + {formatDateTime(v)} ), }, { @@ -203,15 +202,21 @@ export default function NotificationList({ queryFilter }: Props) { size="small" icon={} onClick={() => showDetail(record)} - style={{ color: isDark ? '#475569' : '#94a3b8' }} - /> - + + +
-
+
`共 ${t} 条记录`, }} /> - + ); } diff --git a/crates/erp-auth/src/dto.rs b/crates/erp-auth/src/dto.rs index 40cc692..06c0314 100644 --- a/crates/erp-auth/src/dto.rs +++ b/crates/erp-auth/src/dto.rs @@ -62,6 +62,15 @@ pub struct ChangePasswordReq { pub new_password: String, } +/// 管理员重置用户密码请求 +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct ResetPasswordReq { + #[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))] + pub new_password: String, + #[validate(range(min = 0))] + pub version: i32, +} + // --- User DTOs --- #[derive(Debug, Serialize, ToSchema)] diff --git a/crates/erp-auth/src/handler/user_handler.rs b/crates/erp-auth/src/handler/user_handler.rs index 1111328..42b47ec 100644 --- a/crates/erp-auth/src/handler/user_handler.rs +++ b/crates/erp-auth/src/handler/user_handler.rs @@ -10,7 +10,7 @@ use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext} use uuid::Uuid; use crate::auth_state::AuthState; -use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp}; +use crate::dto::{CreateUserReq, ResetPasswordReq, RoleResp, UpdateUserReq, UserResp}; use crate::service::user_service::UserService; use erp_core::rbac::require_permission; @@ -271,3 +271,52 @@ where Ok(Json(ApiResponse::ok(AssignRolesResp { roles }))) } + +#[utoipa::path( + post, + path = "/api/v1/users/{id}/reset-password", + params(("id" = Uuid, Path, description = "用户ID")), + request_body = ResetPasswordReq, + responses( + (status = 200, description = "密码重置成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "用户不存在"), + ), + security(("bearer_auth" = [])), + tag = "用户管理" +)] +/// POST /api/v1/users/{id}/reset-password +/// +/// 管理员重置指定用户密码。需要 `user.reset-password` 权限。 +pub async fn reset_password( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.reset-password")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + UserService::reset_password( + id, + ctx.tenant_id, + ctx.user_id, + &req.new_password, + req.version, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("密码重置成功".to_string()), + })) +} diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs index 5ce35a7..21213d3 100644 --- a/crates/erp-auth/src/module.rs +++ b/crates/erp-auth/src/module.rs @@ -69,6 +69,10 @@ impl AuthModule { "/users/{id}/roles", axum::routing::post(user_handler::assign_roles), ) + .route( + "/users/{id}/reset-password", + axum::routing::post(user_handler::reset_password), + ) .route( "/roles", axum::routing::get(role_handler::list_roles).post(role_handler::create_role), @@ -237,6 +241,12 @@ impl ErpModule for AuthModule { description: "软删除用户".into(), module: "auth".into(), }, + PermissionDescriptor { + code: "user.reset-password".into(), + name: "重置用户密码".into(), + description: "管理员重置指定用户密码".into(), + module: "auth".into(), + }, PermissionDescriptor { code: "role.list".into(), name: "查看角色列表".into(), diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs index fba61f1..ba62978 100644 --- a/crates/erp-auth/src/service/user_service.rs +++ b/crates/erp-auth/src/service/user_service.rs @@ -434,6 +434,67 @@ impl UserService { result } + /// 管理员重置指定用户密码。 + pub async fn reset_password( + user_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + new_password: &str, + version: i32, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult<()> { + // 1. 验证用户存在且属于当前租户 + let user_model = user::Entity::find() + .filter(user::Column::Id.eq(user_id)) + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?; + + let _next_version = check_version(version, user_model.version) + .map_err(|_| AuthError::Validation("版本冲突,请刷新后重试".to_string()))?; + + // 2. 查找密码凭证 + let cred = user_credential::Entity::find() + .filter(user_credential::Column::UserId.eq(user_id)) + .filter(user_credential::Column::TenantId.eq(tenant_id)) + .filter(user_credential::Column::CredentialType.eq("password")) + .filter(user_credential::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?; + + // 3. 哈希新密码并更新凭证 + let new_hash = password::hash_password(new_password)?; + let cred_version = cred.version; + let mut cred_active: user_credential::ActiveModel = cred.into(); + cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash }))); + cred_active.updated_at = Set(Utc::now()); + cred_active.updated_by = Set(operator_id); + cred_active.version = Set(cred_version + 1); + cred_active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // 4. 吊销所有 refresh token + super::token_service::TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?; + + // 5. 审计日志 + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "user.reset_password", "user") + .with_resource_id(user_id), + db, + ) + .await; + + tracing::info!(user_id = %user_id, operator_id = %operator_id, "Password reset by admin"); + Ok(()) + } + /// Fetch role details for a single user, returning RoleResp DTOs. async fn fetch_user_role_resps( user_id: Uuid, diff --git a/crates/erp-config/src/service/menu_service.rs b/crates/erp-config/src/service/menu_service.rs index 70693eb..7717852 100644 --- a/crates/erp-config/src/service/menu_service.rs +++ b/crates/erp-config/src/service/menu_service.rs @@ -49,20 +49,33 @@ impl MenuService { .collect()) } - /// 获取当前租户下指定角色可见的菜单树。 - /// - /// `role_codes` 为当前用户的角色 code 列表(如 ["admin"]、["doctor"])。 - /// 方法内部将 code 转换为 ID,再通过 menu_roles 表过滤。 - /// 如果角色没有任何菜单关联,返回全部菜单(admin 兜底)。 pub async fn get_menu_tree( tenant_id: Uuid, role_codes: &[String], db: &sea_orm::DatabaseConnection, ) -> ConfigResult> { - // 0. 将角色 code 转换为 UUID + // 0. admin 角色直接返回全部菜单,跳过 menu_roles 过滤 + if role_codes.iter().any(|c| c == "admin") { + let all_menus = menu::Entity::find() + .filter(menu::Column::TenantId.eq(tenant_id)) + .filter(menu::Column::DeletedAt.is_null()) + .order_by_asc(menu::Column::SortOrder) + .all(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + for m in &all_menus { + children_map.entry(m.parent_id).or_default().push(m); + } + let roots = children_map.get(&None).cloned().unwrap_or_default(); + return Ok(Self::build_tree(&roots, &children_map)); + } + + // 1. 将角色 code 转换为 UUID let role_ids = Self::resolve_role_ids(tenant_id, role_codes, db).await?; - // 1. 查询租户下所有未删除的菜单,按 sort_order 排序 + // 2. 查询租户下所有未删除的菜单,按 sort_order 排序 let all_menus = menu::Entity::find() .filter(menu::Column::TenantId.eq(tenant_id)) .filter(menu::Column::DeletedAt.is_null()) @@ -71,7 +84,7 @@ impl MenuService { .await .map_err(|e| ConfigError::Validation(e.to_string()))?; - // 2. 如果 role_ids 非空,通过 menu_roles 表过滤 + // 3. 通过 menu_roles 表过滤 let visible_menu_ids: Option> = if !role_ids.is_empty() { let mr_rows = menu_role::Entity::find() .filter(menu_role::Column::TenantId.eq(tenant_id)) @@ -83,14 +96,12 @@ impl MenuService { let ids: Vec = mr_rows.iter().map(|mr| mr.menu_id).collect(); if ids.is_empty() { - // 角色未关联菜单时回退到显示全部菜单, - // 避免种子数据阶段 menu_roles 为空导致所有有角色用户看不到菜单 - None + Some(vec![]) // 无菜单关联 = 不显示 } else { Some(ids) } } else { - None + Some(vec![]) // 无角色 = 不显示任何菜单 }; // 3. 按 parent_id 分组构建 HashMap diff --git a/crates/erp-health/src/dto/alert_dto.rs b/crates/erp-health/src/dto/alert_dto.rs index 56c6cff..7a482ef 100644 --- a/crates/erp-health/src/dto/alert_dto.rs +++ b/crates/erp-health/src/dto/alert_dto.rs @@ -81,6 +81,7 @@ pub struct AlertResponse { pub detail: Option, pub status: String, pub acknowledged_by: Option, + pub acknowledged_by_name: Option, pub acknowledged_at: Option>, pub resolved_at: Option>, pub created_at: DateTime, diff --git a/crates/erp-health/src/service/alert_service.rs b/crates/erp-health/src/service/alert_service.rs index d667c0e..f118a7a 100644 --- a/crates/erp-health/src/service/alert_service.rs +++ b/crates/erp-health/src/service/alert_service.rs @@ -1,7 +1,7 @@ use chrono::Utc; use sea_orm::ActiveValue::Set; use sea_orm::entity::prelude::*; -use sea_orm::{QueryOrder, QuerySelect}; +use sea_orm::{DatabaseBackend, QueryOrder, QuerySelect, Statement}; use uuid::Uuid; use erp_core::error::check_version; @@ -85,6 +85,38 @@ pub async fn list_alerts( std::collections::HashMap::new() }; + // 批量查询 acknowledged_by 用户名 + let ack_ids: std::collections::HashSet = + models.iter().filter_map(|m| m.acknowledged_by).collect(); + let ack_names: std::collections::HashMap = if !ack_ids.is_empty() { + let params: Vec = ack_ids.iter().map(|id| (*id).into()).collect(); + let placeholders: Vec = (1..=params.len()).map(|i| format!("${}", i)).collect(); + let mut values = params; + values.push(tenant_id.into()); + let sql = format!( + "SELECT id, COALESCE(display_name, username) AS name FROM users WHERE id IN ({}) AND tenant_id = ${}", + placeholders.join(","), + values.len() + ); + let rows = state + .db + .query_all(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + sql, + values, + )) + .await?; + rows.into_iter() + .filter_map(|row| { + let id: Uuid = row.try_get_by_index(0).ok()?; + let name: String = row.try_get_by_index(1).ok()?; + Some((id, name)) + }) + .collect() + } else { + std::collections::HashMap::new() + }; + let items = models .into_iter() .map(|m| AlertResponse { @@ -97,6 +129,9 @@ pub async fn list_alerts( detail: m.detail, status: m.status, acknowledged_by: m.acknowledged_by, + acknowledged_by_name: m + .acknowledged_by + .and_then(|uid| ack_names.get(&uid).cloned()), acknowledged_at: m.acknowledged_at, resolved_at: m.resolved_at, created_at: m.created_at, @@ -107,7 +142,7 @@ pub async fn list_alerts( Ok((items, total)) } -/// 将 alerts::Model 转换为 AlertResponse,并查找关联的患者名称。 +/// 将 alerts::Model 转换为 AlertResponse,并查找关联的患者名称和处理人名称。 async fn enrich_alert_response( db: &DatabaseConnection, tenant_id: Uuid, @@ -118,6 +153,21 @@ async fn enrich_alert_response( .one(db) .await? .map(|p| p.name); + + // 查询处理人用户名 + let acknowledged_by_name = if let Some(ack_uid) = model.acknowledged_by { + let sql = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COALESCE(display_name, username) AS name FROM users WHERE id = $1 AND tenant_id = $2", + [ack_uid.into(), tenant_id.into()], + ); + db.query_one(sql) + .await? + .and_then(|row| row.try_get_by_index::(0).ok()) + } else { + None + }; + Ok(AlertResponse { id: model.id, patient_id: model.patient_id, @@ -128,6 +178,7 @@ async fn enrich_alert_response( detail: model.detail, status: model.status, acknowledged_by: model.acknowledged_by, + acknowledged_by_name, acknowledged_at: model.acknowledged_at, resolved_at: model.resolved_at, created_at: model.created_at, diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 67f85ff..2da9c77 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -147,6 +147,7 @@ mod m20260512_000142_seed_copilot_rules; mod m20260512_000143_seed_copilot_alert_rules; mod m20260513_000144_enforce_version_optimistic_lock; mod m20260513_000145_seed_missing_permissions; +mod m20260515_000146_seed_menu_permissions_phase2; pub struct Migrator; @@ -301,6 +302,7 @@ impl MigratorTrait for Migrator { Box::new(m20260512_000143_seed_copilot_alert_rules::Migration), Box::new(m20260513_000144_enforce_version_optimistic_lock::Migration), Box::new(m20260513_000145_seed_missing_permissions::Migration), + Box::new(m20260515_000146_seed_menu_permissions_phase2::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260515_000146_seed_menu_permissions_phase2.rs b/crates/erp-server/migration/src/m20260515_000146_seed_menu_permissions_phase2.rs new file mode 100644 index 0000000..20bf2ff --- /dev/null +++ b/crates/erp-server/migration/src/m20260515_000146_seed_menu_permissions_phase2.rs @@ -0,0 +1,68 @@ +//! 补充基础模块菜单的 permission 字段(Phase 2) +//! +//! 以下菜单在初始 seed 时 permission 为 NULL,导致前端菜单过滤逻辑 +//! `if (!m.permission) return true` 放行了这些菜单,使无权限用户也能看到。 + +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(); + + let menu_perms: &[(&str, &str)] = &[ + // 基础模块菜单 + ("/", "health.dashboard.list"), // 工作台 + ("/users", "user.list"), // 用户管理 + ("/roles", "permission.list"), // 权限管理 + ("/organizations", "organization.list"), // 组织架构 + ("/workflow", "workflow.list"), // 工作流 + ("/messages", "message.list"), // 消息中心 + ("/settings", "config.settings.list"), // 系统设置 + ("/plugins/admin", "plugin.list"), // 插件管理 + // 健康模块遗漏 + ("/health/statistics", "health.stats.list"), // 统计报表 + ("/health/action-inbox", "health.action-inbox.list"), // 行动收件箱 + ]; + + for &(path, perm) in menu_perms { + db.execute_unprepared(&format!( + "UPDATE menus SET permission = '{perm}', updated_at = NOW() \ + WHERE path = '{path}' AND deleted_at IS NULL AND (permission IS NULL OR permission = '')" + )) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + let paths: &[&str] = &[ + "/", + "/users", + "/roles", + "/organizations", + "/workflow", + "/messages", + "/settings", + "/plugins/admin", + "/health/statistics", + "/health/action-inbox", + ]; + + for &path in paths { + db.execute_unprepared(&format!( + "UPDATE menus SET permission = NULL, updated_at = NOW() \ + WHERE path = '{path}' AND deleted_at IS NULL" + )) + .await?; + } + + Ok(()) + } +}