diff --git a/apps/web/src/api/health/dashboard.ts b/apps/web/src/api/health/dashboard.ts new file mode 100644 index 0000000..43201ef --- /dev/null +++ b/apps/web/src/api/health/dashboard.ts @@ -0,0 +1,69 @@ +import client from '../client'; + +export interface ServiceHealthStatus { + name: string; + status: string; + message: string; + response_ms: number | null; +} + +export interface SystemHealthResp { + services: ServiceHealthStatus[]; + checked_at: string; +} + +export interface RoleCount { + role: string; + count: number; +} + +export interface UserActivityResp { + daily_active: number; + weekly_active: number; + monthly_active: number; + total_registered: number; + by_role: RoleCount[]; +} + +export interface ModuleStatusResp { + name: string; + display_name: string; + description: string; + active: boolean; + entity_count: number | null; + route_count: number | null; +} + +export interface PointsActivityItem { + id: string; + user_name: string; + detail: string; + amount: string; + type: string; + created_at: string; +} + +export interface ArticleStatsResp { + published: number; + draft: number; + pending_review: number; + rejected: number; + total_views: number; +} + +export const dashboardApi = { + getSystemHealth: () => + client.get('/health/admin/system-health').then((r) => r.data.data as SystemHealthResp), + + getUserActivity: () => + client.get('/health/admin/user-activity').then((r) => r.data.data as UserActivityResp), + + getModuleStatus: () => + client.get('/health/admin/modules').then((r) => r.data.data as ModuleStatusResp[]), + + getPointsRecentActivity: () => + client.get('/health/points/recent-activity').then((r) => r.data.data as PointsActivityItem[]), + + getArticleStats: () => + client.get('/health/articles/stats').then((r) => r.data.data as ArticleStatsResp), +}; diff --git a/apps/web/src/hooks/useDashboardRole.ts b/apps/web/src/hooks/useDashboardRole.ts index 636ae50..9915db9 100644 --- a/apps/web/src/hooks/useDashboardRole.ts +++ b/apps/web/src/hooks/useDashboardRole.ts @@ -1,8 +1,8 @@ import { useAuthStore } from '../stores/auth'; -type DashboardRole = 'doctor' | 'nurse' | 'admin' | 'operator'; +type DashboardRole = 'doctor' | 'health_manager' | 'nurse' | 'admin' | 'operator'; -const ROLE_PRIORITY: DashboardRole[] = ['doctor', 'nurse', 'admin', 'operator']; +const ROLE_PRIORITY: DashboardRole[] = ['doctor', 'health_manager', 'nurse', 'admin', 'operator']; export function useDashboardRole(): DashboardRole { const user = useAuthStore(s => s.user); diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 5ad5abd..58ee470 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -32,8 +32,12 @@ import { useStatsData } from './health/StatisticsDashboard/useStatsData'; import { useCountUp } from '../hooks/useCountUp'; import TodoList from './health/components/workbench/TodoList'; import AiInsightPanel from './health/components/workbench/AiInsightPanel'; -import TeamOverviewPanel from './health/components/workbench/TeamOverviewPanel'; import ActionDetailDrawer from './health/components/workbench/ActionDetailDrawer'; +import TaskQueue from './health/components/workbench/TaskQueue'; +import TaskDetail from './health/components/workbench/TaskDetail'; +import DoctorWorkbench from './health/components/workbench/DoctorWorkbench'; +import OperatorWorkbench from './health/components/workbench/OperatorWorkbench'; +import AdminDashboard from './health/components/workbench/AdminDashboard'; import type { ActionItem } from '../api/health/actionInbox'; // --- Shared utilities --- @@ -92,6 +96,7 @@ interface QuickActionDef { const ROLE_WELCOME: Record = { doctor: { title: '今日工作台', subtitle: '患者概览与待办事项' }, + health_manager: { title: '任务工作台', subtitle: '待处理任务与患者管理' }, nurse: { title: '随访监控台', subtitle: '今日随访与体征上报' }, admin: { title: '管理中心', subtitle: '平台运营数据概览' }, operator: { title: '运营中心', subtitle: '积分、内容与活动' }, @@ -112,6 +117,12 @@ const ROLE_STATS: Record = { { key: 'consultations', title: '本月咨询', getValue: (p) => p?.consultations_this_month ?? 0, getDiff: (p) => { const c = p?.consultations_this_month, y = p?.yesterday_consultations_this_month; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/consultations' }, { key: 'followup-rate', title: '随访完成率', getValue: (p) => p?.follow_up_rate ?? 0, icon: , suffix: '%', path: '/health/follow-ups' }, ], + health_manager: [ + { key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, getDiff: (p) => { const c = p?.today_follow_ups, y = p?.yesterday_today_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/follow-up-tasks' }, + { key: 'vital-anomaly', title: '体征异常', getValue: (p) => p?.overdue_follow_ups ?? 0, icon: , path: '/health/alert-dashboard' }, + { key: 'ai-pending', title: 'AI 建议待审', getValue: (p) => p?.consultations_this_month ?? 0, icon: , path: '/health/ai-analysis' }, + { key: 'followup-rate', title: '处理率', getValue: (p) => p?.follow_up_rate ?? 0, icon: , suffix: '%', path: '/health/follow-up-tasks' }, + ], nurse: [ { key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, getDiff: (p) => { const c = p?.today_appointments, y = p?.yesterday_today_appointments; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/appointments' }, { key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, getDiff: (p) => { const c = p?.today_follow_ups, y = p?.yesterday_today_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/follow-ups' }, @@ -141,6 +152,14 @@ const ROLE_ACTIONS: Record = { { icon: , label: '告警中心', path: '/health/alert-dashboard' }, { icon: , label: '健康数据', path: '/health/statistics' }, ], + health_manager: [ + { icon: , label: '随访管理', path: '/health/follow-up-tasks' }, + { icon: , label: '体征监测', path: '/health/alert-dashboard' }, + { icon: , label: '患者咨询', path: '/health/consultations' }, + { icon: , label: '患者管理', path: '/health/patients' }, + { icon: , label: '积分商城', path: '/health/points-products' }, + { icon: , label: '统计报表', path: '/health/statistics' }, + ], nurse: [ { icon: , label: '随访管理', path: '/health/follow-ups' }, { icon: , label: '健康数据', path: '/health/vital-signs' }, @@ -230,6 +249,20 @@ export default function Home() { return (
+ {/* 角色工作台路由 */} + {role === 'doctor' ? ( + + ) : role === 'health_manager' ? ( +
+ + +
+ ) : role === 'operator' ? ( + + ) : role === 'admin' ? ( + + ) : ( + <> {/* 欢迎语 */}

- {/* 双栏布局 */} - {(role === 'doctor' || role === 'nurse') ? ( + {/* 双栏布局 — nurse */} + {role === 'nurse' ? (
- {/* 左:待办列表 */}
{ setDrawerItem(item); setDrawerOpen(true); }} />
- - {/* 右:AI 洞察 */}
) : ( @@ -416,13 +446,6 @@ export default function Home() { - {/* 主任团队概览 — 在快捷入口上方,全宽展示 */} - {role === 'admin' && ( -
- -
- )} - {/* 行动详情抽屉 */} + + )}

); } diff --git a/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx b/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx new file mode 100644 index 0000000..14220b1 --- /dev/null +++ b/apps/web/src/pages/health/components/workbench/AdminDashboard.tsx @@ -0,0 +1,301 @@ +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 { + dashboardApi, + type SystemHealthResp, + type UserActivityResp, + type ModuleStatusResp, +} 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 < 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_LABELS: Record = { + 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: '组织', +}; + +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' }, +]; + +export default function AdminDashboard() { + const navigate = useNavigate(); + const user = useAuthStore((s) => s.user); + const statsData = useStatsData(); + const [auditLogs, setAuditLogs] = useState([]); + 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(), + ]); + + 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); + }, []); + + useEffect(() => { fetchData(); }, [fetchData]); + + 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 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 - activeModules} 个插件待启用` }, + { label: '今日操作', value: auditLogs.length, color: '#16A34A', gradient: 'linear-gradient(90deg,#16A34A,#4ADE80)', sub: 'API 请求 · 0 错误' }, + { 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: 'healthy', message: '检测中...', response_ms: null }, + { name: '数据库', status: 'healthy', message: '检测中...', response_ms: null }, + { name: '定时任务', status: 'healthy', 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' }, + ]; + + return ( +
+ {/* 欢迎栏 */} +
+

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

+

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

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

最近操作记录

+ 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; + return ( +
{ e.currentTarget.style.background = '#F8FAFC'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} + > +
{iconCfg.icon}
+ {log.user_id ? log.user_id.slice(0, 6) : '系统'} + + {actionLabel}了{resourceLabel} + + {formatTimeAgo(log.created_at)} +
+ ); + }) + )} +
+ + {/* 模块状态 */} +
+
+

模块状态

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

用户活跃度

+ navigate('/users')}>用户管理 → +
+ {userActivityItems.map((item) => ( +
+ {item.label} +
+
+
+ {item.value} +
+ ))} +
+
按角色分布
+
+ {userActivity?.by_role.map((r) => ( + {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'; }} + > +
{item.icon}
+ {item.text} +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/health/components/workbench/DoctorWorkbench.tsx b/apps/web/src/pages/health/components/workbench/DoctorWorkbench.tsx new file mode 100644 index 0000000..6d9e1f0 --- /dev/null +++ b/apps/web/src/pages/health/components/workbench/DoctorWorkbench.tsx @@ -0,0 +1,282 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../../../stores/auth'; +import { actionInboxApi, type ActionItem, type WorkbenchStats } from '../../../../api/health/actionInbox'; +import { pointsApi, type PersonalStats } from '../../../../api/health/points'; + +const RISK_BADGE: Record = { + high: { label: '高', bg: '#FEF2F2', color: '#DC2626' }, + medium: { label: '中', bg: '#FFFBEB', color: '#D97706' }, + low: { label: '低', bg: '#F0F9FF', color: '#0284C7' }, +}; + +const TYPE_TAG: Record = { + alert: { label: '体征异常', bg: '#FEF2F2', color: '#DC2626' }, + ai_suggestion: { label: 'AI 建议', bg: '#F5F3FF', color: '#7C3AED' }, + followup: { label: '随访到期', bg: '#F0F9FF', color: '#0284C7' }, + data_anomaly: { label: '数据异常', bg: '#FFF7ED', color: '#EA580C' }, + consult: { label: '患者咨询', bg: '#F0F9FF', color: '#0284C7' }, +}; + +function formatTime(dateStr: string): string { + return new Date(dateStr).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); +} + +function getRisk(priority: string): string { + if (priority === 'urgent') return 'high'; + if (priority === 'high') return 'medium'; + return 'low'; +} + +export default function DoctorWorkbench() { + const navigate = useNavigate(); + const user = useAuthStore((s) => s.user); + const [suggestions, setSuggestions] = useState([]); + const [alerts, setAlerts] = useState([]); + const [stats, setStats] = useState(null); + const [personalStats, setPersonalStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + actionInboxApi.list({ status: 'pending', page: 1, page_size: 10 }), + actionInboxApi.stats(), + pointsApi.getPersonalStats().catch(() => null), + ]) + .then(([res, s, ps]) => { + const items = Array.isArray(res?.data) ? res.data : []; + setSuggestions(items.filter((i) => i.action_type === 'ai_suggestion')); + setAlerts(items.filter((i) => i.action_type === 'alert')); + setStats(s ?? null); + setPersonalStats(ps ?? null); + }) + .finally(() => setLoading(false)); + }, []); + + const firstName = user?.display_name ?? user?.username ?? '医生'; + const now = new Date(); + const greeting = now.getHours() < 12 ? '上午好' : now.getHours() < 18 ? '下午好' : '晚上好'; + const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`; + + const statCards = [ + { label: '危急值告警', value: stats?.urgent_alerts ?? alerts.length, color: '#DC2626', gradient: 'linear-gradient(90deg,#DC2626,#F87171)', sub: alerts.length > 0 ? `${alerts[0]?.patient_name ?? ''} · 血压异常` : '暂无', path: '/health/alert-dashboard' }, + { label: 'AI 建议待审', value: stats?.ai_suggestion_pending ?? suggestions.length, color: '#7C3AED', gradient: 'linear-gradient(90deg,#7C3AED,#A78BFA)', sub: `${suggestions.filter((s) => s.priority === 'urgent').length} 高风险`, path: '/health/ai-analysis' }, + { label: '本月咨询', value: personalStats?.consultations_this_month ?? 0, color: '#0284C7', gradient: 'linear-gradient(90deg,#0284C7,#38BDF8)', sub: `未回复 ${stats?.total_pending ?? 0}`, path: '/health/consultations' }, + { label: '今日预约', value: personalStats?.today_appointments ?? 0, color: '#16A34A', gradient: 'linear-gradient(90deg,#16A34A,#4ADE80)', sub: '查看详情', path: '/health/appointments' }, + ]; + + // Focused patients from alerts + AI suggestions + const focusedPatients = Array.from( + new Map([...alerts, ...suggestions].map((i) => [i.patient_id, i])).values(), + ).slice(0, 5); + + return ( +
+ {/* Greeting */} +
+

+ {greeting},{firstName.charAt(0)}医生 +

+

+ 今天有 {stats?.ai_suggestion_pending ?? 0} 项 AI 建议待审、{stats?.urgent_alerts ?? 0} 条告警需处理 · {dateStr} +

+
+ + {/* Stat Cards */} +
+ {statCards.map((card, i) => ( +
navigate(card.path)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter') navigate(card.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'; }} + > +
+
+
{card.label}
+
{loading ? '—' : card.value}
+
{card.sub}
+
+
+ ))} +
+ + {/* Two-column layout */} +
+ {/* Left: AI suggestions + Focused patients */} +
+ {/* AI Suggestions */} +
+
+

+ AI 建议待审 按风险排序 +

+
+
+ {loading ? ( +
加载中...
+ ) : suggestions.length === 0 ? ( +
暂无待审 AI 建议
+ ) : ( + suggestions.slice(0, 5).map((item) => { + const risk = getRisk(item.priority); + const badge = RISK_BADGE[risk] ?? RISK_BADGE.low; + const tag = TYPE_TAG[item.action_type]; + return ( +
navigate('/health/ai-analysis')} + onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} + > +
+ {badge.label} +
+
+
{item.title}
+
{item.summary ?? item.patient_name}
+
+ {tag && {tag.label}} +
+
+
+ + +
+
+ ); + }) + )} +
+
+ + {/* Focused Patients */} +
+
+

重点关注患者

+ navigate('/health/patients')}>患者列表 → +
+
+ {focusedPatients.length === 0 ? ( +
暂无重点关注患者
+ ) : ( + focusedPatients.map((item) => { + const dotColor = item.priority === 'urgent' ? '#DC2626' : item.priority === 'high' ? '#D97706' : '#0284C7'; + const tagBorder = item.priority === 'urgent' ? '#DC2626' : item.priority === 'high' ? '#D97706' : '#E2E8F0'; + const tagColor = item.priority === 'urgent' ? '#DC2626' : item.priority === 'high' ? '#D97706' : '#475569'; + const tagLabel = item.priority === 'urgent' ? '危急' : item.priority === 'high' ? '高关注' : '观察'; + return ( +
navigate(`/health/patients/${item.patient_id}`)} + onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} + > +
+
{item.patient_name}
+
{item.summary ?? item.title}
+
{tagLabel}
+
+ ); + }) + )} +
+
+
+ + {/* Right sidebar */} +
+ {/* Today's Schedule */} +
+
+

今日日程

+ navigate('/health/schedules')}>排班管理 → +
+
+ {/* Use appointments data or static schedule */} +
+
门诊
+
+
今日预约 {personalStats?.today_appointments ?? 0} 位
+
点击查看详情
+
+
+
+
待审
+
+
AI 建议 {stats?.ai_suggestion_pending ?? 0} 条
+
按风险排序
+
+
+
+
+ + {/* Unreplied Consultations */} +
+
+

+ 未回复咨询 +

+ navigate('/health/consultations')}>查看全部 → +
+
+ {alerts.slice(0, 3).map((item) => ( +
navigate('/health/consultations')} + onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} + > +
+
{item.patient_name}
+
{item.summary ?? item.title}
+
{formatTime(item.created_at)}
+
+ ))} + {alerts.length === 0 &&
暂无未回复咨询
} +
+
+ + {/* Quick Actions */} +
+

快捷操作

+ {[ + { icon: '🤖', bg: '#F5F3FF', color: '#7C3AED', text: 'AI 分析中心', sub: '查看全部分析结果', path: '/health/ai-analysis' }, + { icon: '⚠️', bg: '#FEF2F2', color: '#DC2626', text: '告警中心', sub: '处理体征异常告警', path: '/health/alert-dashboard' }, + { icon: '📋', bg: '#F0F9FF', color: '#0284C7', text: '患者查询', sub: '按姓名/编号搜索', path: '/health/patients' }, + ].map((action) => ( +
navigate(action.path)} + onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#2563EB'; e.currentTarget.style.background = '#EFF6FF'; }} + onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#E2E8F0'; e.currentTarget.style.background = 'transparent'; }} + > +
{action.icon}
+
+
{action.text}
+
{action.sub}
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/health/components/workbench/OperatorWorkbench.tsx b/apps/web/src/pages/health/components/workbench/OperatorWorkbench.tsx new file mode 100644 index 0000000..ef3bfc8 --- /dev/null +++ b/apps/web/src/pages/health/components/workbench/OperatorWorkbench.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../../../stores/auth'; +import { actionInboxApi, type WorkbenchStats } from '../../../../api/health/actionInbox'; +import { useStatsData } from '../../StatisticsDashboard/useStatsData'; +import { + dashboardApi, + type PointsActivityItem, + type ArticleStatsResp, +} from '../../../../api/health/dashboard'; + +export default function OperatorWorkbench() { + const navigate = useNavigate(); + const user = useAuthStore((s) => s.user); + const [stats, setStats] = useState(null); + const [pointsActivity, setPointsActivity] = useState([]); + const [articleStats, setArticleStats] = useState(null); + const statsData = useStatsData(); + + useEffect(() => { + actionInboxApi.stats() + .then((s) => setStats(s ?? null)) + .catch(() => {}); + + dashboardApi.getPointsRecentActivity() + .then((d) => setPointsActivity(d ?? [])) + .catch(() => {}); + + dashboardApi.getArticleStats() + .then((d) => setArticleStats(d ?? null)) + .catch(() => {}); + }, []); + + const firstName = user?.display_name ?? user?.username ?? '运营'; + const now = new Date(); + const greeting = now.getHours() < 12 ? '早上好' : now.getHours() < 18 ? '下午好' : '晚上好'; + + const statCards = [ + { label: '今日活跃用户', value: statsData.healthDataStats?.vital_signs_report_rate?.report_rate ?? 0, color: '#2563EB', trend: '', trendDir: '' }, + { label: '科普阅读量', value: articleStats?.total_views ?? statsData.pointsStats?.total_issued ?? 0, color: '#16A34A', trend: '', trendDir: 'up' }, + { label: '积分发放', value: statsData.pointsStats?.total_issued ?? 0, color: '#EA580C', trend: '', trendDir: 'down' }, + { label: '待审核订单', value: stats?.total_pending ?? 0, color: '#E11D48', trend: '', trendDir: 'down' }, + ]; + + const todos = [ + { icon: '🎁', bg: '#FFF1F2', color: '#E11D48', title: `审核 ${stats?.total_pending ?? 0} 笔积分兑换订单`, sub: 'AI 标记异常订单需确认', action: '紧急', path: '/health/points-orders' }, + { icon: '📝', bg: '#F0FDF4', color: '#16A34A', title: '发布新科普文章', sub: `已发布 ${articleStats?.published ?? 0} 篇,草稿 ${articleStats?.draft ?? 0} 篇`, action: '发布', path: '/health/articles/new' }, + { icon: '🎪', bg: '#F0F9FF', color: '#0284C7', title: '推送活动报名提醒', sub: '报名截止临近,需推广', action: '推送', path: '/health/offline-events' }, + { icon: '📊', bg: '#F5F3FF', color: '#7C3AED', title: '整理上周运营周报', sub: '数据已就绪', action: '查看', path: '/health/statistics' }, + { icon: '👥', bg: '#FFFBEB', color: '#D97706', title: '跟进沉默用户', sub: '7 天未上报体征,建议关怀', action: '跟进', path: '/health/patients' }, + ]; + + return ( +
+ {/* AI Hero Card */} +
+
+
+
{greeting},{firstName.charAt(0)}美玲。AI 帮你梳理了今天的运营重点:
+
{stats?.total_pending ?? 0} 个运营洞察需要关注
+
+ 1. 积分兑换活动数据 — 今日发放 {statsData.pointsStats?.total_issued ?? 0} 积分,消费 {statsData.pointsStats?.total_spent ?? 0}。
+ 2. 患者活跃度 — 体征上报率 {statsData.healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}%,持续关注沉默用户。
+ 3. 待处理任务 — {stats?.total_pending ?? 0} 项任务待处理,其中 {stats?.urgent_alerts ?? 0} 项需优先关注。 +
+
+ + + +
+
+ + {/* Stat Cards */} +
+ {statCards.map((card) => ( +
+
{card.label}
+
{card.value.toLocaleString()}
+
+ ))} +
+ + {/* Two-column: Todos + Points/Content */} +
+ {/* Today's Todos */} +
+
+

今日待办

+ navigate('/health/action-inbox')}>全部 → +
+
+ {todos.map((todo) => ( +
navigate(todo.path)} + onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }} + onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} + > +
{todo.icon}
+
+
{todo.title}
+
{todo.sub}
+
+
{todo.action}
+
+ ))} +
+
+ + {/* Points Activity + Content Matrix */} +
+
+

积分动态

+ navigate('/health/points-orders')}>积分管理 → +
+
+ {pointsActivity.length === 0 ? ( +
暂无积分动态
+ ) : ( + pointsActivity.slice(0, 5).map((item, i) => { + const avatarColors = ['#FEF2F2', '#F0F9FF', '#F0FDF4', '#F5F3FF', '#F0FDFA']; + const avatarTextColors = ['#DC2626', '#0284C7', '#16A34A', '#7C3AED', '#0D9488']; + return ( +
+
{item.user_name.charAt(0)}
+
+
{item.user_name}
+
{item.detail}
+
+
{item.amount}
+
+ ); + }) + )} +
+ + {/* Content Matrix */} +
+
内容矩阵
+
+
+
已发布
+
{articleStats?.published ?? 0}
+
+
+
草稿箱
+
{articleStats?.draft ?? 0}
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/pages/health/components/workbench/TaskDetail.tsx b/apps/web/src/pages/health/components/workbench/TaskDetail.tsx new file mode 100644 index 0000000..43b0725 --- /dev/null +++ b/apps/web/src/pages/health/components/workbench/TaskDetail.tsx @@ -0,0 +1,490 @@ +import { useEffect, useState } from 'react'; +import { Button, Skeleton, message, Space } from 'antd'; +import { useWorkbenchStore } from '../../../../stores/workbenchStore'; +import { + actionInboxApi, + type ThreadResponse, + type ActionDefinition, + type ActionType, +} from '../../../../api/health/actionInbox'; +import client from '../../../../api/client'; + +const TYPE_BADGE: Record = { + alert: { label: '体征异常', color: '#DC2626', bg: '#FEF2F2' }, + ai_suggestion: { label: 'AI 分析', color: '#7C3AED', bg: '#F5F3FF' }, + followup: { label: '随访', color: '#0284C7', bg: '#F0F9FF' }, + data_anomaly: { label: '数据异常', color: '#EA580C', bg: '#FFF7ED' }, +}; + +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 < 60) return `${minutes} 分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + return `${Math.floor(hours / 24)} 天前`; +} + +export default function TaskDetail() { + const { tasks, selectedTaskId, completeTask, refreshTasks } = + useWorkbenchStore(); + const [thread, setThread] = useState(null); + const [loading, setLoading] = useState(false); + const [actionLoading, setActionLoading] = useState(false); + + const selectedTask = tasks.find((t) => t.id === selectedTaskId); + + useEffect(() => { + if (!selectedTask?.id) { + setThread(null); + return; + } + setLoading(true); + actionInboxApi + .getThread(selectedTask.id) + .then((resp) => setThread(resp ?? null)) + .catch(() => setThread(null)) + .finally(() => setLoading(false)); + }, [selectedTask?.id]); + + const handleAction = async (endpoint: string) => { + if (!selectedTask) return; + setActionLoading(true); + try { + const uuid = selectedTask.source_ref; + await client.post(endpoint.replace(':id', uuid)); + message.success('操作成功'); + completeTask(selectedTask.id); + refreshTasks(); + } catch { + message.error('操作失败'); + } finally { + setActionLoading(false); + } + }; + + if (!selectedTask) { + return ( +
+
+ 📋 +
+
从左侧选择一个任务开始处理
+
+ ); + } + + const typeBadge = TYPE_BADGE[selectedTask.action_type]; + const actions = thread?.available_actions ?? []; + + const renderFallbackButtons = () => { + if (actions.length > 0) return null; + switch (selectedTask.action_type) { + case 'alert': + return ( + + ); + case 'ai_suggestion': + return ( + + + + + ); + case 'followup': + return ( + + ); + default: + return ( + + ); + } + }; + + return ( +
+ {/* Patient Bar */} +
+
+ {(selectedTask.patient_name ?? '患').charAt(0)} +
+
+
+ {selectedTask.patient_name ?? '未知患者'} +
+
+ {selectedTask.patient_id + ? `患者编号 ${selectedTask.patient_id.slice(0, 8)}` + : ''} +
+
+ {typeBadge && ( + + {typeBadge.label} + + )} + {selectedTask.priority === 'urgent' ? ( + + 超时 {formatTimeAgo(selectedTask.created_at)} + + ) : null} +
+ + {loading ? ( + + ) : ( + <> + {/* AI Suggestion Card */} + {(selectedTask.action_type === 'ai_suggestion' || + selectedTask.action_type === 'alert') && ( +
+
+

+ {selectedTask.action_type === 'ai_suggestion' + ? 'AI 分析建议' + : '异常体征数据'} +

+ + 上报于{' '} + {new Date(selectedTask.created_at).toLocaleTimeString( + 'zh-CN', + { hour: '2-digit', minute: '2-digit' }, + )} + +
+
+
+
+ {selectedTask.action_type === 'ai_suggestion' + ? '风险评估' + : '异常提示'} +
+
+ {selectedTask.summary ?? selectedTask.title} +
+
+
+
+ 建议措施 +
+
+ 请根据患者实际情况,联系患者确认病情并安排相应处理。 +
+
+
+
+ )} + + {/* Thread Timeline */} + {thread?.thread && thread.thread.length > 0 && ( +
+
+

+ 处理记录 +

+
+
+ {thread.thread.map((evt, i) => ( +
+
+ {evt.label} +
+ {evt.detail && ( +
+ {evt.detail} +
+ )} + {evt.timestamp && ( +
+ {evt.timestamp} +
+ )} +
+ ))} +
+
+ )} + + {/* Summary Card for non-AI/alert tasks */} + {selectedTask.action_type !== 'ai_suggestion' && + selectedTask.action_type !== 'alert' && ( +
+
+

+ 任务摘要 +

+
+
+
+ {selectedTask.summary ?? selectedTask.title} +
+
+ 创建于{' '} + {new Date(selectedTask.created_at).toLocaleString( + 'zh-CN', + )} +
+
+
+ )} + + {/* Action Buttons */} +
+ {actions.length > 0 + ? actions.map((action: ActionDefinition) => ( + + )) + : renderFallbackButtons()} + +
+ + )} +
+ ); +} diff --git a/apps/web/src/pages/health/components/workbench/TaskQueue.tsx b/apps/web/src/pages/health/components/workbench/TaskQueue.tsx new file mode 100644 index 0000000..3993479 --- /dev/null +++ b/apps/web/src/pages/health/components/workbench/TaskQueue.tsx @@ -0,0 +1,242 @@ +import { useEffect } from 'react'; +import { Skeleton, Empty } from 'antd'; +import { useWorkbenchStore } from '../../../../stores/workbenchStore'; +import type { ActionItem, ActionPriority, ActionType } from '../../../../api/health/actionInbox'; + +const PRIORITY_DOT: Record = { + urgent: '#DC2626', + high: '#D97706', + medium: '#2563EB', + low: '#94A3B8', +}; + +const TYPE_TAG: Record = { + alert: { label: '体征异常', color: '#DC2626', bg: '#FEF2F2' }, + ai_suggestion: { label: 'AI 建议', color: '#7C3AED', bg: '#F5F3FF' }, + followup: { label: '随访到期', color: '#0284C7', bg: '#F0F9FF' }, + data_anomaly: { label: '数据提醒', color: '#EA580C', bg: '#FFF7ED' }, +}; + +function formatTime(dateStr: string): string { + const d = new Date(dateStr); + const h = d.getHours().toString().padStart(2, '0'); + const m = d.getMinutes().toString().padStart(2, '0'); + return `${h}:${m}`; +} + +function TaskItem({ + item, + selected, + onClick, +}: { + item: ActionItem; + selected: boolean; + onClick: () => void; +}) { + const tag = TYPE_TAG[item.action_type]; + + return ( +
{ + if (!selected) e.currentTarget.style.background = '#EFF6FF'; + }} + onMouseLeave={(e) => { + if (!selected) e.currentTarget.style.background = 'transparent'; + }} + > +
+
+
+ {item.title} +
+
+ {item.summary ?? item.patient_name} +
+
+ {tag && ( + + {tag.label} + + )} +
+
+
+ {formatTime(item.created_at)} +
+
+ ); +} + +export default function TaskQueue() { + const { tasks, selectedTaskId, tab, loading, stats, selectTask, setTab } = + useWorkbenchStore(); + + useEffect(() => { + const { refreshTasks, refreshStats } = useWorkbenchStore.getState(); + refreshTasks(); + refreshStats(); + }, []); + + return ( +
+ {/* Header */} +
+

+ 今日任务流 +

+
+ + 待处理{' '} + + {stats?.total_pending ?? tasks.length} + + + {stats && ( + + 已完成{' '} + {stats.completion_rate ?? 0} + + )} +
+
+ + {/* Tabs */} +
+ {(['pending', 'completed'] as const).map((t) => ( +
setTab(t)} + style={{ + flex: 1, + padding: 10, + textAlign: 'center', + fontSize: 12, + fontWeight: 500, + cursor: 'pointer', + color: tab === t ? '#2563EB' : '#94A3B8', + borderBottom: tab === t ? '2px solid #2563EB' : '2px solid transparent', + transition: 'all 0.15s', + }} + > + {t === 'pending' ? '待处理' : '已完成'} + {t === 'pending' && stats?.total_pending != null && stats.total_pending > 0 && ( + + {stats.total_pending} + + )} +
+ ))} +
+ + {/* Task List */} +
+ {loading ? ( +
+ +
+ ) : tasks.length === 0 ? ( + + ) : ( + tasks.map((item) => ( + selectTask(item.id)} + /> + )) + )} +
+
+ ); +}