fix(health): 修复 5 角色测试发现的 4 个共性问题
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

- 权限路由守卫:静默重定向改为显示 403 页面,使用 useLocation 替代
  window.location.hash,补全缺失路由权限条目
- 随访状态筛选:usePaginatedData hook 添加 filters 变化监听自动刷新
- 告警操作:后端 acknowledge/dismiss/resolve 改返回 AlertResponse
  (含 patient_name),前端增加 active 状态兼容和错误反馈
- 咨询患者名:后端 create/get/close_session 增加 patient_name 和
  doctor_name enrichment,前端 EntityName 空字符串处理
This commit is contained in:
iven
2026-05-07 07:23:41 +08:00
parent 43f0ba7057
commit 1613e3cfe9
7 changed files with 141 additions and 53 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, lazy, Suspense, useMemo } from 'react';
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
import { HashRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { ConfigProvider, theme as antdTheme, Spin, Result } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import MainLayout from './layouts/MainLayout';
@@ -84,52 +84,71 @@ function FrozenRoute() {
return <Result status="info" title="功能暂未开放" subTitle="该功能正在优化中,敬请期待" />;
}
function ForbiddenPage() {
const navigate = useNavigate();
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<Result
status="403"
title="权限不足"
subTitle="您没有访问此页面的权限,请联系管理员"
extra={<button onClick={() => navigate('/')} style={{ cursor: 'pointer', color: 'var(--ant-color-primary)', background: 'none', border: 'none', fontSize: 14 }}></button>}
/>
</div>
);
}
const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/users': ['user.list', 'user.manage'],
'/roles': ['role.list', 'role.manage'],
'/organizations': ['organization.list', 'organization.manage'],
'/workflow': ['workflow.list', 'workflow.read'],
'/messages': ['message.list'],
'/settings': ['config.settings.list', 'config.settings.manage'],
'/plugins/admin': ['plugin.list', 'plugin.manage'],
'/health/patients': ['health.patient.list', 'health.patient.manage'],
'/health/doctors': ['health.doctor.list', 'health.doctor.manage'],
'/health/follow-up-tasks': ['health.follow-up.list', 'health.follow-up.manage'],
'/health/consultations': ['health.consultation.list', 'health.consultation.manage'],
'/health/action-inbox': ['health.action-inbox.list', 'health.action-inbox.manage'],
'/health/follow-up-templates': ['health.follow-up-templates.list', 'health.follow-up-templates.manage'],
'/health/diagnoses': ['health.health-data.list', 'health.health-data.manage'],
'/health/consents': ['health.consent.list', 'health.consent.manage'],
'/health/realtime-monitor': ['health.device-readings.list', 'health.device-readings.manage'],
'/health/alert-dashboard': ['health.alerts.list', 'health.alerts.manage'],
'/health/alerts': ['health.alerts.list', 'health.alerts.manage'],
'/health/devices': ['health.devices.list', 'health.devices.manage'],
'/health/ble-gateways': ['health.ble-gateways.list', 'health.ble-gateways.manage'],
'/health/critical-value-thresholds': ['health.critical-value-thresholds.list', 'health.critical-value-thresholds.manage'],
'/health/articles': ['health.articles.list', 'health.articles.manage'],
'/health/points-rules': ['health.points.list', 'health.points.manage'],
'/health/points-products': ['health.points.list', 'health.points.manage'],
'/health/points-orders': ['health.points.list', 'health.points.manage'],
'/health/offline-events': ['health.points.list', 'health.points.manage'],
'/health/ai-prompts': ['ai.prompt.list', 'ai.prompt.manage'],
'/health/ai-analysis': ['ai.analysis.list', 'ai.analysis.manage'],
'/health/ai-usage': ['ai.usage.list'],
'/health/oauth-clients': ['health.oauth.list', 'health.oauth.manage'],
'/health/statistics': ['health.health-data.list', 'health.dashboard.manage'],
'/health/tags': ['health.patient.list', 'health.patient.manage'],
'/health/daily-monitoring': ['health.device-readings.list', 'health.device-readings.manage'],
'/health/alert-rules': ['health.alert-rules.list', 'health.alert-rules.manage'],
'/health/medication-records': ['health.medication-records.manage'],
};
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const permissions = useAuthStore((s) => s.permissions);
const location = useLocation();
if (!isAuthenticated) return <Navigate to="/login" replace />;
// 路由级权限检查:如果用户对某个模块完全没有权限,重定向到首页
const path = window.location.hash.replace('#', '');
const routePermissions: Record<string, string[]> = {
'/users': ['user.list', 'user.manage'],
'/roles': ['role.list', 'role.manage'],
'/organizations': ['organization.list', 'organization.manage'],
'/workflow': ['workflow.list', 'workflow.read'],
'/messages': ['message.list'],
'/settings': ['config.settings.list', 'config.settings.manage'],
'/plugins/admin': ['plugin.list', 'plugin.manage'],
'/health/patients': ['health.patient.list', 'health.patient.manage'],
'/health/doctors': ['health.doctor.list', 'health.doctor.manage'],
'/health/follow-up-tasks': ['health.follow-up.list', 'health.follow-up.manage'],
'/health/consultations': ['health.consultation.list', 'health.consultation.manage'],
'/health/action-inbox': ['health.action-inbox.list', 'health.action-inbox.manage'],
'/health/follow-up-templates': ['health.follow-up-templates.list', 'health.follow-up-templates.manage'],
'/health/diagnoses': ['health.health-data.list', 'health.health-data.manage'],
'/health/consents': ['health.consent.list', 'health.consent.manage'],
'/health/realtime-monitor': ['health.device-readings.list', 'health.device-readings.manage'],
'/health/alert-dashboard': ['health.alerts.list', 'health.alerts.manage'],
'/health/devices': ['health.devices.list', 'health.devices.manage'],
'/health/ble-gateways': ['health.ble-gateways.list', 'health.ble-gateways.manage'],
'/health/critical-value-thresholds': ['health.critical-value-thresholds.list', 'health.critical-value-thresholds.manage'],
'/health/articles': ['health.articles.list', 'health.articles.manage'],
'/health/points-rules': ['health.points.list', 'health.points.manage'],
'/health/points-products': ['health.points.list', 'health.points.manage'],
'/health/points-orders': ['health.points.list', 'health.points.manage'],
'/health/offline-events': ['health.points.list', 'health.points.manage'],
'/health/ai-prompts': ['ai.prompt.list', 'ai.prompt.manage'],
'/health/ai-analysis': ['ai.analysis.list', 'ai.analysis.manage'],
'/health/ai-usage': ['ai.usage.list'],
'/health/oauth-clients': ['health.oauth.list', 'health.oauth.manage'],
'/health/statistics': ['health.health-data.list', 'health.dashboard.manage'],
'/health/tags': ['health.patient.list', 'health.patient.manage'],
};
const matchedPrefix = Object.keys(routePermissions).find((prefix) => path.startsWith(prefix));
const path = location.pathname;
const matchedPrefix = Object.keys(ROUTE_PERMISSIONS).find((prefix) => path.startsWith(prefix));
if (matchedPrefix) {
const required = routePermissions[matchedPrefix];
const required = ROUTE_PERMISSIONS[matchedPrefix];
const hasAccess = required.some((r) => permissions.includes(r));
if (!hasAccess) return <Navigate to="/" replace />;
if (!hasAccess) return <ForbiddenPage />;
}
// 冻结路由检查

View File

@@ -7,7 +7,7 @@ interface EntityNameProps {
}
export function EntityName({ name, id, fallbackLabel = '未知' }: EntityNameProps) {
if (name) return <span>{name}</span>;
if (name !== undefined && name !== null && name !== '') return <span>{name}</span>;
if (id) {
return (

View File

@@ -84,6 +84,18 @@ export function usePaginatedData<T, F = string>(
}
}, [shouldAutoFetch, refresh]);
// 筛选条件变化时自动刷新(解决 FollowUpTaskList 等组件直接调用 setFilters 不触发刷新的问题)
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (shouldAutoFetch) {
refresh(1);
}
}, [filters]);
return { ...state, searchText, setSearchText, filters, setFilters, refresh };
}

View File

@@ -106,7 +106,7 @@ export default function AlertDashboard() {
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
} catch {
// 错误由 API client 统一处理
message.error('确认告警失败,请重试');
} finally {
setActionLoading(false);
}
@@ -119,7 +119,7 @@ export default function AlertDashboard() {
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
} catch {
// 错误由 API client 统一处理
message.error('忽略告警失败,请重试');
} finally {
setActionLoading(false);
}
@@ -132,7 +132,7 @@ export default function AlertDashboard() {
setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a)));
setSelectedAlert((prev) => (prev?.id === id ? updated : prev));
} catch {
// 错误由 API client 统一处理
message.error('恢复告警失败,请重试');
} finally {
setActionLoading(false);
}

View File

@@ -17,6 +17,7 @@ const SEVERITY_CONFIG: Record<string, { color: string; label: string; icon: Reac
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
pending: { color: 'orange', label: '待处理' },
active: { color: 'orange', label: '待处理' },
acknowledged: { color: 'blue', label: '已确认' },
resolved: { color: 'green', label: '已恢复' },
dismissed: { color: 'default', label: '已忽略' },
@@ -42,7 +43,7 @@ export function AlertDetailPanel({
}: AlertDetailPanelProps) {
const severity = SEVERITY_CONFIG[alert.severity] ?? SEVERITY_CONFIG.info;
const status = STATUS_CONFIG[alert.status] ?? STATUS_CONFIG.pending;
const isPending = alert.status === 'pending';
const isPending = alert.status === 'pending' || alert.status === 'active';
const isAcknowledged = alert.status === 'acknowledged';
return (

View File

@@ -88,6 +88,34 @@ pub async fn list_alerts(
Ok((items, total))
}
/// 将 alerts::Model 转换为 AlertResponse并查找关联的患者名称。
async fn enrich_alert_response(
db: &DatabaseConnection,
tenant_id: Uuid,
model: alerts::Model,
) -> HealthResult<AlertResponse> {
let patient_name = patient::Entity::find_by_id(model.patient_id)
.filter(patient::Column::TenantId.eq(tenant_id))
.one(db)
.await?
.map(|p| p.name);
Ok(AlertResponse {
id: model.id,
patient_id: model.patient_id,
patient_name,
rule_id: model.rule_id,
severity: model.severity,
title: model.title,
detail: model.detail,
status: model.status,
acknowledged_by: model.acknowledged_by,
acknowledged_at: model.acknowledged_at,
resolved_at: model.resolved_at,
created_at: model.created_at,
version: model.version,
})
}
/// 查询指定医生负责的所有患者 ID 列表(通过 patient_doctor_relation 表)。
async fn get_patient_ids_for_doctor(
db: &DatabaseConnection,
@@ -110,7 +138,7 @@ pub async fn acknowledge_alert(
alert_id: Uuid,
user_id: Uuid,
version: i32,
) -> HealthResult<alerts::Model> {
) -> HealthResult<AlertResponse> {
let alert = alerts::Entity::find_by_id(alert_id)
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::DeletedAt.is_null())
@@ -128,7 +156,8 @@ pub async fn acknowledge_alert(
active.updated_at = Set(Utc::now());
active.version = Set(version + 1);
Ok(active.update(&state.db).await?)
let updated = active.update(&state.db).await?;
enrich_alert_response(&state.db, tenant_id, updated).await
}
pub async fn dismiss_alert(
@@ -137,7 +166,7 @@ pub async fn dismiss_alert(
alert_id: Uuid,
user_id: Uuid,
version: i32,
) -> HealthResult<alerts::Model> {
) -> HealthResult<AlertResponse> {
let alert = alerts::Entity::find_by_id(alert_id)
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::DeletedAt.is_null())
@@ -154,7 +183,8 @@ pub async fn dismiss_alert(
active.updated_at = Set(Utc::now());
active.version = Set(version + 1);
Ok(active.update(&state.db).await?)
let updated = active.update(&state.db).await?;
enrich_alert_response(&state.db, tenant_id, updated).await
}
pub async fn resolve_alert(
@@ -162,7 +192,7 @@ pub async fn resolve_alert(
tenant_id: Uuid,
alert_id: Uuid,
version: i32,
) -> HealthResult<alerts::Model> {
) -> HealthResult<AlertResponse> {
let alert = alerts::Entity::find_by_id(alert_id)
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::DeletedAt.is_null())
@@ -179,7 +209,8 @@ pub async fn resolve_alert(
active.updated_at = Set(Utc::now());
active.version = Set(version + 1);
Ok(active.update(&state.db).await?)
let updated = active.update(&state.db).await?;
enrich_alert_response(&state.db, tenant_id, updated).await
}
#[cfg(test)]

View File

@@ -35,6 +35,31 @@ fn model_to_session_resp(m: consultation_session::Model) -> SessionResp {
}
}
/// 查询单个会话的 patient_name 和 doctor_name 并填充到 SessionResp。
async fn enrich_session_resp(
db: &DatabaseConnection,
tenant_id: Uuid,
mut resp: SessionResp,
) -> HealthResult<SessionResp> {
let patient_name = patient::Entity::find_by_id(resp.patient_id)
.filter(patient::Column::TenantId.eq(tenant_id))
.one(db)
.await?
.map(|p| p.name);
resp.patient_name = patient_name;
if let Some(did) = resp.doctor_id {
let doctor_name = doctor_profile::Entity::find_by_id(did)
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
.one(db)
.await?
.map(|d| d.name);
resp.doctor_name = doctor_name;
}
Ok(resp)
}
pub async fn create_session(
state: &HealthState,
tenant_id: Uuid,
@@ -92,7 +117,7 @@ pub async fn create_session(
&state.db,
).await;
Ok(model_to_session_resp(m))
enrich_session_resp(&state.db, tenant_id, model_to_session_resp(m)).await
}
/// 获取单个咨询会话
@@ -110,7 +135,7 @@ pub async fn get_session(
.await?
.ok_or(HealthError::ConsultationNotFound)?;
Ok(model_to_session_resp(model))
enrich_session_resp(&state.db, tenant_id, model_to_session_resp(model)).await
}
pub async fn list_sessions(
@@ -231,7 +256,7 @@ pub async fn close_session(
&state.db,
).await;
Ok(model_to_session_resp(m))
enrich_session_resp(&state.db, tenant_id, model_to_session_resp(m)).await
}
pub async fn export_sessions(