diff --git a/apps/web/src/hooks/useVitalSSE.ts b/apps/web/src/hooks/useVitalSSE.ts index 8c9284c..04c2a14 100644 --- a/apps/web/src/hooks/useVitalSSE.ts +++ b/apps/web/src/hooks/useVitalSSE.ts @@ -41,15 +41,17 @@ export function useVitalSSE(options: UseVitalSSEOptions = {}): UseVitalSSEReturn setPatientVitals((prev) => { const next = new Map(prev); - if (data.device_model) { - const key = `${data.patient_id}_${data.device_model}`; - next.set(key, { - patient_id: data.patient_id, - device_type: data.device_model, - latest_value: data.count > 0 ? undefined : undefined, - updated_at: data.occurred_at ?? new Date().toISOString(), - }); - } + const key = `${data.patient_id}_${data.device_model ?? 'unknown'}`; + const existing = next.get(key); + next.set(key, { + patient_id: data.patient_id, + device_type: data.device_model ?? 'unknown', + // 批量同步事件不含单值,用累计 count 表示活跃度 + latest_value: data.count > 0 + ? (existing?.latest_value ?? 0) + data.count + : existing?.latest_value, + updated_at: data.occurred_at ?? new Date().toISOString(), + }); return next; }); setLastUpdate(data); diff --git a/apps/web/src/pages/health/OAuthClientList.tsx b/apps/web/src/pages/health/OAuthClientList.tsx index a24039e..a98e020 100644 --- a/apps/web/src/pages/health/OAuthClientList.tsx +++ b/apps/web/src/pages/health/OAuthClientList.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { Button, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography } from 'antd'; +import { Button, Form, Input, InputNumber, message, Modal, Popconfirm, Result, Select, Space, Switch, Table, Tag, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; @@ -11,8 +11,10 @@ import { FHIR_SCOPE_OPTIONS, } from '../../api/health/oauthClients'; import { PageContainer } from '../../components/PageContainer'; +import { usePermission } from '../../hooks/usePermission'; export default function OAuthClientList() { + const { hasPermission } = usePermission('health.oauth.manage'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); @@ -162,6 +164,10 @@ export default function OAuthClientList() { }, ]; + if (!hasPermission) { + return ; + } + return ( = { @@ -48,6 +50,7 @@ const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({ })); export default function OfflineEventList() { + const { hasPermission } = usePermission('health.points.list'); const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -58,6 +61,10 @@ export default function OfflineEventList() { const [editing, setEditing] = useState(null); const [form] = Form.useForm(); + if (!hasPermission) { + return ; + } + // ---- 数据获取 ---- const fetchData = useCallback(async (p = page, ps = pageSize) => { setLoading(true); diff --git a/apps/web/src/pages/health/RealtimeMonitor.tsx b/apps/web/src/pages/health/RealtimeMonitor.tsx index 7cb6e80..17b3f50 100644 --- a/apps/web/src/pages/health/RealtimeMonitor.tsx +++ b/apps/web/src/pages/health/RealtimeMonitor.tsx @@ -1,7 +1,8 @@ import { useState, useCallback, useEffect } from 'react'; -import { Card, Row, Col, Statistic, Tag, List, Select, Badge, Typography, Space, Empty } from 'antd'; +import { Card, Row, Col, Statistic, Tag, List, Select, Badge, Typography, Space, Empty, Result } from 'antd'; import { AlertOutlined } from '@ant-design/icons'; import { useVitalSSE } from '../../hooks/useVitalSSE'; +import { usePermission } from '../../hooks/usePermission'; import { alertApi, type Alert } from '../../api/health/alerts'; import { PageContainer } from '../../components/PageContainer'; import { SEVERITY_COLOR, SEVERITY_LABEL, VITAL_CARD_METRICS } from '../../constants/health'; @@ -20,6 +21,7 @@ interface PatientAlertSummary { * SSE 实时接收体征更新,按告警严重度排序患者列表。 */ export default function RealtimeMonitor() { + const { hasPermission } = usePermission('health.alerts.list'); const [alerts, setAlerts] = useState([]); const [loading, setLoading] = useState(true); const [selectedPatientId, setSelectedPatientId] = useState(null); @@ -68,6 +70,10 @@ export default function RealtimeMonitor() { const totalMedium = alertSummary.reduce((s, a) => s + a.medium, 0); const totalLow = alertSummary.reduce((s, a) => s + a.low, 0); + if (!hasPermission) { + return ; + } + return ( = { }; export default function StatisticsDashboard() { + const { hasPermission } = usePermission('health.dashboard.manage'); const role = useDashboardRole(); + + if (!hasPermission) { + return ; + } + const DashboardComponent = DASHBOARD_MAP[role]; return ; } diff --git a/crates/erp-health/src/fhir/handler.rs b/crates/erp-health/src/fhir/handler.rs index 0c4e03c..13bcffb 100644 --- a/crates/erp-health/src/fhir/handler.rs +++ b/crates/erp-health/src/fhir/handler.rs @@ -31,7 +31,7 @@ where "mode": "server", "resource": [ { "type": "Patient", "interaction": [{"code": "read"}, {"code": "search-type"}], "operation": [{"name": "everything"}] }, - { "type": "Observation", "interaction": [{"code": "read"}, {"code": "search-type"}], "operation": [{"name": "lastn"}] }, + { "type": "Observation", "interaction": [{"code": "read"}, {"code": "search-type"}] }, { "type": "Device", "interaction": [{"code": "read"}, {"code": "search-type"}] }, { "type": "DiagnosticReport", "interaction": [{"code": "read"}, {"code": "search-type"}] }, { "type": "Encounter", "interaction": [{"code": "read"}, {"code": "search-type"}] }, @@ -40,8 +40,7 @@ where { "type": "Task", "interaction": [{"code": "read"}, {"code": "search-type"}] }, ], "operation": [ - { "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" }, - { "name": "lastn", "definition": "/fhir/R4/Observation/$lastn" }, + { "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" } ] }] }); diff --git a/crates/erp-health/src/oauth/middleware.rs b/crates/erp-health/src/oauth/middleware.rs index 6a216c0..ba64d0d 100644 --- a/crates/erp-health/src/oauth/middleware.rs +++ b/crates/erp-health/src/oauth/middleware.rs @@ -6,6 +6,7 @@ use axum::{ Json, }; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use uuid::Uuid; /// Client Credentials JWT Claims @@ -107,12 +108,48 @@ pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response { let fhir_ctx = FhirAuthContext { client_id: claims.sub, tenant_id: claims.tid, - scopes: claims.scopes, + scopes: claims.scopes.clone(), allowed_patient_ids: claims.allowed_patient_ids, }; + // 同时注入 TenantContext,兼容 require_permission() 和 ctx.tenant_id + // 将 FHIR scope 映射为内部 permission code + let permissions: Vec = claims + .scopes + .iter() + .flat_map(|s| scope_to_permissions(s)) + .collect(); + + let tenant_ctx = erp_core::types::TenantContext { + tenant_id: claims.tid, + user_id: claims.sub, + roles: vec!["api_client".to_string()], + permissions, + department_ids: vec![], + permission_data_scopes: HashMap::new(), + }; + let mut request = request; + request.extensions_mut().insert(tenant_ctx); request.extensions_mut().insert(fhir_ctx); next.run(request).await } + +/// FHIR scope → 内部 permission code 映射 +fn scope_to_permissions(scope: &str) -> Vec { + match scope { + "Patient.read" => vec!["health.patient.list".to_string()], + "Observation.read" => vec![ + "health.device-readings.list".to_string(), + "health.health-data.list".to_string(), + ], + "Device.read" => vec!["health.devices.list".to_string()], + "Practitioner.read" => vec!["health.doctor.list".to_string()], + "Appointment.read" => vec!["health.appointment.list".to_string()], + "DiagnosticReport.read" => vec!["health.health-data.list".to_string()], + "Encounter.read" => vec!["health.consultation.list".to_string()], + "Task.read" => vec!["health.follow-up.list".to_string()], + _ => vec![], + } +}