From 310a3cec9008f01edafd9e64178f7bb638662455 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 1 May 2026 22:17:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E9=87=8D=E5=86=99=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=20UI=20=E5=8C=B9=E9=85=8D=E5=8E=9F=E5=9E=8B?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home.tsx 改用 CSS Grid 布局替代 antd Row/Col,统计卡片添加 顶部渐变色条。TodoList/AiInsightPanel/TeamOverviewPanel 全部 重写为自定义 inline style,复刻原型中的紧急度圆点、类型标签、 AI 渐变图标、成员进度条、风险分布色块等视觉元素。 --- apps/web/src/pages/Home.tsx | 247 +++++++++-------- .../components/workbench/AiInsightPanel.tsx | 248 +++++++++++++----- .../workbench/TeamOverviewPanel.tsx | 202 ++++++++------ .../health/components/workbench/TodoList.tsx | 244 +++++++++++++---- 4 files changed, 631 insertions(+), 310 deletions(-) diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index adf33d0..745c8a2 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -96,6 +96,14 @@ const ROLE_WELCOME: Record = operator: { title: '运营中心', subtitle: '积分、内容与活动' }, }; +const STAT_BAR_COLORS: string[] = [ + 'linear-gradient(90deg, #2563EB, #60A5FA)', + 'linear-gradient(90deg, #7C3AED, #A78BFA)', + 'linear-gradient(90deg, #DC2626, #F87171)', + 'linear-gradient(90deg, #D97706, #FBBF24)', +]; +const STAT_TEXT_COLORS: string[] = ['#2563EB', '#7C3AED', '#DC2626', '#D97706']; + const ROLE_STATS: Record = { doctor: [ { key: 'my-patients', title: '我的患者', getValue: (p) => p?.my_patients ?? 0, icon: , path: '/health/patients' }, @@ -238,126 +246,139 @@ export default function Home() { {/* 统计卡片行 */} - +
{statDefs.map((def, i) => { const value = def.getValue(personalStats, statsData); return ( - -
handleNavigate(def.path)} - role="button" - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(def.path); }} - > -
-
-
-
{def.title}
-
- - {def.suffix && {def.suffix}} -
-
-
{def.icon}
+
handleNavigate(def.path)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(def.path); }} + onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.06)'; e.currentTarget.style.transform = 'translateY(-1px)'; }} + onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'none'; }} + > +
+
+
{def.title}
+
+ + {def.suffix && {def.suffix}}
- +
); })} - +
- {/* 待办任务 + 最近活动 */} - - {(role === 'doctor' || role === 'nurse') ? ( - <> - -
-
- - 行动收件箱 -
- { setDrawerItem(item); setDrawerOpen(true); }} /> -
- - - - - - ) : ( - <> - -
-
- - 待办任务 - - {pendingTasks.length} 项待处理 - -
-
- {pendingTasks.length === 0 ? ( - - ) : ( - pendingTasks.map((task) => ( -
handleNavigate('/workflow')} - role="button" - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate('/workflow'); }} - > -
-
-
{task.node_name || task.definition_name || '流程任务'}
-
- {task.definition_name || '工作流'} - {task.status === 'pending' ? '待处理' : task.status} -
-
- 一般 - -
- )) - )} -
-
- + {/* 双栏布局 */} + {(role === 'doctor' || role === 'nurse') ? ( +
+ {/* 左:待办列表 */} +
+
+ 待办事项 +
+ { setDrawerItem(item); setDrawerOpen(true); }} /> +
- -
-
- - 最近动态 -
-
- {activitiesLoading ? ( -
- ) : recentActivities.length === 0 ? ( - - ) : ( - recentActivities.map((log) => ( -
-
- {RESOURCE_ICONS[log.resource_type] || } -
-
-
- {formatActionLabel(log.action)}了{formatResourceLabel(log.resource_type)} -
-
{formatTimeAgo(log.created_at)}
+ {/* 右:AI 洞察 */} + +
+ ) : ( + + +
+
+ + 待办任务 + + {pendingTasks.length} 项待处理 + +
+
+ {pendingTasks.length === 0 ? ( + + ) : ( + pendingTasks.map((task) => ( +
handleNavigate('/workflow')} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate('/workflow'); }} + > +
+
+
{task.node_name || task.definition_name || '流程任务'}
+
+ {task.definition_name || '工作流'} + {task.status === 'pending' ? '待处理' : task.status}
- )) - )} -
+ 一般 + +
+ )) + )}
- - - )} -
+
+ + + +
+
+ + 最近动态 +
+
+ {activitiesLoading ? ( +
+ ) : recentActivities.length === 0 ? ( + + ) : ( + recentActivities.map((log) => ( +
+
+ {RESOURCE_ICONS[log.resource_type] || } +
+
+
+ {formatActionLabel(log.action)}了{formatResourceLabel(log.resource_type)} +
+
{formatTimeAgo(log.created_at)}
+
+
+ )) + )} +
+
+ + + )} {/* 快捷入口 */} @@ -388,13 +409,11 @@ export default function Home() { - {/* 主任团队概览 */} + {/* 主任团队概览 — 在快捷入口上方,全宽展示 */} {role === 'admin' && ( - - - - - +
+ +
)} {/* 行动详情抽屉 */} diff --git a/apps/web/src/pages/health/components/workbench/AiInsightPanel.tsx b/apps/web/src/pages/health/components/workbench/AiInsightPanel.tsx index 6c8a49b..c071bc3 100644 --- a/apps/web/src/pages/health/components/workbench/AiInsightPanel.tsx +++ b/apps/web/src/pages/health/components/workbench/AiInsightPanel.tsx @@ -1,82 +1,212 @@ import { useEffect, useState } from 'react'; -import { Card, Statistic, Row, Col, Spin, Progress } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import { Spin } from 'antd'; import { RobotOutlined, - WarningOutlined, - TeamOutlined, - CheckCircleOutlined, + SearchOutlined, + FormOutlined, + PhoneOutlined, + AlertOutlined, + MedicineBoxOutlined, } from '@ant-design/icons'; -import { actionInboxApi, type WorkbenchStats } from '../../../../api/health/actionInbox'; +import { actionInboxApi, type ActionItem, type WorkbenchStats } from '../../../../api/health/actionInbox'; + +const RISK_CONFIG: Record = { + high: { label: '高风险', color: '#DC2626', bg: '#FEF2F2' }, + medium: { label: '中风险', color: '#D97706', bg: '#FFFBEB' }, + low: { label: '低风险', color: '#16A34A', bg: '#F0FDF4' }, +}; + +function getRiskFromPriority(priority: string) { + if (priority === 'urgent') return 'high'; + if (priority === 'high') return 'medium'; + return 'low'; +} export default function AiInsightPanel() { const [stats, setStats] = useState(null); + const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(true); + const navigate = useNavigate(); useEffect(() => { - actionInboxApi - .stats() - .then(setStats) + Promise.all([ + actionInboxApi.stats(), + actionInboxApi.list({ status: 'pending', page: 1, page_size: 3 }), + ]) + .then(([s, res]) => { + setStats(s); + setInsights(res.data.filter((i) => i.action_type === 'ai_suggestion' || i.action_type === 'alert')); + }) .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 && ( -
-
- - 完成率 +
+ {/* AI 洞察卡片 */} +
+ {/* 卡片头 */} +
+
+ AI
- = 0.8 ? 'success' : 'active'} - /> + 智能洞察 + {stats && stats.total_pending > 0 && ( + + {stats.ai_suggestion_pending} 条新建议 + + )}
- )} - + + {/* 洞察列表 */} + {insights.length === 0 ? ( +
+ 暂无 AI 洞察 +
+ ) : ( + insights.map((item, idx) => { + const risk = getRiskFromPriority(item.priority); + const riskCfg = RISK_CONFIG[risk]; + return ( +
+
+ {item.patient_name} +
+
+ {item.summary} +
+
+ + {riskCfg.label} + + + {item.action_type === 'ai_suggestion' ? 'AI 分析' : '告警'} + +
+ + {/* 第一条洞察的快捷操作 */} + {idx === 0 && ( +
+ + + +
+ )} +
+ ); + }) + )} +
+ + {/* 快捷操作 */} +
+
快捷操作
+
+ + + +
+
+
); } diff --git a/apps/web/src/pages/health/components/workbench/TeamOverviewPanel.tsx b/apps/web/src/pages/health/components/workbench/TeamOverviewPanel.tsx index 097220e..044f451 100644 --- a/apps/web/src/pages/health/components/workbench/TeamOverviewPanel.tsx +++ b/apps/web/src/pages/health/components/workbench/TeamOverviewPanel.tsx @@ -1,59 +1,68 @@ import { useEffect, useState } from 'react'; -import { Card, Table, Tag, Spin, Progress, Empty } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; +import { Spin } from 'antd'; import { actionInboxApi, type TeamOverview, type TeamMemberOverview } from '../../../../api/health/actionInbox'; -const RISK_COLOR: Record = { - high: 'red', - medium: 'orange', - low: 'green', -}; +function ProgressBar({ value, color }: { value: number; color: string }) { + return ( +
+
+
+ ); +} -const RISK_LABEL: Record = { - high: '高风险', - medium: '中风险', - low: '低风险', -}; +function MemberCard({ member }: { member: TeamMemberOverview }) { + const hasOverdue = member.overdue_count > 0; + const rate = member.completion_rate; + const barColor = hasOverdue ? '#D97706' : rate >= 0.8 ? '#16A34A' : '#2563EB'; -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'} - /> - ), - }, + return ( +
+
+
+
{member.name}
+
+ {member.title ? `${member.title} · ` : ''}待办 {member.pending_count} 项 +
+
+
+
+ {member.overdue_count} +
+
超时
+
+
+ +
+ 处理率 {member.completed_count}/{member.pending_count + member.completed_count} + {hasOverdue && ` · ${member.overdue_count} 项已升级`} +
+
+ ); +} + +const RISK_BLOCKS: { key: string; label: string; color: string; bg: string }[] = [ + { key: 'high', label: '高危', color: '#DC2626', bg: '#FEF2F2' }, + { key: 'medium', label: '中危', color: '#D97706', bg: '#FFFBEB' }, + { key: 'low', label: '低危', color: '#16A34A', bg: '#F0FDF4' }, ]; export default function TeamOverviewPanel() { @@ -68,40 +77,71 @@ export default function TeamOverviewPanel() { }, []); if (loading) { - return ( - -
-
- ); + return
; } - if (!data || data.members.length === 0) { - return ( - - - - ); - } + if (!data) return null; return ( - -
- {Object.entries(data.risk_distribution).map(([level, count]) => ( - - {RISK_LABEL[level]}: {count} - - ))} - - 待处理 {data.total_pending} / 已完成 {data.total_completed} - +
+ {/* 团队概览卡片 */} +
+
+ 团队概览 + + 待处理 {data.total_pending} · 已完成 {data.total_completed} + +
+
+ {data.members.length === 0 ? ( +
+ 暂无团队成员数据 +
+ ) : ( + data.members.map((member) => ( + + )) + )} +
- - + + {/* 风险分布 */} +
+
科室患者风险分布
+
+ {RISK_BLOCKS.map((block) => ( +
+
+ {data.risk_distribution[block.key as keyof typeof data.risk_distribution] ?? 0} +
+
{block.label}
+
+ ))} +
+
+ ); } diff --git a/apps/web/src/pages/health/components/workbench/TodoList.tsx b/apps/web/src/pages/health/components/workbench/TodoList.tsx index 14decba..2601fa5 100644 --- a/apps/web/src/pages/health/components/workbench/TodoList.tsx +++ b/apps/web/src/pages/health/components/workbench/TodoList.tsx @@ -1,36 +1,58 @@ import { useEffect, useState, useCallback } from 'react'; -import { List, Tag, Empty, Spin, Button, Space } from 'antd'; -import { - BellOutlined, - RobotOutlined, - TeamOutlined, - WarningOutlined, -} from '@ant-design/icons'; +import { Empty, Spin } from 'antd'; import { actionInboxApi, type ActionItem, type ActionType } from '../../../../api/health/actionInbox'; -const TYPE_CONFIG: Record = { - ai_suggestion: { label: 'AI 建议', color: 'blue', icon: }, - alert: { label: '告警', color: 'red', icon: }, - followup: { label: '随访', color: 'green', icon: }, - data_anomaly: { label: '数据异常', color: 'orange', icon: }, +const TYPE_CONFIG: Record = { + ai_suggestion: { label: 'AI 建议', color: '#7C3AED', bg: '#F5F3FF' }, + alert: { label: '危急告警', color: '#DC2626', bg: '#FEF2F2' }, + followup: { label: '随访', color: '#0284C7', bg: '#F0F9FF' }, + data_anomaly: { label: '数据异常', color: '#D97706', bg: '#FFFBEB' }, }; -const PRIORITY_COLOR: Record = { - urgent: 'red', - high: 'volcano', - medium: 'orange', - low: 'default', +const PRIORITY_DOT: Record = { + urgent: '#DC2626', + high: '#D97706', + medium: '#2563EB', + low: '#94A3B8', }; +const FILTER_OPTIONS: { key: ActionType | 'all'; label: string }[] = [ + { key: 'all', label: '全部' }, + { key: 'ai_suggestion', label: 'AI 建议' }, + { key: 'alert', label: '告警' }, + { key: 'followup', label: '随访' }, + { key: 'data_anomaly', label: '数据异常' }, +]; + +const ACTION_LABEL: Record = { + ai_suggestion: '审批建议', + alert: '立即处理', + followup: '开始随访', + data_anomaly: '查看详情', +}; + +function formatTime(dateStr: string): string { + const d = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffDays = Math.floor(diffMs / 86400000); + if (diffDays > 0) return `${diffDays}天前`; + const hours = d.getHours().toString().padStart(2, '0'); + const mins = d.getMinutes().toString().padStart(2, '0'); + return `${hours}:${mins}`; +} + interface TodoListProps { onItemClick?: (item: ActionItem) => void; } export default function TodoList({ onItemClick }: TodoListProps) { const [items, setItems] = useState([]); + const [filtered, setFiltered] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); + const [activeFilter, setActiveFilter] = useState('all'); const fetchItems = useCallback(async (p: number) => { setLoading(true); @@ -40,7 +62,7 @@ export default function TodoList({ onItemClick }: TodoListProps) { page: p, page_size: 10, }); - setItems(res.items); + setItems(res.data); setTotal(res.total); } finally { setLoading(false); @@ -51,6 +73,14 @@ export default function TodoList({ onItemClick }: TodoListProps) { fetchItems(page); }, [page, fetchItems]); + useEffect(() => { + if (activeFilter === 'all') { + setFiltered(items); + } else { + setFiltered(items.filter((i) => i.action_type === activeFilter)); + } + }, [items, activeFilter]); + if (loading) { return
; } @@ -60,44 +90,146 @@ export default function TodoList({ onItemClick }: TodoListProps) { } return ( - { - const cfg = TYPE_CONFIG[item.action_type]; - return ( - onItemClick?.(item)} +
+ {/* 筛选条 */} +
+ {FILTER_OPTIONS.map((opt) => ( + - - ); - }} - /> + {opt.label} + + ))} +
+ + {/* 待办列表 */} +
+ {filtered.length === 0 ? ( + + ) : ( + filtered.map((item) => { + const cfg = TYPE_CONFIG[item.action_type]; + const dotColor = PRIORITY_DOT[item.priority] || PRIORITY_DOT.low; + return ( +
onItemClick?.(item)} + style={{ + display: 'flex', + alignItems: 'flex-start', + gap: 12, + padding: '14px 12px', + borderRadius: 10, + cursor: 'pointer', + transition: 'all 0.15s', + borderBottom: '1px solid #F1F5F9', + }} + onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} + > + {/* 紧急度圆点 */} +
+ + {/* 内容 */} +
+
{item.title}
+
{item.summary}
+
+ + {cfg.label} + + + {item.patient_name} + +
+
+ + {/* 时间 */} + + {formatTime(item.created_at)} + + + {/* 操作按钮 */} + +
+ ); + }) + )} +
+ + {/* 分页 */} + {total > 10 && ( +
+ + {page} / {Math.ceil(total / 10)} + +
+ )} +
); }