From 22b8ac7ac6cc5f43564ee628e6d8f994e527748d Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 8 May 2026 12:42:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=89=BE=E8=8C=AC=E6=B5=8B=E8=AF=95=20V2=20=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E7=9A=84=2011=20=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 (CRITICAL): - C1: 统计 API 全部改为 safe_aggregate 容错,防止单个子查询崩溃导致 500 - C2: Token 刷新增加用户身份验证,防止并发场景下身份切换 - C3: 患者端线下活动接口添加患者档案验证,防止 Doctor/HM 越权访问 P1 (HIGH): - H1: 操作记录用 EntityName 组件解析用户名,不再显示截断 UUID - H4: 告警标题添加中英文映射 (translateAlertTitle) - H5: 告警面板补全 message import + 修复 hooks 顺序 - H8: 咨询消息发送按钮添加 AuthButton 权限控制 - H9: routeConfig 日常监测权限码改为 health.daily-monitoring.* P2 (MEDIUM): - M4: 咨询类型映射补全 online/phone/doctor/follow_up 中文标签 DTO: LabReportStatisticsResp, AppointmentStatisticsResp, VitalSignsReportRateResp 添加 Default derive --- apps/web/src/api/client.ts | 112 ++- apps/web/src/constants/health.ts | 235 +++--- apps/web/src/pages/health/AlertDashboard.tsx | 135 +-- .../src/pages/health/ConsultationDetail.tsx | 206 ++--- .../web/src/pages/health/ConsultationList.tsx | 152 ++-- .../health/components/AlertDetailPanel.tsx | 158 ++-- .../components/workbench/AdminDashboard.tsx | 773 ++++++++++++++---- apps/web/src/routeConfig.ts | 235 ++++-- crates/erp-health/src/dto/stats_dto.rs | 8 +- .../erp-health/src/handler/points_handler.rs | 2 + .../erp-health/src/handler/stats_handler.rs | 46 +- 11 files changed, 1443 insertions(+), 619 deletions(-) diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 077310b..214400b 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; -import { message as antMessage } from 'antd'; +import axios from "axios"; +import { message as antMessage } from "antd"; // 请求缓存:短时间内相同请求复用结果 interface CacheEntry { @@ -10,26 +10,30 @@ interface CacheEntry { const requestCache = new Map(); const CACHE_TTL = 5000; // 5 秒缓存 -function getCacheKey(config: { url?: string; params?: unknown; method?: string }): string { - return `${config.method || 'get'}:${config.url || ''}:${JSON.stringify(config.params || {})}`; +function getCacheKey(config: { + url?: string; + params?: unknown; + method?: string; +}): string { + return `${config.method || "get"}:${config.url || ""}:${JSON.stringify(config.params || {})}`; } const defaultAdapter = axios.getAdapter(axios.defaults.adapter); const client = axios.create({ - baseURL: '/api/v1', + baseURL: "/api/v1", timeout: 10000, - headers: { 'Content-Type': 'application/json' }, + headers: { "Content-Type": "application/json" }, adapter: (config) => { // GET 请求检查缓存 - if (config.method === 'get' && config.url) { + if (config.method === "get" && config.url) { const key = getCacheKey(config); const entry = requestCache.get(key); if (entry && Date.now() - entry.timestamp < CACHE_TTL) { return Promise.resolve({ data: entry.data, status: 200, - statusText: 'OK (cached)', + statusText: "OK (cached)", headers: {} as any, config, }); @@ -40,11 +44,15 @@ const client = axios.create({ }); // Decode JWT payload without external library -function decodeJwtPayload(token: string): { exp?: number } | null { +function decodeJwtPayload( + token: string, +): { exp?: number; sub?: string } | null { try { - const parts = token.split('.'); + const parts = token.split("."); if (parts.length !== 3) return null; - const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); + const payload = JSON.parse( + atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")), + ); return payload; } catch { return null; @@ -60,26 +68,38 @@ function isTokenExpiringSoon(token: string): boolean { // Request interceptor: attach access token + proactive refresh client.interceptors.request.use(async (config) => { - const token = localStorage.getItem('access_token'); + const token = localStorage.getItem("access_token"); if (token) { // If token is about to expire, proactively refresh before sending the request if (isTokenExpiringSoon(token)) { - const refreshToken = localStorage.getItem('refresh_token'); + const refreshToken = localStorage.getItem("refresh_token"); if (refreshToken && !isRefreshing) { isRefreshing = true; try { - const { data } = await axios.post('/api/v1/auth/refresh', { + const { data } = await axios.post("/api/v1/auth/refresh", { refresh_token: refreshToken, }); const newAccess = data.data.access_token; const newRefresh = data.data.refresh_token; - localStorage.setItem('access_token', newAccess); - localStorage.setItem('refresh_token', newRefresh); + + // 验证新 token 的用户身份一致 + const currentUserSub = decodeJwtPayload(token)?.sub; + const newTokenSub = decodeJwtPayload(newAccess)?.sub; + if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + localStorage.removeItem("user"); + window.location.hash = "/login"; + return Promise.reject(new Error("身份验证失败,请重新登录")); + } + + localStorage.setItem("access_token", newAccess); + localStorage.setItem("refresh_token", newRefresh); processQueue(null, newAccess); config.headers.Authorization = `Bearer ${newAccess}`; return config; } catch { - processQueue(new Error('refresh failed'), null); + processQueue(new Error("refresh failed"), null); // Continue with old token, let 401 handler deal with it } finally { isRefreshing = false; @@ -96,7 +116,7 @@ client.interceptors.request.use(async (config) => { client.interceptors.response.use( (response) => { // 缓存 GET 响应 - if (response.config.method === 'get' && response.config.url) { + if (response.config.method === "get" && response.config.url) { const key = getCacheKey(response.config); requestCache.set(key, { data: response.data, timestamp: Date.now() }); } @@ -104,7 +124,11 @@ client.interceptors.response.use( }, async (error) => { const originalRequest = error.config; - if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.url?.includes('/auth/login')) { + if ( + error.response?.status === 401 && + !originalRequest._retry && + !originalRequest.url?.includes("/auth/login") + ) { if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); @@ -118,18 +142,33 @@ client.interceptors.response.use( isRefreshing = true; try { - const refreshToken = localStorage.getItem('refresh_token'); - if (!refreshToken) throw new Error('No refresh token'); + const refreshToken = localStorage.getItem("refresh_token"); + if (!refreshToken) throw new Error("No refresh token"); - const { data } = await axios.post('/api/v1/auth/refresh', { + const { data } = await axios.post("/api/v1/auth/refresh", { refresh_token: refreshToken, }); const newAccessToken = data.data.access_token; const newRefreshToken = data.data.refresh_token; - localStorage.setItem('access_token', newAccessToken); - localStorage.setItem('refresh_token', newRefreshToken); + // 验证新 token 的用户身份与当前用户一致,防止并发场景下身份切换 + const currentToken = localStorage.getItem("access_token"); + const currentUserSub = currentToken + ? decodeJwtPayload(currentToken)?.sub + : null; + const newTokenSub = decodeJwtPayload(newAccessToken)?.sub; + if (currentUserSub && newTokenSub && currentUserSub !== newTokenSub) { + // 身份不一致,强制登出 + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + localStorage.removeItem("user"); + window.location.hash = "/login"; + return Promise.reject(new Error("身份验证失败,请重新登录")); + } + + localStorage.setItem("access_token", newAccessToken); + localStorage.setItem("refresh_token", newRefreshToken); processQueue(null, newAccessToken); @@ -137,9 +176,9 @@ client.interceptors.response.use( return client(originalRequest); } catch (refreshError) { processQueue(refreshError, null); - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - window.location.hash = '/login'; + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + window.location.hash = "/login"; return Promise.reject(refreshError); } finally { isRefreshing = false; @@ -147,7 +186,7 @@ client.interceptors.response.use( } return Promise.reject(error); - } + }, ); // 全局错误提示(仅对未被组件处理的错误显示) @@ -156,12 +195,14 @@ function showGlobalError(msg: string) { // 防止短时间内弹出大量相同提示 if (globalErrorTimer) return; antMessage.error(msg, 3); - globalErrorTimer = setTimeout(() => { globalErrorTimer = null; }, 3000); + globalErrorTimer = setTimeout(() => { + globalErrorTimer = null; + }, 3000); } // 全局错误拦截 — 在响应拦截器之后、组件 catch 之前执行 // 组件可通过 axios config 中设置 skipGlobalError: true 来抑制全局提示 -declare module 'axios' { +declare module "axios" { interface InternalAxiosRequestConfig { skipGlobalError?: boolean; } @@ -174,16 +215,16 @@ client.interceptors.response.use( return Promise.reject(error); } if (!error.response) { - showGlobalError('网络连接异常,请检查网络'); + showGlobalError("网络连接异常,请检查网络"); } else if (error.response.status === 403) { // 403 通常是权限不足,不全局提示 — 组件层通过 AuthButton 已隐藏操作入口 } else if (error.response.status === 404) { // 404 通常由组件自行处理(如跳转),不全局提示 } else if (error.response.status >= 500) { - showGlobalError('服务器异常,请稍后重试'); + showGlobalError("服务器异常,请稍后重试"); } return Promise.reject(error); - } + }, ); let isRefreshing = false; @@ -206,9 +247,10 @@ export function clearApiCache() { } // 通用错误处理:提取后端错误消息并展示 -export function handleApiError(err: unknown, fallback = '操作失败'): string { +export function handleApiError(err: unknown, fallback = "操作失败"): string { const msg = - (err as { response?: { data?: { message?: string } } })?.response?.data?.message || fallback; + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || fallback; antMessage.error(msg); return msg; } diff --git a/apps/web/src/constants/health.ts b/apps/web/src/constants/health.ts index 8d3515a..15fb07e 100644 --- a/apps/web/src/constants/health.ts +++ b/apps/web/src/constants/health.ts @@ -7,167 +7,194 @@ // --- 性别 --- export const GENDER_OPTIONS = [ - { value: 'male', label: '男' }, - { value: 'female', label: '女' }, - { value: 'other', label: '其他' }, + { value: "male", label: "男" }, + { value: "female", label: "女" }, + { value: "other", label: "其他" }, ]; export const GENDER_LABEL: Record = { - male: '男', - female: '女', - other: '其他', + male: "男", + female: "女", + other: "其他", }; // --- 血型 --- export const BLOOD_TYPE_OPTIONS = [ - { value: 'A', label: 'A 型' }, - { value: 'B', label: 'B 型' }, - { value: 'AB', label: 'AB 型' }, - { value: 'O', label: 'O 型' }, + { value: "A", label: "A 型" }, + { value: "B", label: "B 型" }, + { value: "AB", label: "AB 型" }, + { value: "O", label: "O 型" }, ]; // --- 患者状态 --- export const STATUS_OPTIONS = [ - { value: '', label: '全部状态' }, - { value: 'active', label: '活跃' }, - { value: 'inactive', label: '停用' }, - { value: 'deceased', label: '已故' }, + { value: "", label: "全部状态" }, + { value: "active", label: "活跃" }, + { value: "inactive", label: "停用" }, + { value: "deceased", label: "已故" }, ]; // --- 严重度(统一 5 处重复定义: AlertDashboard, AlertList, AlertRuleList, DoctorDashboard) --- export const SEVERITY_COLOR: Record = { - info: 'default', - warning: 'orange', - critical: 'red', - urgent: 'magenta', - high: 'red', - medium: 'orange', + info: "default", + warning: "orange", + critical: "red", + urgent: "magenta", + high: "red", + medium: "orange", }; export const SEVERITY_LABEL: Record = { - info: '提示', - warning: '警告', - critical: '严重', - urgent: '紧急', - high: '严重', - medium: '中等', + info: "提示", + warning: "警告", + critical: "严重", + urgent: "紧急", + high: "严重", + medium: "中等", }; export const SEVERITY_OPTIONS = [ - { value: 'info', label: '提示' }, - { value: 'warning', label: '警告' }, - { value: 'medium', label: '中等' }, - { value: 'critical', label: '严重' }, - { value: 'high', label: '严重' }, - { value: 'urgent', label: '紧急' }, + { value: "info", label: "提示" }, + { value: "warning", label: "警告" }, + { value: "medium", label: "中等" }, + { value: "critical", label: "严重" }, + { value: "high", label: "严重" }, + { value: "urgent", label: "紧急" }, ]; // --- 告警状态(统一 3 处: AlertDashboard, AlertList) --- export const ALERT_STATUS_COLOR: Record = { - pending: 'orange', - active: 'gold', - acknowledged: 'blue', - resolved: 'green', - dismissed: 'default', + pending: "orange", + active: "gold", + acknowledged: "blue", + resolved: "green", + dismissed: "default", }; export const ALERT_STATUS_LABEL: Record = { - pending: '待处理', - active: '活跃', - acknowledged: '已确认', - resolved: '已恢复', - dismissed: '已忽略', + pending: "待处理", + active: "活跃", + acknowledged: "已确认", + resolved: "已恢复", + dismissed: "已忽略", }; export const ALERT_STATUS_OPTIONS = [ - { value: '', label: '全部状态' }, - { value: 'active', label: '活跃' }, - { value: 'pending', label: '待处理' }, - { value: 'acknowledged', label: '已确认' }, - { value: 'resolved', label: '已恢复' }, - { value: 'dismissed', label: '已忽略' }, + { value: "", label: "全部状态" }, + { value: "active", label: "活跃" }, + { value: "pending", label: "待处理" }, + { value: "acknowledged", label: "已确认" }, + { value: "resolved", label: "已恢复" }, + { value: "dismissed", label: "已忽略" }, ]; // --- 设备类型(统一 3 处: DeviceManage, DeviceReadingsTab, AlertRuleList) --- export const DEVICE_TYPE_OPTIONS = [ - { value: 'blood_pressure', label: '血压' }, - { value: 'blood_glucose', label: '血糖' }, - { value: 'heart_rate', label: '心率' }, - { value: 'blood_oxygen', label: '血氧' }, - { value: 'temperature', label: '体温' }, - { value: 'steps', label: '步数' }, - { value: 'sleep', label: '睡眠' }, - { value: 'stress', label: '压力' }, + { value: "blood_pressure", label: "血压" }, + { value: "blood_glucose", label: "血糖" }, + { value: "heart_rate", label: "心率" }, + { value: "blood_oxygen", label: "血氧" }, + { value: "temperature", label: "体温" }, + { value: "steps", label: "步数" }, + { value: "sleep", label: "睡眠" }, + { value: "stress", label: "压力" }, ]; export const DEVICE_TYPE_COLOR: Record = { - blood_pressure: 'red', - blood_glucose: 'purple', - heart_rate: 'volcano', - blood_oxygen: 'blue', - temperature: 'orange', - steps: 'green', - sleep: 'cyan', - stress: 'geekblue', + blood_pressure: "red", + blood_glucose: "purple", + heart_rate: "volcano", + blood_oxygen: "blue", + temperature: "orange", + steps: "green", + sleep: "cyan", + stress: "geekblue", }; // --- 告警规则条件类型 --- export const CONDITION_TYPE_OPTIONS = [ - { value: 'single_threshold', label: '单次阈值' }, - { value: 'consecutive', label: '连续触发' }, - { value: 'trend', label: '趋势变化' }, + { value: "single_threshold", label: "单次阈值" }, + { value: "consecutive", label: "连续触发" }, + { value: "trend", label: "趋势变化" }, ]; // --- 设备连接状态 --- export const DEVICE_STATUS_OPTIONS = [ - { value: '', label: '全部状态' }, - { value: 'online', label: '在线' }, - { value: 'offline', label: '离线' }, - { value: 'paired', label: '已配对' }, - { value: 'error', label: '异常' }, + { value: "", label: "全部状态" }, + { value: "online", label: "在线" }, + { value: "offline", label: "离线" }, + { value: "paired", label: "已配对" }, + { value: "error", label: "异常" }, ]; export const DEVICE_STATUS_COLOR: Record = { - online: 'green', - offline: 'default', - paired: 'blue', - error: 'red', + online: "green", + offline: "default", + paired: "blue", + error: "red", }; // --- 设备连接类型 --- export const CONNECTION_TYPE_OPTIONS = [ - { value: 'ble', label: '蓝牙' }, - { value: 'gateway', label: '网关' }, - { value: 'manual', label: '手动录入' }, + { value: "ble", label: "蓝牙" }, + { value: "gateway", label: "网关" }, + { value: "manual", label: "手动录入" }, ]; // --- 实时监控卡片指标 --- export const VITAL_CARD_METRICS = [ - { key: 'heart_rate', label: '心率', unit: 'bpm', color: '#ff4d4f' }, - { key: 'blood_oxygen', label: '血氧', unit: '%', color: '#1890ff' }, - { key: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#f5222d' }, - { key: 'blood_glucose', label: '血糖', unit: 'mg/dL', color: '#722ed1' }, - { key: 'temperature', label: '体温', unit: '°C', color: '#fa8c16' }, - { key: 'steps', label: '步数', unit: '步', color: '#52c41a' }, + { key: "heart_rate", label: "心率", unit: "bpm", color: "#ff4d4f" }, + { key: "blood_oxygen", label: "血氧", unit: "%", color: "#1890ff" }, + { key: "blood_pressure", label: "血压", unit: "mmHg", color: "#f5222d" }, + { key: "blood_glucose", label: "血糖", unit: "mg/dL", color: "#722ed1" }, + { key: "temperature", label: "体温", unit: "°C", color: "#fa8c16" }, + { key: "steps", label: "步数", unit: "步", color: "#52c41a" }, ] as const; -// --- 通用状态标签(StatusTag 组件统一引用) --- -export const STATUS_TAG_CONFIG: Record = { - // 预约状态 - pending: { color: 'gold', label: '待确认' }, - confirmed: { color: 'blue', label: '已确认' }, - completed: { color: 'green', label: '已完成' }, - cancelled: { color: 'default', label: '已取消' }, - no_show: { color: 'red', label: '未到诊' }, - // 随访状态 - overdue: { color: 'red', label: '逾期' }, - in_progress: { color: 'processing', label: '进行中' }, - // 咨询状态 - waiting: { color: 'gold', label: '等待中' }, - active: { color: 'green', label: '进行中' }, - closed: { color: 'default', label: '已关闭' }, - // 患者状态 - inactive: { color: 'default', label: '停用' }, - deceased: { color: 'default', label: '已故' }, - verified: { color: 'green', label: '已认证' }, +// --- 告警标题中英文映射 --- +export const ALERT_TITLE_MAP: Record = { + "BP Critical High": "血压严重偏高", + "BP Critical Low": "血压严重偏低", + "Heart Rate Abnormal": "心率异常", + "Blood Sugar Elevated": "血糖偏高", + "Blood Sugar Critical": "血糖危急值", + "Blood Sugar Low": "血糖偏低", + "Weight Gain Alert": "体重增长异常", + "Missed Medication": "漏服药物", + "SpO2 Low": "血氧偏低", + "Temperature High": "体温偏高", + "Temperature Low": "体温偏低", + "BP Trending High": "血压趋势偏高", + "BP Trending Low": "血压趋势偏低", + "Heart Rate High": "心率偏高", + "Heart Rate Low": "心率偏低", +}; + +/** 翻译告警标题:优先精确匹配,其次回退原文 */ +export function translateAlertTitle(title: string): string { + return ALERT_TITLE_MAP[title] ?? title; +} + +// --- 通用状态标签(StatusTag 组件统一引用) --- +export const STATUS_TAG_CONFIG: Record< + string, + { color: string; label: string } +> = { + // 预约状态 + pending: { color: "gold", label: "待确认" }, + confirmed: { color: "blue", label: "已确认" }, + completed: { color: "green", label: "已完成" }, + cancelled: { color: "default", label: "已取消" }, + no_show: { color: "red", label: "未到诊" }, + // 随访状态 + overdue: { color: "red", label: "逾期" }, + in_progress: { color: "processing", label: "进行中" }, + // 咨询状态 + waiting: { color: "gold", label: "等待中" }, + active: { color: "green", label: "进行中" }, + closed: { color: "default", label: "已关闭" }, + // 患者状态 + inactive: { color: "default", label: "停用" }, + deceased: { color: "default", label: "已故" }, + verified: { color: "green", label: "已认证" }, }; diff --git a/apps/web/src/pages/health/AlertDashboard.tsx b/apps/web/src/pages/health/AlertDashboard.tsx index afb5410..0317ddf 100644 --- a/apps/web/src/pages/health/AlertDashboard.tsx +++ b/apps/web/src/pages/health/AlertDashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect } from "react"; import { Row, Col, @@ -13,21 +13,29 @@ import { Space, Flex, Result, -} from 'antd'; + message, +} from "antd"; import { AlertOutlined, CheckCircleOutlined, ExclamationCircleOutlined, WarningOutlined, WifiOutlined, -} from '@ant-design/icons'; -import { alertApi, type Alert } from '../../api/health/alerts'; -import { usePermission } from '../../hooks/usePermission'; -import { SEVERITY_COLOR, SEVERITY_LABEL, ALERT_STATUS_COLOR, ALERT_STATUS_LABEL, ALERT_STATUS_OPTIONS } from '../../constants/health'; -import { useAlertSSE, type AlertSSEEvent } from '../../hooks/useAlertSSE'; -import { AlertDetailPanel } from './components/AlertDetailPanel'; -import { PageContainer } from '../../components/PageContainer'; -import { EntityName } from '../../components/EntityName'; +} from "@ant-design/icons"; +import { alertApi, type Alert } from "../../api/health/alerts"; +import { usePermission } from "../../hooks/usePermission"; +import { + SEVERITY_COLOR, + SEVERITY_LABEL, + ALERT_STATUS_COLOR, + ALERT_STATUS_LABEL, + ALERT_STATUS_OPTIONS, + translateAlertTitle, +} from "../../constants/health"; +import { useAlertSSE, type AlertSSEEvent } from "../../hooks/useAlertSSE"; +import { AlertDetailPanel } from "./components/AlertDetailPanel"; +import { PageContainer } from "../../components/PageContainer"; +import { EntityName } from "../../components/EntityName"; /** * 实时告警仪表盘 — 医生端。 @@ -40,11 +48,10 @@ import { EntityName } from '../../components/EntityName'; * - 确认/忽略/恢复操作 */ export default function AlertDashboard() { - const { hasPermission } = usePermission('health.alerts.list'); - if (!hasPermission) return ; + const { hasPermission } = usePermission("health.alerts.list"); const [alerts, setAlerts] = useState([]); const [selectedAlert, setSelectedAlert] = useState(null); - const [statusFilter, setStatusFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(""); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); const [total, setTotal] = useState(0); @@ -76,11 +83,11 @@ export default function AlertDashboard() { const newAlert: Alert = { id: event.alert_id, patient_id: event.patient_id, - rule_id: '', + rule_id: "", severity: event.severity, - title: event.rule_name ?? '新告警', + title: event.rule_name ?? "新告警", detail: event.detail, - status: 'pending', + status: "pending", created_at: event.occurred_at ?? new Date().toISOString(), version: 1, }; @@ -106,7 +113,7 @@ export default function AlertDashboard() { setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a))); setSelectedAlert((prev) => (prev?.id === id ? updated : prev)); } catch { - message.error('确认告警失败,请重试'); + message.error("确认告警失败,请重试"); } finally { setActionLoading(false); } @@ -119,7 +126,7 @@ export default function AlertDashboard() { setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a))); setSelectedAlert((prev) => (prev?.id === id ? updated : prev)); } catch { - message.error('忽略告警失败,请重试'); + message.error("忽略告警失败,请重试"); } finally { setActionLoading(false); } @@ -132,16 +139,28 @@ export default function AlertDashboard() { setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a))); setSelectedAlert((prev) => (prev?.id === id ? updated : prev)); } catch { - message.error('恢复告警失败,请重试'); + message.error("恢复告警失败,请重试"); } finally { setActionLoading(false); } }, []); // 统计 - const pendingCount = alerts.filter((a) => a.status === 'pending').length; - const acknowledgedCount = alerts.filter((a) => a.status === 'acknowledged').length; - const criticalCount = alerts.filter((a) => a.severity === 'critical' || a.severity === 'urgent').length; + if (!hasPermission) + return ( + + ); + const pendingCount = alerts.filter((a) => a.status === "pending").length; + const acknowledgedCount = alerts.filter( + (a) => a.status === "acknowledged", + ).length; + const criticalCount = alerts.filter( + (a) => a.severity === "critical" || a.severity === "urgent", + ).length; return ( - - - {connected ? '实时连接' : '连接断开'} - - } /> + + + {connected ? "实时连接" : "连接断开"} + + } + /> } > @@ -174,7 +196,7 @@ export default function AlertDashboard() { title="待处理" value={pendingCount} prefix={} - valueStyle={{ color: pendingCount > 0 ? '#fa8c16' : undefined }} + valueStyle={{ color: pendingCount > 0 ? "#fa8c16" : undefined }} /> @@ -184,7 +206,7 @@ export default function AlertDashboard() { title="已确认" value={acknowledgedCount} prefix={} - valueStyle={{ color: '#1890ff' }} + valueStyle={{ color: "#1890ff" }} /> @@ -194,7 +216,9 @@ export default function AlertDashboard() { title="危急值" value={criticalCount} prefix={} - valueStyle={{ color: criticalCount > 0 ? '#ff4d4f' : undefined }} + valueStyle={{ + color: criticalCount > 0 ? "#ff4d4f" : undefined, + }} /> @@ -212,46 +236,65 @@ export default function AlertDashboard() { } size="small" - style={{ maxHeight: 600, overflow: 'auto' }} + style={{ maxHeight: 600, overflow: "auto" }} > ( setSelectedAlert(alert)} style={{ - cursor: 'pointer', - background: selectedAlert?.id === alert.id ? 'var(--ant-color-primary-bg)' : undefined, - padding: '8px 12px', + cursor: "pointer", + background: + selectedAlert?.id === alert.id + ? "var(--ant-color-primary-bg)" + : undefined, + padding: "8px 12px", borderRadius: 6, - transition: 'background 0.2s', + transition: "background 0.2s", }} > {SEVERITY_LABEL[alert.severity] ?? alert.severity} } title={ - {alert.title} - + {translateAlertTitle(alert.title)} + {ALERT_STATUS_LABEL[alert.status] ?? alert.status} } description={ - - 患者: - {' · '} - {new Date(alert.created_at).toLocaleString('zh-CN')} + + 患者:{" "} + + {" · "} + {new Date(alert.created_at).toLocaleString("zh-CN")} } /> @@ -272,7 +315,7 @@ export default function AlertDashboard() { loading={actionLoading} /> ) : ( -
+
点击左侧告警查看详情 diff --git a/apps/web/src/pages/health/ConsultationDetail.tsx b/apps/web/src/pages/health/ConsultationDetail.tsx index 059f71a..b54095d 100644 --- a/apps/web/src/pages/health/ConsultationDetail.tsx +++ b/apps/web/src/pages/health/ConsultationDetail.tsx @@ -1,23 +1,31 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Button, Input, Spin, Popconfirm, message, Typography } from 'antd'; -import { SendOutlined, CloseCircleOutlined, ArrowUpOutlined } from '@ant-design/icons'; -import { useParams } from 'react-router-dom'; -import { consultationApi, type Session, type Message } from '../../api/health/consultations'; -import { StatusTag } from './components/StatusTag'; -import { ImagePreview } from './components/ImagePreview'; -import { useThemeMode } from '../../hooks/useThemeMode'; -import { AuthButton } from '../../components/AuthButton'; -import { EntityName } from '../../components/EntityName'; +import { useState, useEffect, useCallback, useRef } from "react"; +import { Button, Input, Spin, Popconfirm, message, Typography } from "antd"; +import { + SendOutlined, + CloseCircleOutlined, + ArrowUpOutlined, +} from "@ant-design/icons"; +import { useParams } from "react-router-dom"; +import { + consultationApi, + type Session, + type Message, +} from "../../api/health/consultations"; +import { StatusTag } from "./components/StatusTag"; +import { ImagePreview } from "./components/ImagePreview"; +import { useThemeMode } from "../../hooks/useThemeMode"; +import { AuthButton } from "../../components/AuthButton"; +import { EntityName } from "../../components/EntityName"; const PAGE_SIZE = 30; const POLL_INTERVAL = 10_000; function formatTime(value: string): string { - return new Date(value).toLocaleString('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', + return new Date(value).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", }); } @@ -32,15 +40,15 @@ function parseImageUrls(content: string): string[] { } } -const ROLE_ALIGN: Record = { - patient: 'flex-start', - doctor: 'flex-end', - system: 'center', +const ROLE_ALIGN: Record = { + patient: "flex-start", + doctor: "flex-end", + system: "center", }; export default function ConsultationDetail() { const { id } = useParams<{ id: string }>(); - const sessionId = id ?? ''; + const sessionId = id ?? ""; // Session info const [session, setSession] = useState(null); @@ -51,7 +59,7 @@ export default function ConsultationDetail() { const [msgPage, setMsgPage] = useState(1); const [msgLoading, setMsgLoading] = useState(false); const [sending, setSending] = useState(false); - const [inputText, setInputText] = useState(''); + const [inputText, setInputText] = useState(""); const [hasMore, setHasMore] = useState(false); const chatEndRef = useRef(null); @@ -68,7 +76,7 @@ export default function ConsultationDetail() { const result = await consultationApi.getSession(sessionId); setSession(result); } catch { - message.error('加载会话信息失败'); + message.error("加载会话信息失败"); } setSessionLoading(false); }, [sessionId]); @@ -93,7 +101,7 @@ export default function ConsultationDetail() { } setHasMore(page < totalPages); } catch { - message.error('加载消息失败'); + message.error("加载消息失败"); } setMsgLoading(false); }, @@ -108,7 +116,7 @@ export default function ConsultationDetail() { // Poll new messages while session is active useEffect(() => { - if (!session || session.status === 'closed') return; + if (!session || session.status === "closed") return; const stopPolling = () => { if (pollRef.current) { @@ -121,8 +129,9 @@ export default function ConsultationDetail() { pollRef.current = setInterval(async () => { if (!sessionId) return; try { - const realMsgs = messages.filter((m) => !m.id.startsWith('temp_')); - const lastId = realMsgs.length > 0 ? realMsgs[realMsgs.length - 1].id : undefined; + const realMsgs = messages.filter((m) => !m.id.startsWith("temp_")); + const lastId = + realMsgs.length > 0 ? realMsgs[realMsgs.length - 1].id : undefined; const result = await consultationApi.listMessages(sessionId, { page: 1, page_size: 50, @@ -143,7 +152,7 @@ export default function ConsultationDetail() { // Auto-scroll to bottom on new messages useEffect(() => { if (shouldScrollRef.current && chatEndRef.current) { - chatEndRef.current.scrollIntoView({ behavior: 'smooth' }); + chatEndRef.current.scrollIntoView({ behavior: "smooth" }); } }, [messages.length]); @@ -158,27 +167,27 @@ export default function ConsultationDetail() { const optimisticMsg: Message = { id: `temp_${Date.now()}`, session_id: sessionId, - sender_id: '', - sender_role: 'doctor', - content_type: 'text', + sender_id: "", + sender_role: "doctor", + content_type: "text", content: text, is_read: false, created_at: new Date().toISOString(), }; setMessages((prev) => [...prev, optimisticMsg]); - setInputText(''); + setInputText(""); shouldScrollRef.current = true; await consultationApi.createMessage({ session_id: sessionId, - content_type: 'text', + content_type: "text", content: text, }); // Refresh to replace optimistic message with server version await fetchMessages(msgPage, false); } catch { - message.error('发送失败'); + message.error("发送失败"); } finally { setSending(false); } @@ -200,20 +209,20 @@ export default function ConsultationDetail() { version: session.version, }); setSession(updated); - message.success('会话已关闭'); + message.success("会话已关闭"); } catch { - message.error('关闭会话失败'); + message.error("关闭会话失败"); } }; // --- Render a single message bubble --- const renderMessage = (msg: Message) => { - const align = ROLE_ALIGN[msg.sender_role] ?? 'flex-start'; + const align = ROLE_ALIGN[msg.sender_role] ?? "flex-start"; // System messages: centered plain text - if (msg.sender_role === 'system') { + if (msg.sender_role === "system") { return ( -
+
{msg.content} @@ -221,31 +230,32 @@ export default function ConsultationDetail() { ); } - const isImage = msg.content_type === 'image'; + const isImage = msg.content_type === "image"; return (
-
+
{isImage ? ( ) : (
- + {msg.content}
@@ -261,34 +271,34 @@ export default function ConsultationDetail() { // --- Full render --- if (sessionLoading && messages.length === 0) { return ( -
+
); } - const isClosed = session?.status === 'closed'; + const isClosed = session?.status === "closed"; return (
{/* Top bar */}
@@ -298,10 +308,16 @@ export default function ConsultationDetail() { {session && ( <> - 患者: + 患者:{" "} + - 医护: + 医护:{" "} + @@ -323,7 +339,7 @@ export default function ConsultationDetail() { size="small" danger icon={} - style={{ marginLeft: 'auto' }} + style={{ marginLeft: "auto" }} > 关闭会话 @@ -336,13 +352,13 @@ export default function ConsultationDetail() {
{hasMore && ( -
+
+ + setInputText(e.target.value)} + placeholder={isClosed ? "会话已关闭" : "输入消息..."} + autoSize={{ minRows: 1, maxRows: 4 }} + style={{ flex: 1, borderRadius: 8 }} + onPressEnter={(e) => { + if (!e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + disabled={isClosed} + /> + +
); diff --git a/apps/web/src/pages/health/ConsultationList.tsx b/apps/web/src/pages/health/ConsultationList.tsx index d8d485b..92ce622 100644 --- a/apps/web/src/pages/health/ConsultationList.tsx +++ b/apps/web/src/pages/health/ConsultationList.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback } from "react"; import { Table, Select, @@ -9,38 +9,50 @@ import { Popconfirm, message, DatePicker, -} from 'antd'; -import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons'; -import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { consultationApi, type Session, type CreateSessionReq } from '../../api/health/consultations'; -import { StatusTag } from './components/StatusTag'; -import { PatientSelect } from './components/PatientSelect'; -import { DoctorSelect } from './components/DoctorSelect'; -import { ExportButton } from './components/ExportButton'; -import { AuthButton } from '../../components/AuthButton'; -import { PageContainer } from '../../components/PageContainer'; -import { EntityName } from '../../components/EntityName'; -import { formatDateTime } from '../../utils/format'; -import { usePaginatedData } from '../../hooks/usePaginatedData'; -import { useDictionary } from '../../hooks/useDictionary'; +} from "antd"; +import { PlusOutlined, CloseCircleOutlined } from "@ant-design/icons"; +import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { + consultationApi, + type Session, + type CreateSessionReq, +} from "../../api/health/consultations"; +import { StatusTag } from "./components/StatusTag"; +import { PatientSelect } from "./components/PatientSelect"; +import { DoctorSelect } from "./components/DoctorSelect"; +import { ExportButton } from "./components/ExportButton"; +import { AuthButton } from "../../components/AuthButton"; +import { PageContainer } from "../../components/PageContainer"; +import { EntityName } from "../../components/EntityName"; +import { formatDateTime } from "../../utils/format"; +import { usePaginatedData } from "../../hooks/usePaginatedData"; +import { useDictionary } from "../../hooks/useDictionary"; const STATUS_OPTIONS = [ - { value: 'waiting', label: '等待中' }, - { value: 'active', label: '进行中' }, - { value: 'closed', label: '已关闭' }, + { value: "waiting", label: "等待中" }, + { value: "active", label: "进行中" }, + { value: "closed", label: "已关闭" }, ]; const CONSULTATION_TYPE_FALLBACK = [ - { value: 'customer_service', label: '客服咨询' }, - { value: 'medical', label: '医疗咨询' }, - { value: 'health_consultation', label: '健康咨询' }, + { value: "customer_service", label: "客服咨询" }, + { value: "medical", label: "医疗咨询" }, + { value: "health_consultation", label: "健康咨询" }, + { value: "online", label: "在线咨询" }, + { value: "phone", label: "电话咨询" }, + { value: "doctor", label: "医生咨询" }, + { value: "follow_up", label: "随访咨询" }, ]; const CONSULTATION_TYPE_MAP: Record = { - customer_service: '客服咨询', - medical: '医疗咨询', - health_consultation: '健康咨询', + customer_service: "客服咨询", + medical: "医疗咨询", + health_consultation: "健康咨询", + online: "在线咨询", + phone: "电话咨询", + doctor: "医生咨询", + follow_up: "随访咨询", }; interface ConsultationFilters { @@ -49,10 +61,13 @@ interface ConsultationFilters { } export default function ConsultationList() { - const { options: CONSULTATION_TYPE_OPTIONS } = useDictionary('health_consultation_type', CONSULTATION_TYPE_FALLBACK); + const { options: CONSULTATION_TYPE_OPTIONS } = useDictionary( + "health_consultation_type", + CONSULTATION_TYPE_FALLBACK, + ); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const urlPatientId = searchParams.get('patient_id'); + const urlPatientId = searchParams.get("patient_id"); // Close session const [closingId, setClosingId] = useState(null); @@ -72,7 +87,9 @@ export default function ConsultationList() { params.created_start = filters.dateRange[0]; params.created_end = filters.dateRange[1]; } - return consultationApi.listSessions(params as Parameters[0]); + return consultationApi.listSessions( + params as Parameters[0], + ); }, [urlPatientId], ); @@ -101,13 +118,13 @@ export default function ConsultationList() { const values = await createForm.validateFields(); setCreateLoading(true); await consultationApi.createSession(values); - message.success('咨询会话创建成功'); + message.success("咨询会话创建成功"); setCreateOpen(false); createForm.resetFields(); refresh(page); } catch (err: unknown) { - if (err && typeof err === 'object' && 'errorFields' in err) return; - message.error('创建咨询会话失败'); + if (err && typeof err === "object" && "errorFields" in err) return; + message.error("创建咨询会话失败"); } finally { setCreateLoading(false); } @@ -117,11 +134,13 @@ export default function ConsultationList() { const handleClose = async (session: Session) => { setClosingId(session.id); try { - await consultationApi.closeSession(session.id, { version: session.version }); - message.success('会话已关闭'); + await consultationApi.closeSession(session.id, { + version: session.version, + }); + message.success("会话已关闭"); refresh(page); } catch { - message.error('关闭会话失败'); + message.error("关闭会话失败"); } finally { setClosingId(null); } @@ -139,40 +158,44 @@ export default function ConsultationList() { // --- Columns --- const columns: ColumnsType = [ { - title: '患者', - dataIndex: 'patient_name', - key: 'patient_name', + title: "患者", + dataIndex: "patient_name", + key: "patient_name", width: 140, render: (_: unknown, record: Session) => ( ), }, { - title: '医护', - dataIndex: 'doctor_name', - key: 'doctor_name', + title: "医护", + dataIndex: "doctor_name", + key: "doctor_name", width: 140, render: (_: unknown, record: Session) => ( - + ), }, { - title: '咨询类型', - dataIndex: 'consultation_type', - key: 'consultation_type', + title: "咨询类型", + dataIndex: "consultation_type", + key: "consultation_type", width: 110, render: (v: string) => CONSULTATION_TYPE_MAP[v] || v, }, { - title: '状态', - dataIndex: 'status', - key: 'status', + title: "状态", + dataIndex: "status", + key: "status", width: 100, render: (status: string) => , }, { - title: '未读(患者/医护)', - key: 'unread', + title: "未读(患者/医护)", + key: "unread", width: 140, render: (_: unknown, record: Session) => ( @@ -181,27 +204,27 @@ export default function ConsultationList() { ), }, { - title: '最后消息时间', - dataIndex: 'last_message_at', - key: 'last_message_at', + title: "最后消息时间", + dataIndex: "last_message_at", + key: "last_message_at", width: 160, render: (v: string | undefined) => formatDateTime(v), }, { - title: '创建时间', - dataIndex: 'created_at', - key: 'created_at', + title: "创建时间", + dataIndex: "created_at", + key: "created_at", width: 160, render: (v: string) => formatDateTime(v), }, { - title: '操作', - key: 'actions', + title: "操作", + key: "actions", width: 120, render: (_: unknown, record: Session) => ( - {record.status !== 'closed' && ( + {record.status !== "closed" && ( handleClose(record)} @@ -237,7 +260,9 @@ export default function ConsultationList() { style={{ width: 140 }} options={STATUS_OPTIONS} value={filters.status} - onChange={(value) => setFilters((prev) => ({ ...prev, status: value }))} + onChange={(value) => + setFilters((prev) => ({ ...prev, status: value })) + } /> ({ ...prev, - dateRange: [dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')], + dateRange: [ + dates[0]!.format("YYYY-MM-DD"), + dates[1]!.format("YYYY-MM-DD"), + ], })); } else { setFilters((prev) => ({ ...prev, dateRange: undefined })); @@ -285,7 +313,7 @@ export default function ConsultationList() { onChange={handleTableChange} onRow={(record) => ({ onClick: () => handleRowClick(record), - style: { cursor: 'pointer' }, + style: { cursor: "pointer" }, })} pagination={{ current: page, @@ -312,7 +340,7 @@ export default function ConsultationList() { diff --git a/apps/web/src/pages/health/components/AlertDetailPanel.tsx b/apps/web/src/pages/health/components/AlertDetailPanel.tsx index 3ff4dff..a854879 100644 --- a/apps/web/src/pages/health/components/AlertDetailPanel.tsx +++ b/apps/web/src/pages/health/components/AlertDetailPanel.tsx @@ -1,55 +1,87 @@ -import { Descriptions, Tag, Typography, Space, Button, Popconfirm, Tooltip } from 'antd'; +import { + Descriptions, + Tag, + Typography, + Space, + Button, + Popconfirm, + Tooltip, +} from "antd"; import { CheckOutlined, StopOutlined, SafetyCertificateOutlined, ClockCircleOutlined, ExclamationCircleOutlined, -} from '@ant-design/icons'; -import type { Alert } from '../../../api/health/alerts'; +} from "@ant-design/icons"; +import type { Alert } from "../../../api/health/alerts"; +import { translateAlertTitle } from "../../../constants/health"; -const SEVERITY_CONFIG: Record = { - info: { color: 'default', label: '提示', icon: }, - warning: { color: 'orange', label: '警告', icon: }, - critical: { color: 'red', label: '严重', icon: }, - urgent: { color: 'magenta', label: '紧急', icon: }, +const SEVERITY_CONFIG: Record< + string, + { color: string; label: string; icon: React.ReactNode } +> = { + info: { + color: "default", + label: "提示", + icon: , + }, + warning: { + color: "orange", + label: "警告", + icon: , + }, + critical: { + color: "red", + label: "严重", + icon: , + }, + urgent: { + color: "magenta", + label: "紧急", + icon: , + }, }; const STATUS_CONFIG: Record = { - pending: { color: 'orange', label: '待处理' }, - active: { color: 'orange', label: '待处理' }, - acknowledged: { color: 'blue', label: '已确认' }, - resolved: { color: 'green', label: '已恢复' }, - dismissed: { color: 'default', label: '已忽略' }, + pending: { color: "orange", label: "待处理" }, + active: { color: "orange", label: "待处理" }, + acknowledged: { color: "blue", label: "已确认" }, + resolved: { color: "green", label: "已恢复" }, + dismissed: { color: "default", label: "已忽略" }, }; const DETAIL_LABEL_MAP: Record = { - message: '告警描述', - value: '监测值', - threshold: '阈值', - unit: '单位', - metric: '指标', - metric_name: '指标名称', - indicator_type: '体征类型', - recorded_at: '记录时间', - blood_pressure_systolic: '收缩压', - blood_pressure_diastolic: '舒张压', - heart_rate: '心率', - blood_glucose: '血糖', - temperature: '体温', - spo2: '血氧饱和度', + message: "告警描述", + value: "监测值", + threshold: "阈值", + unit: "单位", + metric: "指标", + metric_name: "指标名称", + indicator_type: "体征类型", + recorded_at: "记录时间", + blood_pressure_systolic: "收缩压", + blood_pressure_diastolic: "舒张压", + heart_rate: "心率", + blood_glucose: "血糖", + temperature: "体温", + spo2: "血氧饱和度", }; function formatDetailValue(key: string, value: unknown): string { - if (value === null || value === undefined) return '-'; - if (typeof value === 'string') { - if (key.endsWith('_at') || key === 'recorded_at') { - try { return new Date(value).toLocaleString('zh-CN'); } catch { return value; } + if (value === null || value === undefined) return "-"; + if (typeof value === "string") { + if (key.endsWith("_at") || key === "recorded_at") { + try { + return new Date(value).toLocaleString("zh-CN"); + } catch { + return value; + } } return value; } - if (typeof value === 'number') return String(value); - if (typeof value === 'boolean') return value ? '是' : '否'; + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return value ? "是" : "否"; return JSON.stringify(value); } @@ -73,11 +105,13 @@ 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' || alert.status === 'active'; - const isAcknowledged = alert.status === 'acknowledged'; + const isPending = alert.status === "pending" || alert.status === "active"; + const isAcknowledged = alert.status === "acknowledged"; const detailEntries = alert.detail - ? Object.entries(alert.detail).filter(([, v]) => v !== null && v !== undefined) + ? Object.entries(alert.detail).filter( + ([, v]) => v !== null && v !== undefined, + ) : []; return ( @@ -85,13 +119,17 @@ export function AlertDetailPanel({ {/* 顶部摘要 */}
- + {severity.label} {status.label} - {new Date(alert.created_at).toLocaleString('zh-CN')} + {new Date(alert.created_at).toLocaleString("zh-CN")}
@@ -99,17 +137,29 @@ export function AlertDetailPanel({ {/* 详情 */} - {alert.patient_name || '未知患者'} + + {alert.patient_name || "未知患者"} + - + {alert.patient_id.slice(0, 8)}... - {alert.title || '未知规则'} + + {translateAlertTitle(alert.title) || "未知规则"} + - + {alert.rule_id.slice(0, 8)}... @@ -122,24 +172,32 @@ export function AlertDetailPanel({ {alert.acknowledged_by && ( - {alert.acknowledged_by} + + {alert.acknowledged_by} + )} {alert.acknowledged_at && ( - {new Date(alert.acknowledged_at).toLocaleString('zh-CN')} + {new Date(alert.acknowledged_at).toLocaleString("zh-CN")} )} {alert.resolved_at && ( - {new Date(alert.resolved_at).toLocaleString('zh-CN')} + {new Date(alert.resolved_at).toLocaleString("zh-CN")} )} {/* 告警详情 */} {detailEntries.length > 0 && ( - + {detailEntries.map(([key, value]) => ( @@ -151,7 +209,13 @@ export function AlertDetailPanel({ )} {/* 操作按钮 */} -
+
{isPending && onAcknowledge && ( diff --git a/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx b/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx index 13a2a78..626a9b9 100644 --- a/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx +++ b/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx @@ -1,58 +1,125 @@ -import { useEffect, useState, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAuthStore } from '../../../../stores/auth'; -import { listAuditLogs, type AuditLogItem } from '../../../../api/auditLogs'; -import { useStatsData } from '../../StatisticsDashboard/useStatsData'; +import { useEffect, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuthStore } from "../../../../stores/auth"; +import { listAuditLogs, type AuditLogItem } from "../../../../api/auditLogs"; +import { EntityName } from "../../../../components/EntityName"; +import { useStatsData } from "../../StatisticsDashboard/useStatsData"; import { dashboardApi, type SystemHealthResp, type UserActivityResp, type ModuleStatusResp, -} from '../../../../api/health/dashboard'; +} from "../../../../api/health/dashboard"; function formatTimeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const minutes = Math.floor(diff / 60000); - if (minutes < 1) return '刚刚'; + if (minutes < 1) return "刚刚"; if (minutes < 60) return `${minutes} 分钟前`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} 小时前`; return `${Math.floor(hours / 24)} 天前`; } -const ACTION_ICONS: Record = { - create: { icon: '✓', bg: '#F0FDF4', color: '#16A34A' }, - created: { icon: '✓', bg: '#F0FDF4', color: '#16A34A' }, - update: { icon: '⚙', bg: '#FFFBEB', color: '#D97706' }, - updated: { icon: '⚙', bg: '#FFFBEB', color: '#D97706' }, - delete: { icon: '✕', bg: '#FEF2F2', color: '#DC2626' }, - deleted: { icon: '✕', bg: '#FEF2F2', color: '#DC2626' }, - login: { icon: '👤', bg: '#EFF6FF', color: '#2563EB' }, - 'user.create': { icon: '✓', bg: '#F0FDF4', color: '#16A34A' }, - 'user.update': { icon: '⚙', bg: '#FFFBEB', color: '#D97706' }, - 'user.delete': { icon: '✕', bg: '#FEF2F2', color: '#DC2626' }, +const ACTION_ICONS: Record< + string, + { icon: string; bg: string; color: string } +> = { + create: { icon: "✓", bg: "#F0FDF4", color: "#16A34A" }, + created: { icon: "✓", bg: "#F0FDF4", color: "#16A34A" }, + update: { icon: "⚙", bg: "#FFFBEB", color: "#D97706" }, + updated: { icon: "⚙", bg: "#FFFBEB", color: "#D97706" }, + delete: { icon: "✕", bg: "#FEF2F2", color: "#DC2626" }, + deleted: { icon: "✕", bg: "#FEF2F2", color: "#DC2626" }, + login: { icon: "👤", bg: "#EFF6FF", color: "#2563EB" }, + "user.create": { icon: "✓", bg: "#F0FDF4", color: "#16A34A" }, + "user.update": { icon: "⚙", bg: "#FFFBEB", color: "#D97706" }, + "user.delete": { icon: "✕", bg: "#FEF2F2", color: "#DC2626" }, }; const ACTION_LABELS: Record = { - create: '创建', created: '创建', update: '更新', updated: '更新', - delete: '删除', deleted: '删除', login: '登录', 'user.create': '创建', - 'user.update': '更新', 'user.delete': '删除', + create: "创建", + created: "创建", + update: "更新", + updated: "更新", + delete: "删除", + deleted: "删除", + login: "登录", + "user.create": "创建", + "user.update": "更新", + "user.delete": "删除", }; const RESOURCE_LABELS: Record = { - user: '用户', role: '角色', patient: '患者', doctor: '医护', - appointment: '预约', follow_up_task: '随访', consultation_session: '咨询', - message: '消息', plugin: '插件', process_instance: '流程实例', organization: '组织', + user: "用户", + role: "角色", + patient: "患者", + doctor: "医护", + appointment: "预约", + follow_up_task: "随访", + consultation_session: "咨询", + message: "消息", + plugin: "插件", + process_instance: "流程实例", + organization: "组织", }; const QUICK_ACTIONS = [ - { icon: '👤', bg: '#EFF6FF', color: '#2563EB', text: '用户管理', path: '/users' }, - { icon: '🔑', bg: '#F5F3FF', color: '#7C3AED', text: '角色权限', path: '/roles' }, - { icon: '⚙', bg: '#FFFBEB', color: '#D97706', text: '系统配置', path: '/settings' }, - { icon: '📋', bg: '#FEF2F2', color: '#DC2626', text: '审计日志', path: '/audit-logs' }, - { icon: '🧩', bg: '#F0FDF4', color: '#16A34A', text: '插件管理', path: '/plugins' }, - { icon: '📖', bg: '#F0F9FF', color: '#0284C7', text: '菜单管理', path: '/menus' }, - { icon: '📊', bg: '#FFF1F2', color: '#E11D48', text: '数据字典', path: '/dictionaries' }, - { icon: '🔔', bg: '#F8FAFC', color: '#475569', text: '消息管理', path: '/messages' }, + { + icon: "👤", + bg: "#EFF6FF", + color: "#2563EB", + text: "用户管理", + path: "/users", + }, + { + icon: "🔑", + bg: "#F5F3FF", + color: "#7C3AED", + text: "角色权限", + path: "/roles", + }, + { + icon: "⚙", + bg: "#FFFBEB", + color: "#D97706", + text: "系统配置", + path: "/settings", + }, + { + icon: "📋", + bg: "#FEF2F2", + color: "#DC2626", + text: "审计日志", + path: "/audit-logs", + }, + { + icon: "🧩", + bg: "#F0FDF4", + color: "#16A34A", + text: "插件管理", + path: "/plugins", + }, + { + icon: "📖", + bg: "#F0F9FF", + color: "#0284C7", + text: "菜单管理", + path: "/menus", + }, + { + icon: "📊", + bg: "#FFF1F2", + color: "#E11D48", + text: "数据字典", + path: "/dictionaries", + }, + { + icon: "🔔", + bg: "#F8FAFC", + color: "#475569", + text: "消息管理", + path: "/messages", + }, ]; export default function AdminDashboard() { @@ -60,142 +127,367 @@ export default function AdminDashboard() { const user = useAuthStore((s) => s.user); const statsData = useStatsData(); const [auditLogs, setAuditLogs] = useState([]); - const [systemHealth, setSystemHealth] = useState(null); - const [userActivity, setUserActivity] = useState(null); + const [systemHealth, setSystemHealth] = useState( + null, + ); + const [userActivity, setUserActivity] = useState( + null, + ); const [modules, setModules] = useState([]); const fetchData = useCallback(async () => { - const [auditResult, healthResult, activityResult, modulesResult] = await Promise.allSettled([ - listAuditLogs({ page: 1, page_size: 6 }), - dashboardApi.getSystemHealth(), - dashboardApi.getUserActivity(), - dashboardApi.getModuleStatus(), - ]); + const [auditResult, healthResult, activityResult, modulesResult] = + await Promise.allSettled([ + listAuditLogs({ page: 1, page_size: 6 }), + dashboardApi.getSystemHealth(), + dashboardApi.getUserActivity(), + dashboardApi.getModuleStatus(), + ]); - if (auditResult.status === 'fulfilled') { - setAuditLogs(auditResult.value.data.filter((a) => a.action !== 'login_failed')); + if (auditResult.status === "fulfilled") { + setAuditLogs( + auditResult.value.data.filter((a) => a.action !== "login_failed"), + ); } - if (healthResult.status === 'fulfilled') setSystemHealth(healthResult.value); - if (activityResult.status === 'fulfilled') setUserActivity(activityResult.value); - if (modulesResult.status === 'fulfilled') setModules(modulesResult.value); + if (healthResult.status === "fulfilled") + setSystemHealth(healthResult.value); + if (activityResult.status === "fulfilled") + setUserActivity(activityResult.value); + if (modulesResult.status === "fulfilled") setModules(modulesResult.value); }, []); - useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + fetchData(); + }, [fetchData]); - const firstName = user?.display_name ?? user?.username ?? '管理员'; + const firstName = user?.display_name ?? user?.username ?? "管理员"; const now = new Date(); - const greeting = now.getHours() < 12 ? '早上好' : now.getHours() < 18 ? '下午好' : '晚上好'; - const activeModules = modules.length > 0 ? modules.filter((m) => m.active).length : 0; + const greeting = + now.getHours() < 12 ? "早上好" : now.getHours() < 18 ? "下午好" : "晚上好"; + const activeModules = + modules.length > 0 ? modules.filter((m) => m.active).length : 0; const totalModules = modules.length || 8; const statCards = [ - { label: '注册用户', value: userActivity?.total_registered ?? statsData.patientStats?.total_patients ?? 0, color: '#2563EB', gradient: 'linear-gradient(90deg,#2563EB,#60A5FA)', sub: `今日活跃 ${userActivity?.daily_active ?? 0}` }, - { label: '业务模块', value: `${activeModules} / ${totalModules}`, color: '#7C3AED', gradient: 'linear-gradient(90deg,#7C3AED,#A78BFA)', sub: totalModules > 0 ? `${totalModules - activeModules} 个插件待启用` : '加载中...' }, - { label: '今日操作', value: userActivity?.daily_active ?? 0, color: '#16A34A', gradient: 'linear-gradient(90deg,#16A34A,#4ADE80)', sub: `近 ${auditLogs.length} 条记录` }, - { label: '本周活跃', value: userActivity?.weekly_active ?? 0, color: '#EA580C', gradient: 'linear-gradient(90deg,#EA580C,#FB923C)', sub: `月活 ${userActivity?.monthly_active ?? 0}` }, + { + label: "注册用户", + value: + userActivity?.total_registered ?? + statsData.patientStats?.total_patients ?? + 0, + color: "#2563EB", + gradient: "linear-gradient(90deg,#2563EB,#60A5FA)", + sub: `今日活跃 ${userActivity?.daily_active ?? 0}`, + }, + { + label: "业务模块", + value: `${activeModules} / ${totalModules}`, + color: "#7C3AED", + gradient: "linear-gradient(90deg,#7C3AED,#A78BFA)", + sub: + totalModules > 0 + ? `${totalModules - activeModules} 个插件待启用` + : "加载中...", + }, + { + label: "今日操作", + value: userActivity?.daily_active ?? 0, + color: "#16A34A", + gradient: "linear-gradient(90deg,#16A34A,#4ADE80)", + sub: `近 ${auditLogs.length} 条记录`, + }, + { + label: "本周活跃", + value: userActivity?.weekly_active ?? 0, + color: "#EA580C", + gradient: "linear-gradient(90deg,#EA580C,#FB923C)", + sub: `月活 ${userActivity?.monthly_active ?? 0}`, + }, ]; const healthServices = systemHealth?.services ?? [ - { name: 'API 服务', status: 'unknown' as const, message: '数据加载中...', response_ms: null }, - { name: '数据库', status: 'unknown' as const, message: '数据加载中...', response_ms: null }, - { name: '定时任务', status: 'unknown' as const, message: '数据加载中...', response_ms: null }, + { + name: "API 服务", + status: "unknown" as const, + message: "数据加载中...", + response_ms: null, + }, + { + name: "数据库", + status: "unknown" as const, + message: "数据加载中...", + response_ms: null, + }, + { + name: "定时任务", + status: "unknown" as const, + message: "数据加载中...", + response_ms: null, + }, ]; const userActivityItems = [ - { label: '今日活跃', value: userActivity?.daily_active ?? 0, pct: userActivity ? Math.round((userActivity.daily_active / Math.max(userActivity.total_registered, 1)) * 100) : 0, color: '#2563EB' }, - { label: '本周活跃', value: userActivity?.weekly_active ?? 0, pct: userActivity ? Math.round((userActivity.weekly_active / Math.max(userActivity.total_registered, 1)) * 100) : 0, color: '#7C3AED' }, - { label: '本月活跃', value: userActivity?.monthly_active ?? 0, pct: userActivity ? Math.round((userActivity.monthly_active / Math.max(userActivity.total_registered, 1)) * 100) : 0, color: '#16A34A' }, - { label: '总注册', value: userActivity?.total_registered ?? statsData.patientStats?.total_patients ?? 0, pct: 100, color: '#94A3B8' }, + { + label: "今日活跃", + value: userActivity?.daily_active ?? 0, + pct: userActivity + ? Math.round( + (userActivity.daily_active / + Math.max(userActivity.total_registered, 1)) * + 100, + ) + : 0, + color: "#2563EB", + }, + { + label: "本周活跃", + value: userActivity?.weekly_active ?? 0, + pct: userActivity + ? Math.round( + (userActivity.weekly_active / + Math.max(userActivity.total_registered, 1)) * + 100, + ) + : 0, + color: "#7C3AED", + }, + { + label: "本月活跃", + value: userActivity?.monthly_active ?? 0, + pct: userActivity + ? Math.round( + (userActivity.monthly_active / + Math.max(userActivity.total_registered, 1)) * + 100, + ) + : 0, + color: "#16A34A", + }, + { + label: "总注册", + value: + userActivity?.total_registered ?? + statsData.patientStats?.total_patients ?? + 0, + pct: 100, + color: "#94A3B8", + }, ]; return (
{/* 欢迎栏 */}
-

+

{greeting},{firstName.charAt(0)}主任

-

+

平台运行正常 · {activeModules} 个模块已激活 · 今日数据概览

{/* 系统健康条 */} -
+
{healthServices.map((item, i) => ( -
-
- {item.name} {item.message} +
+
+ + {item.name} + {" "} + {item.message}
))}
{/* 统计卡片 */} -
+
{statCards.map((card) => ( -
+
-
-
{card.label}
-
{card.value}
-
{card.sub}
+
+
+ {card.label} +
+
+ {card.value} +
+
+ {card.sub} +
))}
{/* 双栏:审计日志 + 模块状态 */} -
+
{/* 最近审计日志 */} -
-
+
+

最近操作记录

- navigate('/audit-logs')}>审计日志 → + navigate("/audit-logs")} + > + 审计日志 → +
{auditLogs.length === 0 ? ( -
暂无操作记录
+
+ 暂无操作记录 +
) : ( auditLogs.map((log) => { - const actionKey = log.action.split('.').pop() ?? log.action; - const iconCfg = ACTION_ICONS[log.action] ?? ACTION_ICONS[actionKey] ?? { icon: '📋', bg: '#F0F9FF', color: '#0284C7' }; - const actionLabel = ACTION_LABELS[log.action] ?? ACTION_LABELS[actionKey] ?? log.action; - const resourceLabel = RESOURCE_LABELS[log.resource_type] ?? RESOURCE_LABELS[log.resource_type.split('.').pop() ?? ''] ?? log.resource_type; + const actionKey = log.action.split(".").pop() ?? log.action; + const iconCfg = ACTION_ICONS[log.action] ?? + ACTION_ICONS[actionKey] ?? { + icon: "📋", + bg: "#F0F9FF", + color: "#0284C7", + }; + const actionLabel = + ACTION_LABELS[log.action] ?? + ACTION_LABELS[actionKey] ?? + log.action; + const resourceLabel = + RESOURCE_LABELS[log.resource_type] ?? + RESOURCE_LABELS[log.resource_type.split(".").pop() ?? ""] ?? + log.resource_type; return ( -
{ e.currentTarget.style.background = '#F8FAFC'; }} - onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} +
{ + e.currentTarget.style.background = "#F8FAFC"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = "transparent"; + }} > -
{iconCfg.icon}
- {log.user_id ? log.user_id.slice(0, 6) : '系统'} - +
+ {iconCfg.icon} +
+ + {log.user_id ? ( + + ) : ( + "系统" + )} + + {actionLabel}了{resourceLabel} - {formatTimeAgo(log.created_at)} + + {formatTimeAgo(log.created_at)} +
); }) @@ -203,94 +495,231 @@ export default function AdminDashboard() {
{/* 模块状态 */} -
-
+
+

模块状态

- navigate('/plugins')}>模块管理 → + navigate("/plugins")} + > + 模块管理 → +
{(modules.length > 0 ? modules : []).map((mod) => ( -
+
-
{mod.display_name}
-
{mod.description}
+
+ {mod.display_name} +
+
+ {mod.description} +
- {mod.active ? '运行中' : '未启用'} + + {mod.active ? "运行中" : "未启用"} +
))}
{/* 双栏:用户活跃度 + 快捷管理 */} -
+
{/* 用户活跃度 */} -
-
+
+

用户活跃度

- navigate('/users')}>用户管理 → + navigate("/users")} + > + 用户管理 → +
{userActivityItems.map((item) => ( -
- {item.label} -
-
+
+ + {item.label} + +
+
- {item.value} + + {item.value} +
))} -
-
按角色分布
-
+
+
按角色分布
+
{userActivity?.by_role.map((r) => ( - {r.role} {r.count} - )) ?? 加载中...} + + {r.role} {r.count} + + )) ?? 加载中...}
{/* 快捷管理入口 */} -
-
+
+

系统管理

-
+
{QUICK_ACTIONS.map((item) => ( -
navigate(item.path)} - onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#2563EB'; e.currentTarget.style.background = '#F8FAFC'; }} - onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#E2E8F0'; e.currentTarget.style.background = 'transparent'; }} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = "#2563EB"; + e.currentTarget.style.background = "#F8FAFC"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = "#E2E8F0"; + e.currentTarget.style.background = "transparent"; + }} > -
{item.icon}
- {item.text} +
+ {item.icon} +
+ + {item.text} +
))}
diff --git a/apps/web/src/routeConfig.ts b/apps/web/src/routeConfig.ts index d4baacd..fb93012 100644 --- a/apps/web/src/routeConfig.ts +++ b/apps/web/src/routeConfig.ts @@ -21,74 +21,209 @@ interface RoutePermissionEntry { const ENTRIES: RoutePermissionEntry[] = [ // ===== 基础模块 ===== - { path: '/users', permissions: ['user.list', 'user.update'] }, - { path: '/roles', permissions: ['role.list', 'role.update'] }, - { path: '/organizations', permissions: ['organization.list', 'organization.update'] }, - { path: '/workflow', permissions: ['workflow.list', 'workflow.read'] }, - { path: '/messages', permissions: ['message.list'] }, - { path: '/settings', permissions: ['setting.read', 'setting.update'] }, + { path: "/users", permissions: ["user.list", "user.update"] }, + { path: "/roles", permissions: ["role.list", "role.update"] }, + { + path: "/organizations", + permissions: ["organization.list", "organization.update"], + }, + { path: "/workflow", permissions: ["workflow.list", "workflow.read"] }, + { path: "/messages", permissions: ["message.list"] }, + { path: "/settings", permissions: ["setting.read", "setting.update"] }, // ===== 插件模块(精确路径优先于前缀通配) ===== - { path: '/plugins/admin', permissions: ['plugin.admin'] }, - { path: '/plugins/market', permissions: ['plugin.admin'] }, + { path: "/plugins/admin", permissions: ["plugin.admin"] }, + { path: "/plugins/market", permissions: ["plugin.admin"] }, // 动态路由 catch-all: /plugins/:pluginId/:entityName 等 - { path: '/plugins', permissions: ['plugin.list', 'plugin.admin'] }, + { path: "/plugins", permissions: ["plugin.list", "plugin.admin"] }, // ===== 健康管理 — 患者与医生 ===== - { path: '/health/patients', permissions: ['health.patient.list', 'health.patient.manage'] }, - { path: '/health/tags', permissions: ['health.patient.list', 'health.patient.manage'] }, - { path: '/health/doctors', permissions: ['health.doctor.list', 'health.doctor.manage'] }, - { path: '/health/appointments', permissions: ['health.appointment.list', 'health.appointment.manage'] }, + { + path: "/health/patients", + permissions: ["health.patient.list", "health.patient.manage"], + }, + { + path: "/health/tags", + permissions: ["health.patient.list", "health.patient.manage"], + }, + { + path: "/health/doctors", + permissions: ["health.doctor.list", "health.doctor.manage"], + }, + { + path: "/health/appointments", + permissions: ["health.appointment.list", "health.appointment.manage"], + }, // ===== 健康管理 — 随访与咨询 ===== - { path: '/health/follow-up-tasks', permissions: ['health.follow-up.list', 'health.follow-up.manage'] }, - { path: '/health/follow-up-records', permissions: ['health.follow-up.list', 'health.follow-up.manage'] }, - { path: '/health/follow-up-templates', permissions: ['health.follow-up-templates.list', 'health.follow-up-templates.manage'] }, - { path: '/health/consultations', permissions: ['health.consultation.list', 'health.consultation.manage'] }, - { path: '/health/action-inbox', permissions: ['health.action-inbox.list', 'health.action-inbox.manage'] }, + { + path: "/health/follow-up-tasks", + permissions: ["health.follow-up.list", "health.follow-up.manage"], + }, + { + path: "/health/follow-up-records", + permissions: ["health.follow-up.list", "health.follow-up.manage"], + }, + { + path: "/health/follow-up-templates", + permissions: [ + "health.follow-up-templates.list", + "health.follow-up-templates.manage", + ], + }, + { + path: "/health/consultations", + permissions: ["health.consultation.list", "health.consultation.manage"], + }, + { + path: "/health/action-inbox", + permissions: ["health.action-inbox.list", "health.action-inbox.manage"], + }, // ===== 健康管理 — 告警与设备 ===== - { path: '/health/alerts', permissions: ['health.alerts.list', 'health.alerts.manage'] }, - { path: '/health/alert-dashboard', permissions: ['health.alerts.list', 'health.alerts.manage'] }, - { path: '/health/alert-rules', permissions: ['health.alert-rules.list', 'health.alert-rules.manage'] }, - { path: '/health/devices', permissions: ['health.devices.list', 'health.devices.manage'] }, - { path: '/health/realtime-monitor', permissions: ['health.device-readings.list', 'health.device-readings.manage'] }, - { path: '/health/ble-gateways', permissions: ['health.ble-gateways.list', 'health.ble-gateways.manage'] }, - { path: '/health/critical-value-thresholds', permissions: ['health.critical-value-thresholds.list', 'health.critical-value-thresholds.manage'] }, - { path: '/health/daily-monitoring', permissions: ['health.device-readings.list', 'health.device-readings.manage'] }, + { + path: "/health/alerts", + permissions: ["health.alerts.list", "health.alerts.manage"], + }, + { + path: "/health/alert-dashboard", + permissions: ["health.alerts.list", "health.alerts.manage"], + }, + { + path: "/health/alert-rules", + permissions: ["health.alert-rules.list", "health.alert-rules.manage"], + }, + { + path: "/health/devices", + permissions: ["health.devices.list", "health.devices.manage"], + }, + { + path: "/health/realtime-monitor", + permissions: [ + "health.device-readings.list", + "health.device-readings.manage", + ], + }, + { + path: "/health/ble-gateways", + permissions: ["health.ble-gateways.list", "health.ble-gateways.manage"], + }, + { + path: "/health/critical-value-thresholds", + permissions: [ + "health.critical-value-thresholds.list", + "health.critical-value-thresholds.manage", + ], + }, + { + path: "/health/daily-monitoring", + permissions: [ + "health.daily-monitoring.list", + "health.daily-monitoring.manage", + ], + }, // ===== 健康管理 — 诊断与知情同意 ===== - { path: '/health/diagnoses', permissions: ['health.health-data.list', 'health.health-data.manage'] }, - { path: '/health/consents', permissions: ['health.consent.list', 'health.consent.manage'] }, + { + path: "/health/diagnoses", + permissions: ["health.health-data.list", "health.health-data.manage"], + }, + { + path: "/health/consents", + permissions: ["health.consent.list", "health.consent.manage"], + }, // ===== 健康管理 — AI 模块 ===== - { path: '/health/ai-prompts', permissions: ['ai.prompt.list', 'ai.prompt.manage'] }, - { path: '/health/ai-analysis', permissions: ['ai.analysis.list', 'ai.analysis.manage'] }, - { path: '/health/ai-usage', permissions: ['ai.usage.list'] }, + { + path: "/health/ai-prompts", + permissions: ["ai.prompt.list", "ai.prompt.manage"], + }, + { + path: "/health/ai-analysis", + permissions: ["ai.analysis.list", "ai.analysis.manage"], + }, + { path: "/health/ai-usage", permissions: ["ai.usage.list"] }, // ===== 健康管理 — 积分商城 ===== - { path: '/health/points-rules', permissions: ['health.points.list', 'health.points.manage'] }, - { path: '/health/points-products', permissions: ['health.points.list', 'health.points.manage'] }, - { path: '/health/points-orders', permissions: ['health.points.list', 'health.points.manage'] }, - { path: '/health/offline-events', permissions: ['health.points.list', 'health.points.manage'] }, + { + path: "/health/points-rules", + permissions: ["health.points.list", "health.points.manage"], + }, + { + path: "/health/points-products", + permissions: ["health.points.list", "health.points.manage"], + }, + { + path: "/health/points-orders", + permissions: ["health.points.list", "health.points.manage"], + }, + { + path: "/health/offline-events", + permissions: ["health.points.list", "health.points.manage"], + }, // ===== 健康管理 — 内容管理 ===== - { path: '/health/articles', permissions: ['health.articles.list', 'health.articles.manage'] }, - { path: '/health/article-categories', permissions: ['health.articles.list', 'health.articles.manage'] }, - { path: '/health/article-tags', permissions: ['health.articles.list', 'health.articles.manage'] }, + { + path: "/health/articles", + permissions: ["health.articles.list", "health.articles.manage"], + }, + { + path: "/health/article-categories", + permissions: ["health.articles.list", "health.articles.manage"], + }, + { + path: "/health/article-tags", + permissions: ["health.articles.list", "health.articles.manage"], + }, // ===== 健康管理 — 其他 ===== - { path: '/health/oauth-clients', permissions: ['health.oauth.list', 'health.oauth.manage'] }, - { path: '/health/statistics', permissions: ['health.health-data.list', 'health.dashboard.manage'] }, - { path: '/health/medication-records', permissions: ['health.medication-records.manage'] }, + { + path: "/health/oauth-clients", + permissions: ["health.oauth.list", "health.oauth.manage"], + }, + { + path: "/health/statistics", + permissions: ["health.health-data.list", "health.dashboard.manage"], + }, + { + path: "/health/medication-records", + permissions: ["health.medication-records.manage"], + }, // ===== 冻结路由 ===== - { path: '/health/care-plans', permissions: ['health.care-plan.list', 'health.care-plan.manage'], frozen: true }, - { path: '/health/shifts', permissions: ['health.shifts.list', 'health.shifts.manage'], frozen: true }, - { path: '/health/family-proxy', permissions: ['health.family-proxy.list', 'health.family-proxy.manage'], frozen: true }, - { path: '/health/medications', permissions: ['health.medication-records.list', 'health.medication-records.manage'], frozen: true }, - { path: '/health/dialysis', permissions: ['health.dialysis.list', 'health.dialysis.manage'], frozen: true }, - { path: '/health/schedules', permissions: ['health.appointment.list', 'health.appointment.manage'], frozen: true }, + { + path: "/health/care-plans", + permissions: ["health.care-plan.list", "health.care-plan.manage"], + frozen: true, + }, + { + path: "/health/shifts", + permissions: ["health.shifts.list", "health.shifts.manage"], + frozen: true, + }, + { + path: "/health/family-proxy", + permissions: ["health.family-proxy.list", "health.family-proxy.manage"], + frozen: true, + }, + { + path: "/health/medications", + permissions: [ + "health.medication-records.list", + "health.medication-records.manage", + ], + frozen: true, + }, + { + path: "/health/dialysis", + permissions: ["health.dialysis.list", "health.dialysis.manage"], + frozen: true, + }, + { + path: "/health/schedules", + permissions: ["health.appointment.list", "health.appointment.manage"], + frozen: true, + }, ]; /** 活跃路由的权限映射 — 自动从配置生成,供 PrivateRoute 使用 */ @@ -97,13 +232,15 @@ export const ROUTE_PERMISSIONS: Record = Object.fromEntries( ); /** 冻结路由路径列表 — 自动从配置生成 */ -export const FROZEN_ROUTES: string[] = ENTRIES.filter((e) => e.frozen).map((e) => e.path); +export const FROZEN_ROUTES: string[] = ENTRIES.filter((e) => e.frozen).map( + (e) => e.path, +); /** 开发模式下校验:检查是否有路由路径重复 */ if (import.meta.env.DEV) { const paths = ENTRIES.map((e) => e.path); const dupes = paths.filter((p, i) => paths.indexOf(p) !== i); if (dupes.length > 0) { - console.error('[routeConfig] 检测到重复路径:', dupes); + console.error("[routeConfig] 检测到重复路径:", dupes); } } diff --git a/crates/erp-health/src/dto/stats_dto.rs b/crates/erp-health/src/dto/stats_dto.rs index 8360500..df6a04c 100644 --- a/crates/erp-health/src/dto/stats_dto.rs +++ b/crates/erp-health/src/dto/stats_dto.rs @@ -42,7 +42,7 @@ pub struct DashboardStatsResp { // 健康数据统计 // --------------------------------------------------------------------------- -#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Default, Serialize, Deserialize, ToSchema)] pub struct LabReportStatisticsResp { pub total_reports: i64, pub this_month: i64, @@ -56,7 +56,7 @@ pub struct LabReportStatisticsResp { pub reviewed: i64, } -#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Default, Serialize, Deserialize, ToSchema)] pub struct AppointmentStatisticsResp { pub total_appointments: i64, pub this_month: i64, @@ -68,7 +68,7 @@ pub struct AppointmentStatisticsResp { pub cancel_rate: f64, } -#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Default, Serialize, Deserialize, ToSchema)] pub struct VitalSignsReportRateResp { /// 总患者数 pub total_patients: i64, @@ -131,7 +131,7 @@ pub struct PersonalStatsResp { // 通用结构 // --------------------------------------------------------------------------- -#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Default, Serialize, Deserialize, ToSchema)] pub struct NameValue { pub name: String, pub value: i64, diff --git a/crates/erp-health/src/handler/points_handler.rs b/crates/erp-health/src/handler/points_handler.rs index 4cb6d76..919d801 100644 --- a/crates/erp-health/src/handler/points_handler.rs +++ b/crates/erp-health/src/handler/points_handler.rs @@ -179,6 +179,8 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.points.list")?; + // 患者端端点:验证当前用户有关联的患者档案 + let _patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let result = diff --git a/crates/erp-health/src/handler/stats_handler.rs b/crates/erp-health/src/handler/stats_handler.rs index 7080f84..6a7b84b 100644 --- a/crates/erp-health/src/handler/stats_handler.rs +++ b/crates/erp-health/src/handler/stats_handler.rs @@ -31,7 +31,11 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.consultation.list")?; - let result = stats_service::get_consultation_statistics(&state, ctx.tenant_id).await?; + let result = safe_aggregate( + stats_service::get_consultation_statistics(&state, ctx.tenant_id), + "咨询统计", + ) + .await; Ok(Json(ApiResponse::ok(result))) } @@ -96,7 +100,11 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.patient.list")?; - let result = stats_service::get_lab_report_statistics(&state, ctx.tenant_id).await?; + let result = safe_aggregate( + stats_service::get_lab_report_statistics(&state, ctx.tenant_id), + "化验报告统计", + ) + .await; Ok(Json(ApiResponse::ok(result))) } @@ -109,7 +117,11 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.patient.list")?; - let result = stats_service::get_appointment_statistics(&state, ctx.tenant_id).await?; + let result = safe_aggregate( + stats_service::get_appointment_statistics(&state, ctx.tenant_id), + "预约统计", + ) + .await; Ok(Json(ApiResponse::ok(result))) } @@ -122,7 +134,11 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.patient.list")?; - let result = stats_service::get_vital_signs_report_rate(&state, ctx.tenant_id).await?; + let result = safe_aggregate( + stats_service::get_vital_signs_report_rate(&state, ctx.tenant_id), + "体征上报率统计", + ) + .await; Ok(Json(ApiResponse::ok(result))) } @@ -135,8 +151,26 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.patient.list")?; - let result = stats_service::get_health_data_stats(&state, ctx.tenant_id).await?; - Ok(Json(ApiResponse::ok(result))) + let lab_reports = safe_aggregate( + stats_service::get_lab_report_statistics(&state, ctx.tenant_id), + "化验报告统计", + ) + .await; + let appointments = safe_aggregate( + stats_service::get_appointment_statistics(&state, ctx.tenant_id), + "预约统计", + ) + .await; + let vital_signs_report_rate = safe_aggregate( + stats_service::get_vital_signs_report_rate(&state, ctx.tenant_id), + "体征上报率统计", + ) + .await; + Ok(Json(ApiResponse::ok(HealthDataStatsResp { + lab_reports, + appointments, + vital_signs_report_rate, + }))) } // ---------------------------------------------------------------------------