fix: 修复多角色找茬测试 V2 发现的 11 个问题
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

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
This commit is contained in:
iven
2026-05-08 12:42:41 +08:00
parent 297a151b0c
commit 22b8ac7ac6
11 changed files with 1443 additions and 619 deletions

View File

@@ -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 <Result status="403" title="权限不足" subTitle="您没有查看告警面板的权限" />;
const { hasPermission } = usePermission("health.alerts.list");
const [alerts, setAlerts] = useState<Alert[]>([]);
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>("");
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 (
<Result
status="403"
title="权限不足"
subTitle="您没有查看告警面板的权限"
/>
);
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 (
<PageContainer
@@ -156,12 +175,15 @@ export default function AlertDashboard() {
style={{ width: 120 }}
placeholder="按状态筛选"
/>
<Badge status={connected ? 'success' : 'error'} text={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<WifiOutlined style={{ marginRight: 4 }} />
{connected ? '实时连接' : '连接断开'}
</Typography.Text>
} />
<Badge
status={connected ? "success" : "error"}
text={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<WifiOutlined style={{ marginRight: 4 }} />
{connected ? "实时连接" : "连接断开"}
</Typography.Text>
}
/>
</Space>
}
>
@@ -174,7 +196,7 @@ export default function AlertDashboard() {
title="待处理"
value={pendingCount}
prefix={<ExclamationCircleOutlined />}
valueStyle={{ color: pendingCount > 0 ? '#fa8c16' : undefined }}
valueStyle={{ color: pendingCount > 0 ? "#fa8c16" : undefined }}
/>
</Card>
</Col>
@@ -184,7 +206,7 @@ export default function AlertDashboard() {
title="已确认"
value={acknowledgedCount}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#1890ff' }}
valueStyle={{ color: "#1890ff" }}
/>
</Card>
</Col>
@@ -194,7 +216,9 @@ export default function AlertDashboard() {
title="危急值"
value={criticalCount}
prefix={<WarningOutlined />}
valueStyle={{ color: criticalCount > 0 ? '#ff4d4f' : undefined }}
valueStyle={{
color: criticalCount > 0 ? "#ff4d4f" : undefined,
}}
/>
</Card>
</Col>
@@ -212,46 +236,65 @@ export default function AlertDashboard() {
</Space>
}
size="small"
style={{ maxHeight: 600, overflow: 'auto' }}
style={{ maxHeight: 600, overflow: "auto" }}
>
<Spin spinning={loading}>
<List
size="small"
dataSource={alerts}
locale={{ emptyText: '暂无告警' }}
locale={{ emptyText: "暂无告警" }}
renderItem={(alert) => (
<List.Item
onClick={() => 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",
}}
>
<List.Item.Meta
avatar={
<Tag
color={SEVERITY_COLOR[alert.severity] || 'default'}
style={{ margin: 0, minWidth: 48, textAlign: 'center' }}
color={SEVERITY_COLOR[alert.severity] || "default"}
style={{
margin: 0,
minWidth: 48,
textAlign: "center",
}}
>
{SEVERITY_LABEL[alert.severity] ?? alert.severity}
</Tag>
}
title={
<Flex justify="space-between" align="center">
<span>{alert.title}</span>
<Tag color={ALERT_STATUS_COLOR[alert.status] || 'default'} style={{ fontSize: 11 }}>
<span>{translateAlertTitle(alert.title)}</span>
<Tag
color={
ALERT_STATUS_COLOR[alert.status] || "default"
}
style={{ fontSize: 11 }}
>
{ALERT_STATUS_LABEL[alert.status] ?? alert.status}
</Tag>
</Flex>
}
description={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
: <EntityName name={alert.patient_name} id={alert.patient_id} />
{' · '}
{new Date(alert.created_at).toLocaleString('zh-CN')}
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
>
:{" "}
<EntityName
name={alert.patient_name}
id={alert.patient_id}
/>
{" · "}
{new Date(alert.created_at).toLocaleString("zh-CN")}
</Typography.Text>
}
/>
@@ -272,7 +315,7 @@ export default function AlertDashboard() {
loading={actionLoading}
/>
) : (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ padding: 40, textAlign: "center" }}>
<Typography.Text type="secondary">
</Typography.Text>

View File

@@ -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<string, 'flex-start' | 'flex-end' | 'center'> = {
patient: 'flex-start',
doctor: 'flex-end',
system: 'center',
const ROLE_ALIGN: Record<string, "flex-start" | "flex-end" | "center"> = {
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<Session | null>(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<HTMLDivElement>(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 (
<div key={msg.id} style={{ textAlign: 'center', padding: '8px 0' }}>
<div key={msg.id} style={{ textAlign: "center", padding: "8px 0" }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{msg.content}
</Typography.Text>
@@ -221,31 +230,32 @@ export default function ConsultationDetail() {
);
}
const isImage = msg.content_type === 'image';
const isImage = msg.content_type === "image";
return (
<div
key={msg.id}
style={{
display: 'flex',
display: "flex",
justifyContent: align,
marginBottom: 12,
}}
>
<div style={{ maxWidth: '70%' }}>
<div style={{ maxWidth: "70%" }}>
{isImage ? (
<ImagePreview urls={parseImageUrls(msg.content)} width={200} />
) : (
<div
style={{
background: msg.sender_role === 'doctor' ? '#1890ff' : '#f0f0f0',
color: msg.sender_role === 'doctor' ? '#fff' : '#000',
padding: '8px 12px',
background:
msg.sender_role === "doctor" ? "#1890ff" : "#f0f0f0",
color: msg.sender_role === "doctor" ? "#fff" : "#000",
padding: "8px 12px",
borderRadius: 8,
wordBreak: 'break-word',
wordBreak: "break-word",
}}
>
<Typography.Paragraph style={{ margin: 0, color: 'inherit' }}>
<Typography.Paragraph style={{ margin: 0, color: "inherit" }}>
{msg.content}
</Typography.Paragraph>
</div>
@@ -261,34 +271,34 @@ export default function ConsultationDetail() {
// --- Full render ---
if (sessionLoading && messages.length === 0) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<div style={{ display: "flex", justifyContent: "center", padding: 100 }}>
<Spin size="large" />
</div>
);
}
const isClosed = session?.status === 'closed';
const isClosed = session?.status === "closed";
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 120px)',
background: isDark ? '#111827' : '#FFFFFF',
display: "flex",
flexDirection: "column",
height: "calc(100vh - 120px)",
background: isDark ? "#111827" : "#FFFFFF",
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
border: `1px solid ${isDark ? "#0f172a" : "#f8fafc"}`,
overflow: "hidden",
}}
>
{/* Top bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
gap: 12,
padding: '12px 20px',
borderBottom: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
padding: "12px 20px",
borderBottom: `1px solid ${isDark ? "#1e293b" : "#f1f5f9"}`,
flexShrink: 0,
}}
>
@@ -298,10 +308,16 @@ export default function ConsultationDetail() {
{session && (
<>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
: <EntityName name={session.patient_name} id={session.patient_id} />
:{" "}
<EntityName name={session.patient_name} id={session.patient_id} />
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
: <EntityName name={session.doctor_name} id={session.doctor_id} fallbackLabel="未分配" />
:{" "}
<EntityName
name={session.doctor_name}
id={session.doctor_id}
fallbackLabel="未分配"
/>
</Typography.Text>
<StatusTag status={session.status} />
</>
@@ -323,7 +339,7 @@ export default function ConsultationDetail() {
size="small"
danger
icon={<CloseCircleOutlined />}
style={{ marginLeft: 'auto' }}
style={{ marginLeft: "auto" }}
>
</Button>
@@ -336,13 +352,13 @@ export default function ConsultationDetail() {
<div
style={{
flex: 1,
overflow: 'auto',
padding: '16px 20px',
background: isDark ? '#0f172a' : '#f8fafc',
overflow: "auto",
padding: "16px 20px",
background: isDark ? "#0f172a" : "#f8fafc",
}}
>
{hasMore && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<div style={{ textAlign: "center", marginBottom: 16 }}>
<Button
size="small"
icon={<ArrowUpOutlined />}
@@ -355,7 +371,7 @@ export default function ConsultationDetail() {
)}
{msgLoading && messages.length === 0 && (
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ textAlign: "center", padding: 40 }}>
<Spin />
</div>
)}
@@ -368,38 +384,40 @@ export default function ConsultationDetail() {
{/* Input area */}
<div
style={{
display: 'flex',
alignItems: 'flex-end',
display: "flex",
alignItems: "flex-end",
gap: 8,
padding: '12px 20px',
borderTop: `1px solid ${isDark ? '#1e293b' : '#f1f5f9'}`,
padding: "12px 20px",
borderTop: `1px solid ${isDark ? "#1e293b" : "#f1f5f9"}`,
flexShrink: 0,
background: isDark ? '#111827' : '#FFFFFF',
background: isDark ? "#111827" : "#FFFFFF",
}}
>
<Input.TextArea
value={inputText}
onChange={(e) => 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}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sending}
disabled={!inputText.trim() || isClosed}
>
</Button>
<AuthButton code="health.consultation.manage">
<Input.TextArea
value={inputText}
onChange={(e) => 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}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sending}
disabled={!inputText.trim() || isClosed}
>
</Button>
</AuthButton>
</div>
</div>
);

View File

@@ -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<string, string> = {
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<string | null>(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<typeof consultationApi.listSessions>[0]);
return consultationApi.listSessions(
params as Parameters<typeof consultationApi.listSessions>[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<Session> = [
{
title: '患者',
dataIndex: 'patient_name',
key: 'patient_name',
title: "患者",
dataIndex: "patient_name",
key: "patient_name",
width: 140,
render: (_: unknown, record: Session) => (
<EntityName name={record.patient_name} id={record.patient_id} />
),
},
{
title: '医护',
dataIndex: 'doctor_name',
key: 'doctor_name',
title: "医护",
dataIndex: "doctor_name",
key: "doctor_name",
width: 140,
render: (_: unknown, record: Session) => (
<EntityName name={record.doctor_name} id={record.doctor_id} fallbackLabel="未分配" />
<EntityName
name={record.doctor_name}
id={record.doctor_id}
fallbackLabel="未分配"
/>
),
},
{
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) => <StatusTag status={status} />,
},
{
title: '未读(患者/医护)',
key: 'unread',
title: "未读(患者/医护)",
key: "unread",
width: 140,
render: (_: unknown, record: Session) => (
<span>
@@ -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) => (
<AuthButton code="health.consultation.manage">
<Space size={4}>
{record.status !== 'closed' && (
{record.status !== "closed" && (
<Popconfirm
title="确认关闭该咨询会话?"
onConfirm={() => 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 }))
}
/>
<DatePicker.RangePicker
style={{ width: 240 }}
@@ -245,7 +270,10 @@ export default function ConsultationList() {
if (dates && dates[0] && dates[1]) {
setFilters((prev) => ({
...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() {
<Form.Item
name="patient_id"
label="患者"
rules={[{ required: true, message: '请选择患者' }]}
rules={[{ required: true, message: "请选择患者" }]}
>
<PatientSelect />
</Form.Item>

View File

@@ -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<string, { color: string; label: string; icon: React.ReactNode }> = {
info: { color: 'default', label: '提示', icon: <ExclamationCircleOutlined /> },
warning: { color: 'orange', label: '警告', icon: <ExclamationCircleOutlined /> },
critical: { color: 'red', label: '严重', icon: <ExclamationCircleOutlined /> },
urgent: { color: 'magenta', label: '紧急', icon: <ExclamationCircleOutlined /> },
const SEVERITY_CONFIG: Record<
string,
{ color: string; label: string; icon: React.ReactNode }
> = {
info: {
color: "default",
label: "提示",
icon: <ExclamationCircleOutlined />,
},
warning: {
color: "orange",
label: "警告",
icon: <ExclamationCircleOutlined />,
},
critical: {
color: "red",
label: "严重",
icon: <ExclamationCircleOutlined />,
},
urgent: {
color: "magenta",
label: "紧急",
icon: <ExclamationCircleOutlined />,
},
};
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: '已忽略' },
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<string, string> = {
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({
{/* 顶部摘要 */}
<div style={{ marginBottom: 16 }}>
<Space align="center" size="middle">
<Tag color={severity.color} icon={severity.icon} style={{ fontSize: 14, padding: '4px 12px' }}>
<Tag
color={severity.color}
icon={severity.icon}
style={{ fontSize: 14, padding: "4px 12px" }}
>
{severity.label}
</Tag>
<Tag color={status.color}>{status.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{new Date(alert.created_at).toLocaleString('zh-CN')}
{new Date(alert.created_at).toLocaleString("zh-CN")}
</Typography.Text>
</Space>
</div>
@@ -99,17 +137,29 @@ export function AlertDetailPanel({
{/* 详情 */}
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="患者">
<Typography.Text strong>{alert.patient_name || '未知患者'}</Typography.Text>
<Typography.Text strong>
{alert.patient_name || "未知患者"}
</Typography.Text>
<Tooltip title={alert.patient_id}>
<Typography.Text type="secondary" copyable style={{ fontSize: 12, marginLeft: 8, cursor: 'help' }}>
<Typography.Text
type="secondary"
copyable
style={{ fontSize: 12, marginLeft: 8, cursor: "help" }}
>
{alert.patient_id.slice(0, 8)}...
</Typography.Text>
</Tooltip>
</Descriptions.Item>
<Descriptions.Item label="规则">
<Typography.Text>{alert.title || '未知规则'}</Typography.Text>
<Typography.Text>
{translateAlertTitle(alert.title) || "未知规则"}
</Typography.Text>
<Tooltip title={alert.rule_id}>
<Typography.Text type="secondary" copyable style={{ fontSize: 12, marginLeft: 8, cursor: 'help' }}>
<Typography.Text
type="secondary"
copyable
style={{ fontSize: 12, marginLeft: 8, cursor: "help" }}
>
{alert.rule_id.slice(0, 8)}...
</Typography.Text>
</Tooltip>
@@ -122,24 +172,32 @@ export function AlertDetailPanel({
</Descriptions.Item>
{alert.acknowledged_by && (
<Descriptions.Item label="处理人" span={2}>
<Typography.Text style={{ fontSize: 12 }}>{alert.acknowledged_by}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}>
{alert.acknowledged_by}
</Typography.Text>
</Descriptions.Item>
)}
{alert.acknowledged_at && (
<Descriptions.Item label="确认时间">
{new Date(alert.acknowledged_at).toLocaleString('zh-CN')}
{new Date(alert.acknowledged_at).toLocaleString("zh-CN")}
</Descriptions.Item>
)}
{alert.resolved_at && (
<Descriptions.Item label="恢复时间">
{new Date(alert.resolved_at).toLocaleString('zh-CN')}
{new Date(alert.resolved_at).toLocaleString("zh-CN")}
</Descriptions.Item>
)}
</Descriptions>
{/* 告警详情 */}
{detailEntries.length > 0 && (
<Descriptions column={2} size="small" bordered style={{ marginTop: 12 }} title="告警详情">
<Descriptions
column={2}
size="small"
bordered
style={{ marginTop: 12 }}
title="告警详情"
>
{detailEntries.map(([key, value]) => (
<Descriptions.Item key={key} label={DETAIL_LABEL_MAP[key] ?? key}>
<Typography.Text style={{ fontSize: 13 }}>
@@ -151,7 +209,13 @@ export function AlertDetailPanel({
)}
{/* 操作按钮 */}
<div style={{ marginTop: 16, borderTop: '1px solid var(--ant-color-border)', paddingTop: 12 }}>
<div
style={{
marginTop: 16,
borderTop: "1px solid var(--ant-color-border)",
paddingTop: 12,
}}
>
<Space>
{isPending && onAcknowledge && (
<Tooltip title="确认已知晓此告警">

View File

@@ -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<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_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<string, string> = {
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<string, string> = {
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<AuditLogItem[]>([]);
const [systemHealth, setSystemHealth] = useState<SystemHealthResp | null>(null);
const [userActivity, setUserActivity] = useState<UserActivityResp | null>(null);
const [systemHealth, setSystemHealth] = useState<SystemHealthResp | null>(
null,
);
const [userActivity, setUserActivity] = useState<UserActivityResp | null>(
null,
);
const [modules, setModules] = useState<ModuleStatusResp[]>([]);
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 (
<div>
{/* 欢迎栏 */}
<div style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: '0 0 4px' }}>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: "0 0 4px" }}>
{greeting}{firstName.charAt(0)}
</h1>
<p style={{ color: '#64748B', fontSize: 13, margin: 0 }}>
<p style={{ color: "#64748B", fontSize: 13, margin: 0 }}>
· {activeModules} ·
</p>
</div>
{/* 系统健康条 */}
<div style={{
display: 'flex', gap: 12, marginBottom: 20,
padding: '14px 20px', background: '#fff', borderRadius: 12,
border: '1px solid #E2E8F0',
}}>
<div
style={{
display: "flex",
gap: 12,
marginBottom: 20,
padding: "14px 20px",
background: "#fff",
borderRadius: 12,
border: "1px solid #E2E8F0",
}}
>
{healthServices.map((item, i) => (
<div key={item.name} style={{
display: 'flex', alignItems: 'center', gap: 8,
fontSize: 12, color: '#64748B', paddingRight: 12,
borderRight: i < healthServices.length - 1 ? '1px solid #F1F5F9' : undefined,
}}>
<div style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: item.status === 'healthy' ? '#22C55E' : item.status === 'degraded' ? '#EAB308' : '#EF4444',
}} />
<span style={{ fontWeight: 500, color: '#334155' }}>{item.name}</span> {item.message}
<div
key={item.name}
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
color: "#64748B",
paddingRight: 12,
borderRight:
i < healthServices.length - 1 ? "1px solid #F1F5F9" : undefined,
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
background:
item.status === "healthy"
? "#22C55E"
: item.status === "degraded"
? "#EAB308"
: "#EF4444",
}}
/>
<span style={{ fontWeight: 500, color: "#334155" }}>
{item.name}
</span>{" "}
{item.message}
</div>
))}
</div>
{/* 统计卡片 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 24 }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 14,
marginBottom: 24,
}}
>
{statCards.map((card) => (
<div key={card.label} style={{
background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0',
overflow: 'hidden', cursor: 'pointer', transition: 'all 0.2s',
}}>
<div
key={card.label}
style={{
background: "#fff",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",
cursor: "pointer",
transition: "all 0.2s",
}}
>
<div style={{ height: 3, background: card.gradient }} />
<div style={{ padding: '14px 18px' }}>
<div style={{ fontSize: 12, color: '#94A3B8', marginBottom: 4 }}>{card.label}</div>
<div style={{ fontSize: 26, fontWeight: 700, color: card.color }}>{card.value}</div>
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 3 }}>{card.sub}</div>
<div style={{ padding: "14px 18px" }}>
<div style={{ fontSize: 12, color: "#94A3B8", marginBottom: 4 }}>
{card.label}
</div>
<div style={{ fontSize: 26, fontWeight: 700, color: card.color }}>
{card.value}
</div>
<div style={{ fontSize: 11, color: "#94A3B8", marginTop: 3 }}>
{card.sub}
</div>
</div>
</div>
))}
</div>
{/* 双栏:审计日志 + 模块状态 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 16,
marginBottom: 20,
}}
>
{/* 最近审计日志 */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
<div style={{
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<div
style={{
background: "#fff",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",
}}
>
<div
style={{
padding: "12px 18px",
borderBottom: "1px solid #F1F5F9",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h3 style={{ fontSize: 14, fontWeight: 600 }}></h3>
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/audit-logs')}> </span>
<span
style={{ fontSize: 11, color: "#2563EB", cursor: "pointer" }}
onClick={() => navigate("/audit-logs")}
>
</span>
</div>
{auditLogs.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: '#94A3B8', fontSize: 13 }}></div>
<div
style={{
padding: 24,
textAlign: "center",
color: "#94A3B8",
fontSize: 13,
}}
>
</div>
) : (
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 (
<div key={log.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 18px', borderBottom: '1px solid #F1F5F9',
fontSize: 13, transition: 'background 0.15s', cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#F8FAFC'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
<div
key={log.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 18px",
borderBottom: "1px solid #F1F5F9",
fontSize: 13,
transition: "background 0.15s",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#F8FAFC";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
<div style={{
width: 28, height: 28, borderRadius: 6, background: iconCfg.bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, flexShrink: 0, color: iconCfg.color,
}}>{iconCfg.icon}</div>
<span style={{ fontWeight: 500, flexShrink: 0, width: 60 }}>{log.user_id ? log.user_id.slice(0, 6) : '系统'}</span>
<span style={{ flex: 1, color: '#475569' }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 6,
background: iconCfg.bg,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
flexShrink: 0,
color: iconCfg.color,
}}
>
{iconCfg.icon}
</div>
<span style={{ fontWeight: 500, flexShrink: 0, width: 60 }}>
{log.user_id ? (
<EntityName
name={log.user_id.slice(0, 6)}
id={log.user_id}
/>
) : (
"系统"
)}
</span>
<span style={{ flex: 1, color: "#475569" }}>
{actionLabel}{resourceLabel}
</span>
<span style={{ fontSize: 11, color: '#94A3B8', flexShrink: 0 }}>{formatTimeAgo(log.created_at)}</span>
<span
style={{ fontSize: 11, color: "#94A3B8", flexShrink: 0 }}
>
{formatTimeAgo(log.created_at)}
</span>
</div>
);
})
@@ -203,94 +495,231 @@ export default function AdminDashboard() {
</div>
{/* 模块状态 */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
<div style={{
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<div
style={{
background: "#fff",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",
}}
>
<div
style={{
padding: "12px 18px",
borderBottom: "1px solid #F1F5F9",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h3 style={{ fontSize: 14, fontWeight: 600 }}></h3>
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/plugins')}> </span>
<span
style={{ fontSize: 11, color: "#2563EB", cursor: "pointer" }}
onClick={() => navigate("/plugins")}
>
</span>
</div>
{(modules.length > 0 ? modules : []).map((mod) => (
<div key={mod.name} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 18px', borderBottom: '1px solid #F1F5F9',
}}>
<div
key={mod.name}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 18px",
borderBottom: "1px solid #F1F5F9",
}}
>
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{mod.display_name}</div>
<div style={{ fontSize: 11, color: '#94A3B8' }}>{mod.description}</div>
<div style={{ fontSize: 13, fontWeight: 500 }}>
{mod.display_name}
</div>
<div style={{ fontSize: 11, color: "#94A3B8" }}>
{mod.description}
</div>
</div>
<span style={{
fontSize: 11, padding: '2px 10px', borderRadius: 10, fontWeight: 500,
background: mod.active ? '#F0FDF4' : '#F1F5F9',
color: mod.active ? '#16A34A' : '#94A3B8',
}}>{mod.active ? '运行中' : '未启用'}</span>
<span
style={{
fontSize: 11,
padding: "2px 10px",
borderRadius: 10,
fontWeight: 500,
background: mod.active ? "#F0FDF4" : "#F1F5F9",
color: mod.active ? "#16A34A" : "#94A3B8",
}}
>
{mod.active ? "运行中" : "未启用"}
</span>
</div>
))}
</div>
</div>
{/* 双栏:用户活跃度 + 快捷管理 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
{/* 用户活跃度 */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
<div style={{
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<div
style={{
background: "#fff",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",
}}
>
<div
style={{
padding: "12px 18px",
borderBottom: "1px solid #F1F5F9",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h3 style={{ fontSize: 14, fontWeight: 600 }}></h3>
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/users')}> </span>
<span
style={{ fontSize: 11, color: "#2563EB", cursor: "pointer" }}
onClick={() => navigate("/users")}
>
</span>
</div>
{userActivityItems.map((item) => (
<div key={item.label} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 18px', borderBottom: '1px solid #F1F5F9',
}}>
<span style={{ fontSize: 12, width: 70, flexShrink: 0 }}>{item.label}</span>
<div style={{ flex: 1, height: 6, background: '#F1F5F9', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${item.pct}%`, height: '100%', background: item.color, borderRadius: 3 }} />
<div
key={item.label}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "10px 18px",
borderBottom: "1px solid #F1F5F9",
}}
>
<span style={{ fontSize: 12, width: 70, flexShrink: 0 }}>
{item.label}
</span>
<div
style={{
flex: 1,
height: 6,
background: "#F1F5F9",
borderRadius: 3,
overflow: "hidden",
}}
>
<div
style={{
width: `${item.pct}%`,
height: "100%",
background: item.color,
borderRadius: 3,
}}
/>
</div>
<span style={{ fontSize: 13, fontWeight: 600, width: 40, textAlign: 'right', flexShrink: 0, color: item.color === '#94A3B8' ? '#475569' : item.color }}>{item.value}</span>
<span
style={{
fontSize: 13,
fontWeight: 600,
width: 40,
textAlign: "right",
flexShrink: 0,
color: item.color === "#94A3B8" ? "#475569" : item.color,
}}
>
{item.value}
</span>
</div>
))}
<div style={{
padding: '12px 18px', borderTop: '1px solid #F1F5F9',
display: 'flex', justifyContent: 'space-between',
}}>
<div style={{ fontSize: 11, color: '#94A3B8' }}></div>
<div style={{ display: 'flex', gap: 10, fontSize: 11 }}>
<div
style={{
padding: "12px 18px",
borderTop: "1px solid #F1F5F9",
display: "flex",
justifyContent: "space-between",
}}
>
<div style={{ fontSize: 11, color: "#94A3B8" }}></div>
<div style={{ display: "flex", gap: 10, fontSize: 11 }}>
{userActivity?.by_role.map((r) => (
<span key={r.role}>{r.role} {r.count}</span>
)) ?? <span style={{ color: '#94A3B8' }}>...</span>}
<span key={r.role}>
{r.role} {r.count}
</span>
)) ?? <span style={{ color: "#94A3B8" }}>...</span>}
</div>
</div>
</div>
{/* 快捷管理入口 */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
<div style={{
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<div
style={{
background: "#fff",
borderRadius: 12,
border: "1px solid #E2E8F0",
overflow: "hidden",
}}
>
<div
style={{
padding: "12px 18px",
borderBottom: "1px solid #F1F5F9",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h3 style={{ fontSize: 14, fontWeight: 600 }}></h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, padding: '14px 18px' }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 10,
padding: "14px 18px",
}}
>
{QUICK_ACTIONS.map((item) => (
<div key={item.path} style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
padding: '14px 8px', borderRadius: 10, border: '1px solid #E2E8F0',
cursor: 'pointer', transition: 'all 0.15s', textAlign: 'center',
}}
<div
key={item.path}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 6,
padding: "14px 8px",
borderRadius: 10,
border: "1px solid #E2E8F0",
cursor: "pointer",
transition: "all 0.15s",
textAlign: "center",
}}
onClick={() => 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";
}}
>
<div style={{
width: 36, height: 36, borderRadius: 8, background: item.bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16, color: item.color,
}}>{item.icon}</div>
<span style={{ fontSize: 12, fontWeight: 500 }}>{item.text}</span>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
background: item.bg,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
color: item.color,
}}
>
{item.icon}
</div>
<span style={{ fontSize: 12, fontWeight: 500 }}>
{item.text}
</span>
</div>
))}
</div>