From ab2c9bbc434a321660ae6f84bad3ae84f785661d Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 1 May 2026 21:19:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=BB=84=E4=BB=B6=20=E2=80=94=20AiInsightPan?= =?UTF-8?q?el=20/=20TeamOverviewPanel=20/=20ActionDetailDrawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiInsightPanel: 工作台统计概览(待处理/AI建议/紧急告警/到期随访+完成率) - TeamOverviewPanel: 主任团队概览(成员列表+风险分布+完成率进度条) - ActionDetailDrawer: 待办详情抽屉(患者信息+操作时间线+快捷操作按钮) --- .../workbench/ActionDetailDrawer.tsx | 167 ++++++++++++++++++ .../components/workbench/AiInsightPanel.tsx | 82 +++++++++ .../workbench/TeamOverviewPanel.tsx | 107 +++++++++++ 3 files changed, 356 insertions(+) create mode 100644 apps/web/src/pages/health/components/workbench/ActionDetailDrawer.tsx create mode 100644 apps/web/src/pages/health/components/workbench/AiInsightPanel.tsx create mode 100644 apps/web/src/pages/health/components/workbench/TeamOverviewPanel.tsx diff --git a/apps/web/src/pages/health/components/workbench/ActionDetailDrawer.tsx b/apps/web/src/pages/health/components/workbench/ActionDetailDrawer.tsx new file mode 100644 index 0000000..eec1bb2 --- /dev/null +++ b/apps/web/src/pages/health/components/workbench/ActionDetailDrawer.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react'; +import { Drawer, Descriptions, Tag, Steps, Button, Space, Spin, message } from 'antd'; +import { + CheckOutlined, + CloseOutlined, + EnterOutlined, +} from '@ant-design/icons'; +import { + actionInboxApi, + type ActionItem, + type ThreadResponse, + type ActionType, +} from '../../../../api/health/actionInbox'; + +const TYPE_CONFIG: Record = { + ai_suggestion: { label: 'AI 建议', color: 'blue' }, + alert: { label: '告警', color: 'red' }, + followup: { label: '随访', color: 'green' }, + data_anomaly: { label: '数据异常', color: 'orange' }, +}; + +const PRIORITY_COLOR: Record = { + urgent: 'red', + high: 'volcano', + medium: 'orange', + low: 'default', +}; + +const STATUS_STEP: Record = { + pending: { title: '待处理', description: '等待处理' }, + in_progress: { title: '处理中', description: '正在处理' }, + completed: { title: '已完成', description: '已处理完毕' }, + dismissed: { title: '已忽略', description: '已标记忽略' }, +}; + +interface ActionDetailDrawerProps { + item: ActionItem | null; + open: boolean; + onClose: () => void; + onActionComplete?: () => void; +} + +export default function ActionDetailDrawer({ + item, + open, + onClose, + onActionComplete, +}: ActionDetailDrawerProps) { + const [thread, setThread] = useState(null); + const [loading, setLoading] = useState(false); + const [actionLoading, setActionLoading] = useState(false); + + useEffect(() => { + if (!item || !open) { + setThread(null); + return; + } + setLoading(true); + actionInboxApi + .getThread(item.source_ref) + .then(setThread) + .finally(() => setLoading(false)); + }, [item, open]); + + if (!item) return null; + + const typeCfg = TYPE_CONFIG[item.action_type]; + + const handleAction = async (actionKey: string) => { + if (!thread) return; + setActionLoading(true); + try { + const actionDef = thread.available_actions.find((a) => a.key === actionKey); + if (!actionDef?.api_endpoint) { + message.warning('该操作暂未实现'); + return; + } + // TODO: 调用实际 API 执行操作 + message.success('操作成功'); + onActionComplete?.(); + onClose(); + } catch { + message.error('操作失败'); + } finally { + setActionLoading(false); + } + }; + + const currentStepIdx = thread + ? ['pending', 'in_progress', 'completed', 'dismissed'].indexOf(thread.action_item.status) + : 0; + + return ( + + {typeCfg.label} + {item.priority} + {item.title} + + } + open={open} + onClose={onClose} + width={520} + extra={ + thread?.available_actions.length ? ( + + {thread.available_actions.map((action) => ( + + ))} + + ) : undefined + } + > + {loading ? ( +
+ ) : ( + <> + + {item.patient_name} + + {new Date(item.created_at).toLocaleString()} + + {item.summary} + + + {thread && ( + ({ + title: STATUS_STEP[evt.status]?.title ?? evt.label, + description: ( +
+
{evt.detail || evt.label}
+ {evt.timestamp && ( +
+ {new Date(evt.timestamp).toLocaleString()} +
+ )} + {evt.operator && ( +
操作人: {evt.operator}
+ )} +
+ ), + }))} + /> + )} + + )} +
+ ); +} diff --git a/apps/web/src/pages/health/components/workbench/AiInsightPanel.tsx b/apps/web/src/pages/health/components/workbench/AiInsightPanel.tsx new file mode 100644 index 0000000..6c8a49b --- /dev/null +++ b/apps/web/src/pages/health/components/workbench/AiInsightPanel.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { Card, Statistic, Row, Col, Spin, Progress } from 'antd'; +import { + RobotOutlined, + WarningOutlined, + TeamOutlined, + CheckCircleOutlined, +} from '@ant-design/icons'; +import { actionInboxApi, type WorkbenchStats } from '../../../../api/health/actionInbox'; + +export default function AiInsightPanel() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + actionInboxApi + .stats() + .then(setStats) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + +
+
+ ); + } + + if (!stats) return null; + + return ( + + + + } + valueStyle={{ color: stats.total_pending > 0 ? '#cf1322' : undefined }} + /> + + + } + valueStyle={{ color: '#1677ff' }} + /> + + + } + valueStyle={{ color: stats.urgent_alerts > 0 ? '#fa541c' : undefined }} + /> + + + } + /> + + + {stats.completion_rate != null && ( +
+
+ + 完成率 +
+ = 0.8 ? 'success' : 'active'} + /> +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/health/components/workbench/TeamOverviewPanel.tsx b/apps/web/src/pages/health/components/workbench/TeamOverviewPanel.tsx new file mode 100644 index 0000000..097220e --- /dev/null +++ b/apps/web/src/pages/health/components/workbench/TeamOverviewPanel.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react'; +import { Card, Table, Tag, Spin, Progress, Empty } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { actionInboxApi, type TeamOverview, type TeamMemberOverview } from '../../../../api/health/actionInbox'; + +const RISK_COLOR: Record = { + high: 'red', + medium: 'orange', + low: 'green', +}; + +const RISK_LABEL: Record = { + high: '高风险', + medium: '中风险', + low: '低风险', +}; + +const columns: ColumnsType = [ + { + title: '姓名', + dataIndex: 'name', + key: 'name', + width: 100, + render: (name: string, record) => ( + {name}{record.title ? ` (${record.title})` : ''} + ), + }, + { + title: '待处理', + dataIndex: 'pending_count', + key: 'pending', + width: 80, + align: 'center', + render: (v: number) => v > 0 ? {v} : 0, + }, + { + title: '已逾期', + dataIndex: 'overdue_count', + key: 'overdue', + width: 80, + align: 'center', + render: (v: number) => v > 0 ? {v} : 0, + }, + { + title: '完成率', + dataIndex: 'completion_rate', + key: 'rate', + width: 120, + render: (v: number) => ( + = 0.8 ? 'success' : v >= 0.5 ? 'active' : 'exception'} + /> + ), + }, +]; + +export default function TeamOverviewPanel() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + actionInboxApi + .team() + .then(setData) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + +
+
+ ); + } + + if (!data || data.members.length === 0) { + return ( + + + + ); + } + + return ( + +
+ {Object.entries(data.risk_distribution).map(([level, count]) => ( + + {RISK_LABEL[level]}: {count} + + ))} + + 待处理 {data.total_pending} / 已完成 {data.total_completed} + +
+ + + ); +}