feat(web): 工作台页面改造 — 管理员/运营数据改用真实 API
- AdminDashboard 移除硬编码模块列表,改用 system-health/user-activity/modules API - OperatorWorkbench 移除硬编码积分动态和文章统计,改用 points-recent-activity/article-stats API - 新增 dashboard.ts API 客户端,AxiosResponse 解包到 data.data - Home.tsx 集成 4 个角色工作台组件路由 - useDashboardRole 支持 health_manager 角色
This commit is contained in:
69
apps/web/src/api/health/dashboard.ts
Normal file
69
apps/web/src/api/health/dashboard.ts
Normal file
@@ -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),
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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<DashboardRole, { title: string; subtitle: string }> = {
|
||||
doctor: { title: '今日工作台', subtitle: '患者概览与待办事项' },
|
||||
health_manager: { title: '任务工作台', subtitle: '待处理任务与患者管理' },
|
||||
nurse: { title: '随访监控台', subtitle: '今日随访与体征上报' },
|
||||
admin: { title: '管理中心', subtitle: '平台运营数据概览' },
|
||||
operator: { title: '运营中心', subtitle: '积分、内容与活动' },
|
||||
@@ -112,6 +117,12 @@ const ROLE_STATS: Record<DashboardRole, StatCardDef[]> = {
|
||||
{ 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: <MessageOutlined />, path: '/health/consultations' },
|
||||
{ key: 'followup-rate', title: '随访完成率', getValue: (p) => p?.follow_up_rate ?? 0, icon: <HeartOutlined />, 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: <HeartOutlined />, path: '/health/follow-up-tasks' },
|
||||
{ key: 'vital-anomaly', title: '体征异常', getValue: (p) => p?.overdue_follow_ups ?? 0, icon: <AlertOutlined />, path: '/health/alert-dashboard' },
|
||||
{ key: 'ai-pending', title: 'AI 建议待审', getValue: (p) => p?.consultations_this_month ?? 0, icon: <MedicineBoxOutlined />, path: '/health/ai-analysis' },
|
||||
{ key: 'followup-rate', title: '处理率', getValue: (p) => p?.follow_up_rate ?? 0, icon: <CheckCircleOutlined />, 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: <CalendarOutlined />, 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: <HeartOutlined />, path: '/health/follow-ups' },
|
||||
@@ -141,6 +152,14 @@ const ROLE_ACTIONS: Record<DashboardRole, QuickActionDef[]> = {
|
||||
{ icon: <AlertOutlined />, label: '告警中心', path: '/health/alert-dashboard' },
|
||||
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/statistics' },
|
||||
],
|
||||
health_manager: [
|
||||
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-up-tasks' },
|
||||
{ icon: <MedicineBoxOutlined />, label: '体征监测', path: '/health/alert-dashboard' },
|
||||
{ icon: <MessageOutlined />, label: '患者咨询', path: '/health/consultations' },
|
||||
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
|
||||
{ icon: <TrophyOutlined />, label: '积分商城', path: '/health/points-products' },
|
||||
{ icon: <FileTextOutlined />, label: '统计报表', path: '/health/statistics' },
|
||||
],
|
||||
nurse: [
|
||||
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
|
||||
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/vital-signs' },
|
||||
@@ -230,6 +249,20 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 角色工作台路由 */}
|
||||
{role === 'doctor' ? (
|
||||
<DoctorWorkbench />
|
||||
) : role === 'health_manager' ? (
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 64px)', overflow: 'hidden', margin: -20 }}>
|
||||
<TaskQueue />
|
||||
<TaskDetail />
|
||||
</div>
|
||||
) : role === 'operator' ? (
|
||||
<OperatorWorkbench />
|
||||
) : role === 'admin' ? (
|
||||
<AdminDashboard />
|
||||
) : (
|
||||
<>
|
||||
{/* 欢迎语 */}
|
||||
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
|
||||
<h2 style={{
|
||||
@@ -288,10 +321,9 @@ export default function Home() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 双栏布局 */}
|
||||
{(role === 'doctor' || role === 'nurse') ? (
|
||||
{/* 双栏布局 — nurse */}
|
||||
{role === 'nurse' ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 20, marginBottom: 24 }}>
|
||||
{/* 左:待办列表 */}
|
||||
<div style={{
|
||||
background: 'var(--erp-bg-card, white)',
|
||||
borderRadius: 12,
|
||||
@@ -309,8 +341,6 @@ export default function Home() {
|
||||
</div>
|
||||
<TodoList onItemClick={(item) => { setDrawerItem(item); setDrawerOpen(true); }} />
|
||||
</div>
|
||||
|
||||
{/* 右:AI 洞察 */}
|
||||
<AiInsightPanel />
|
||||
</div>
|
||||
) : (
|
||||
@@ -416,13 +446,6 @@ export default function Home() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 主任团队概览 — 在快捷入口上方,全宽展示 */}
|
||||
{role === 'admin' && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<TeamOverviewPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 行动详情抽屉 */}
|
||||
<ActionDetailDrawer
|
||||
item={drawerItem}
|
||||
@@ -433,6 +456,8 @@ export default function Home() {
|
||||
setDrawerItem(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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': '删除',
|
||||
};
|
||||
const RESOURCE_LABELS: Record<string, string> = {
|
||||
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<AuditLogItem[]>([]);
|
||||
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(),
|
||||
]);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{/* 欢迎栏 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, margin: '0 0 4px' }}>
|
||||
{greeting},{firstName.charAt(0)}主任
|
||||
</h1>
|
||||
<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',
|
||||
}}>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<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 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 双栏:审计日志 + 模块状态 */}
|
||||
<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',
|
||||
}}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>最近操作记录</h3>
|
||||
<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>
|
||||
) : (
|
||||
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 (
|
||||
<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' }}>
|
||||
{actionLabel}了{resourceLabel}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#94A3B8', flexShrink: 0 }}>{formatTimeAgo(log.created_at)}</span>
|
||||
</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',
|
||||
}}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>模块状态</h3>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 双栏:用户活跃度 + 快捷管理 */}
|
||||
<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',
|
||||
}}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>用户活跃度</h3>
|
||||
<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>
|
||||
<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 }}>
|
||||
{userActivity?.by_role.map((r) => (
|
||||
<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',
|
||||
}}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>系统管理</h3>
|
||||
</div>
|
||||
<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',
|
||||
}}
|
||||
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'; }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, { label: string; bg: string; color: string }> = {
|
||||
high: { label: '高', bg: '#FEF2F2', color: '#DC2626' },
|
||||
medium: { label: '中', bg: '#FFFBEB', color: '#D97706' },
|
||||
low: { label: '低', bg: '#F0F9FF', color: '#0284C7' },
|
||||
};
|
||||
|
||||
const TYPE_TAG: Record<string, { label: string; bg: string; color: string }> = {
|
||||
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<ActionItem[]>([]);
|
||||
const [alerts, setAlerts] = useState<ActionItem[]>([]);
|
||||
const [stats, setStats] = useState<WorkbenchStats | null>(null);
|
||||
const [personalStats, setPersonalStats] = useState<PersonalStats | null>(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 (
|
||||
<div>
|
||||
{/* Greeting */}
|
||||
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, margin: '0 0 4px' }}>
|
||||
{greeting},{firstName.charAt(0)}医生
|
||||
</h1>
|
||||
<p style={{ color: '#94A3B8', fontSize: 13, margin: 0 }}>
|
||||
今天有 {stats?.ai_suggestion_pending ?? 0} 项 AI 建议待审、{stats?.urgent_alerts ?? 0} 条告警需处理 · {dateStr}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 24 }}>
|
||||
{statCards.map((card, i) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={`erp-fade-in erp-fade-in-delay-${i + 1}`}
|
||||
style={{
|
||||
background: 'var(--erp-bg-card, white)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--erp-border, #E2E8F0)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onClick={() => 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'; }}
|
||||
>
|
||||
<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, lineHeight: 1.2, color: card.color }}>{loading ? '—' : card.value}</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 3 }}>{card.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 20 }}>
|
||||
{/* Left: AI suggestions + Focused patients */}
|
||||
<div>
|
||||
{/* AI Suggestions */}
|
||||
<div style={{ background: 'var(--erp-bg-card, white)', borderRadius: 12, border: '1px solid var(--erp-border, #E2E8F0)', overflow: 'hidden', marginBottom: 16 }}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid #F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>
|
||||
AI 建议待审 <span style={{ fontSize: 12, color: '#94A3B8', fontWeight: 400 }}>按风险排序</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#94A3B8' }}>加载中...</div>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#94A3B8' }}>暂无待审 AI 建议</div>
|
||||
) : (
|
||||
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 (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{ display: 'flex', gap: 12, padding: '12px 20px', borderBottom: '1px solid #F1F5F9', cursor: 'pointer', transition: 'all 0.15s' }}
|
||||
onClick={() => navigate('/health/ai-analysis')}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: badge.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, color: badge.color, flexShrink: 0 }}>
|
||||
{badge.label}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{item.title}</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 2 }}>{item.summary ?? item.patient_name}</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
{tag && <span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 500, background: tag.bg, color: tag.color }}>{tag.label}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
|
||||
<button style={{ padding: '4px 10px', borderRadius: 4, fontSize: 11, border: '1px solid #2563EB', background: '#2563EB', color: 'white', cursor: 'pointer', fontFamily: 'inherit' }}>采纳</button>
|
||||
<button style={{ padding: '4px 10px', borderRadius: 4, fontSize: 11, border: '1px solid #E2E8F0', background: 'white', cursor: 'pointer', fontFamily: 'inherit' }}>拒绝</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Focused Patients */}
|
||||
<div style={{ background: 'var(--erp-bg-card, white)', borderRadius: 12, border: '1px solid var(--erp-border, #E2E8F0)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid #F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>重点关注患者</h3>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>患者列表 →</span>
|
||||
</div>
|
||||
<div>
|
||||
{focusedPatients.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#94A3B8' }}>暂无重点关注患者</div>
|
||||
) : (
|
||||
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 (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid #F1F5F9', cursor: 'pointer', transition: 'all 0.15s' }}
|
||||
onClick={() => navigate(`/health/patients/${item.patient_id}`)}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: dotColor, flexShrink: 0 }} />
|
||||
<div style={{ fontSize: 13, fontWeight: 500, flexShrink: 0 }}>{item.patient_name}</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', flex: 1, whiteSpace: 'nowrap' as const, overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.summary ?? item.title}</div>
|
||||
<div style={{ fontSize: 10, padding: '2px 8px', borderRadius: 4, border: `1px solid ${tagBorder}`, color: tagColor, flexShrink: 0 }}>{tagLabel}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right sidebar */}
|
||||
<div>
|
||||
{/* Today's Schedule */}
|
||||
<div style={{ background: 'var(--erp-bg-card, white)', borderRadius: 12, border: '1px solid var(--erp-border, #E2E8F0)', overflow: 'hidden', marginBottom: 16 }}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid #F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>今日日程</h3>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/health/schedules')}>排班管理 →</span>
|
||||
</div>
|
||||
<div>
|
||||
{/* Use appointments data or static schedule */}
|
||||
<div style={{ padding: '10px 16px', borderBottom: '1px solid #F1F5F9', display: 'flex', gap: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#2563EB', flexShrink: 0, width: 44 }}>门诊</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>今日预约 {personalStats?.today_appointments ?? 0} 位</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>点击查看详情</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '10px 16px', display: 'flex', gap: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#2563EB', flexShrink: 0, width: 44 }}>待审</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>AI 建议 {stats?.ai_suggestion_pending ?? 0} 条</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>按风险排序</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unreplied Consultations */}
|
||||
<div style={{ background: 'var(--erp-bg-card, white)', borderRadius: 12, border: '1px solid var(--erp-border, #E2E8F0)', overflow: 'hidden', marginBottom: 16 }}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid #F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: '#0284C7' }}>●</span> 未回复咨询
|
||||
</h3>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>查看全部 →</span>
|
||||
</div>
|
||||
<div>
|
||||
{alerts.slice(0, 3).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderBottom: '1px solid #F1F5F9', cursor: 'pointer', transition: 'all 0.15s' }}
|
||||
onClick={() => navigate('/health/consultations')}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#2563EB', flexShrink: 0 }} />
|
||||
<div style={{ fontSize: 12, fontWeight: 500, flexShrink: 0 }}>{item.patient_name}</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', flex: 1, whiteSpace: 'nowrap' as const, overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.summary ?? item.title}</div>
|
||||
<div style={{ fontSize: 10, color: '#94A3B8', flexShrink: 0 }}>{formatTime(item.created_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
{alerts.length === 0 && <div style={{ padding: 16, textAlign: 'center', color: '#94A3B8', fontSize: 13 }}>暂无未回复咨询</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div style={{ background: 'var(--erp-bg-card, white)', borderRadius: 12, border: '1px solid var(--erp-border, #E2E8F0)', padding: '14px 16px' }}>
|
||||
<h3 style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>快捷操作</h3>
|
||||
{[
|
||||
{ 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) => (
|
||||
<div
|
||||
key={action.path}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderRadius: 10, cursor: 'pointer', transition: 'all 0.15s', border: '1px solid #E2E8F0', marginBottom: 8 }}
|
||||
onClick={() => 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'; }}
|
||||
>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: action.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15, flexShrink: 0 }}>{action.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{action.text}</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>{action.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<WorkbenchStats | null>(null);
|
||||
const [pointsActivity, setPointsActivity] = useState<PointsActivityItem[]>([]);
|
||||
const [articleStats, setArticleStats] = useState<ArticleStatsResp | null>(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 (
|
||||
<div>
|
||||
{/* AI Hero Card */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #EA580C 0%, #C2410C 50%, #9A3412 100%)',
|
||||
borderRadius: 16,
|
||||
padding: '24px 28px',
|
||||
marginBottom: 24,
|
||||
color: 'white',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
} as React.CSSProperties}
|
||||
className="erp-fade-in">
|
||||
<div style={{ position: 'absolute', top: -30, right: -30, width: 150, height: 150, background: 'rgba(255,255,255,0.06)', borderRadius: '50%' }} />
|
||||
<div style={{ position: 'absolute', bottom: -50, left: '25%', width: 200, height: 200, background: 'rgba(255,255,255,0.04)', borderRadius: '50%' }} />
|
||||
<div style={{ fontSize: 14, opacity: 0.85, marginBottom: 4, position: 'relative', zIndex: 1 }}>{greeting},{firstName.charAt(0)}美玲。AI 帮你梳理了今天的运营重点:</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 14, position: 'relative', zIndex: 1 }}>{stats?.total_pending ?? 0} 个运营洞察需要关注</div>
|
||||
<div style={{ fontSize: 13, lineHeight: 2, opacity: 0.92, position: 'relative', zIndex: 1 }}>
|
||||
<b style={{ color: '#FED7AA', fontWeight: 600 }}>1. 积分兑换活动数据</b> — 今日发放 {statsData.pointsStats?.total_issued ?? 0} 积分,消费 {statsData.pointsStats?.total_spent ?? 0}。<br />
|
||||
<b style={{ color: '#FED7AA', fontWeight: 600 }}>2. 患者活跃度</b> — 体征上报率 {statsData.healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}%,持续关注沉默用户。<br />
|
||||
<b style={{ color: '#FED7AA', fontWeight: 600 }}>3. 待处理任务</b> — {stats?.total_pending ?? 0} 项任务待处理,其中 {stats?.urgent_alerts ?? 0} 项需优先关注。
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, position: 'relative', zIndex: 1, flexWrap: 'wrap' as const }}>
|
||||
<button onClick={() => navigate('/health/points-orders')} style={{ padding: '7px 16px', borderRadius: 6, fontSize: 12, fontWeight: 500, cursor: 'pointer', border: 'none', fontFamily: 'inherit', background: 'white', color: '#EA580C' }}>审核积分订单</button>
|
||||
<button onClick={() => navigate('/health/articles/new')} style={{ padding: '7px 16px', borderRadius: 6, fontSize: 12, fontWeight: 500, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.25)', fontFamily: 'inherit', background: 'rgba(255,255,255,0.15)', color: 'white' }}>发布新文章</button>
|
||||
<button onClick={() => navigate('/health/offline-events')} style={{ padding: '7px 16px', borderRadius: 6, fontSize: 12, fontWeight: 500, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.25)', fontFamily: 'inherit', background: 'rgba(255,255,255,0.15)', color: 'white' }}>推送活动提醒</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 20 }}>
|
||||
{statCards.map((card) => (
|
||||
<div key={card.label} style={{ background: 'var(--erp-bg-card, white)', borderRadius: 12, border: '1px solid var(--erp-border, #E2E8F0)', padding: '14px 18px' }}>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', marginBottom: 2 }}>{card.label}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: card.color }}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-column: Todos + Points/Content */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
{/* Today's Todos */}
|
||||
<div style={{ background: 'var(--erp-bg-card, white)', borderRadius: 12, border: '1px solid var(--erp-border, #E2E8F0)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid #F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, margin: 0 }}>今日待办</h3>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/health/action-inbox')}>全部 →</span>
|
||||
</div>
|
||||
<div>
|
||||
{todos.map((todo) => (
|
||||
<div
|
||||
key={todo.path}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 18px', borderBottom: '1px solid #F1F5F9', cursor: 'pointer', transition: 'all 0.15s' }}
|
||||
onClick={() => navigate(todo.path)}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: todo.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, flexShrink: 0 }}>{todo.icon}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{todo.title}</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>{todo.sub}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, padding: '3px 10px', borderRadius: 4, border: '1px solid #E2E8F0', color: '#475569', cursor: 'pointer', flexShrink: 0 }}>{todo.action}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points Activity + Content Matrix */}
|
||||
<div style={{ background: 'var(--erp-bg-card, white)', borderRadius: 12, border: '1px solid var(--erp-border, #E2E8F0)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid #F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600, margin: 0 }}>积分动态</h3>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/health/points-orders')}>积分管理 →</span>
|
||||
</div>
|
||||
<div>
|
||||
{pointsActivity.length === 0 ? (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: '#94A3B8', fontSize: 13 }}>暂无积分动态</div>
|
||||
) : (
|
||||
pointsActivity.slice(0, 5).map((item, i) => {
|
||||
const avatarColors = ['#FEF2F2', '#F0F9FF', '#F0FDF4', '#F5F3FF', '#F0FDFA'];
|
||||
const avatarTextColors = ['#DC2626', '#0284C7', '#16A34A', '#7C3AED', '#0D9488'];
|
||||
return (
|
||||
<div key={item.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 18px', borderBottom: i < Math.min(pointsActivity.length, 5) - 1 ? '1px solid #F1F5F9' : undefined }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: avatarColors[i % 5], display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 600, color: avatarTextColors[i % 5], flexShrink: 0 }}>{item.user_name.charAt(0)}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 500 }}>{item.user_name}</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>{item.detail}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: item.type === 'earn' ? '#16A34A' : '#EA580C' }}>{item.amount}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Matrix */}
|
||||
<div style={{ padding: '16px 18px', borderTop: '1px solid #F1F5F9' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>内容矩阵</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 14 }}>
|
||||
<div style={{ padding: '10px 14px', background: '#EFF6FF', borderRadius: 10 }}>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>已发布</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#2563EB' }}>{articleStats?.published ?? 0}</div>
|
||||
</div>
|
||||
<div style={{ padding: '10px 14px', background: '#F0FDF4', borderRadius: 10 }}>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>草稿箱</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#16A34A' }}>{articleStats?.draft ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
490
apps/web/src/pages/health/components/workbench/TaskDetail.tsx
Normal file
490
apps/web/src/pages/health/components/workbench/TaskDetail.tsx
Normal file
@@ -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<ActionType, { label: string; color: string; bg: string }> = {
|
||||
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<ThreadResponse | null>(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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#94A3B8',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 40, marginBottom: 12, opacity: 0.3 }}>
|
||||
📋
|
||||
</div>
|
||||
<div>从左侧选择一个任务开始处理</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
loading={actionLoading}
|
||||
onClick={() =>
|
||||
handleAction('/health/alerts/:id/acknowledge')
|
||||
}
|
||||
>
|
||||
立即处理
|
||||
</Button>
|
||||
);
|
||||
case 'ai_suggestion':
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={actionLoading}
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
'/health/ai-analysis/:id/review?action=approve',
|
||||
)
|
||||
}
|
||||
>
|
||||
采纳建议
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
loading={actionLoading}
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
'/health/ai-analysis/:id/review?action=reject',
|
||||
)
|
||||
}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
case 'followup':
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={actionLoading}
|
||||
onClick={() =>
|
||||
handleAction('/health/follow-up/:id/complete')
|
||||
}
|
||||
>
|
||||
完成随访
|
||||
</Button>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Button type="primary" loading={actionLoading}>
|
||||
查看详情
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '24px 28px',
|
||||
background: '#F8FAFC',
|
||||
}}
|
||||
>
|
||||
{/* Patient Bar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
padding: '16px 20px',
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #E2E8F0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: '#EFF6FF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#2563EB',
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{(selectedTask.patient_name ?? '患').charAt(0)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
{selectedTask.patient_name ?? '未知患者'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: '#94A3B8',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{selectedTask.patient_id
|
||||
? `患者编号 ${selectedTask.patient_id.slice(0, 8)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
{typeBadge && (
|
||||
<span
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
background: typeBadge.bg,
|
||||
color: typeBadge.color,
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{typeBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{selectedTask.priority === 'urgent' ? (
|
||||
<span
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
background: '#FFFBEB',
|
||||
color: '#D97706',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
超时 {formatTimeAgo(selectedTask.created_at)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : (
|
||||
<>
|
||||
{/* AI Suggestion Card */}
|
||||
{(selectedTask.action_type === 'ai_suggestion' ||
|
||||
selectedTask.action_type === 'alert') && (
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #E2E8F0',
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #F1F5F9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>
|
||||
{selectedTask.action_type === 'ai_suggestion'
|
||||
? 'AI 分析建议'
|
||||
: '异常体征数据'}
|
||||
</h3>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color:
|
||||
selectedTask.priority === 'urgent'
|
||||
? '#DC2626'
|
||||
: '#94A3B8',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
上报于{' '}
|
||||
{new Date(selectedTask.created_at).toLocaleTimeString(
|
||||
'zh-CN',
|
||||
{ hour: '2-digit', minute: '2-digit' },
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 20px',
|
||||
background:
|
||||
selectedTask.action_type === 'ai_suggestion'
|
||||
? '#F5F3FF'
|
||||
: '#FEF2F2',
|
||||
borderRadius: 10,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color:
|
||||
selectedTask.action_type === 'ai_suggestion'
|
||||
? '#7C3AED'
|
||||
: '#DC2626',
|
||||
fontWeight: 600,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{selectedTask.action_type === 'ai_suggestion'
|
||||
? '风险评估'
|
||||
: '异常提示'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: '#1E293B',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{selectedTask.summary ?? selectedTask.title}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 20px',
|
||||
background: '#F0F9FF',
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#0284C7',
|
||||
fontWeight: 600,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
建议措施
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: '#1E293B',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
请根据患者实际情况,联系患者确认病情并安排相应处理。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thread Timeline */}
|
||||
{thread?.thread && thread.thread.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #E2E8F0',
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #F1F5F9',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>
|
||||
处理记录
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
{thread.thread.map((evt, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
borderBottom:
|
||||
i < thread.thread.length - 1
|
||||
? '1px solid #F1F5F9'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: '#94A3B8' }}>
|
||||
{evt.label}
|
||||
</div>
|
||||
{evt.detail && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: '#475569',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{evt.detail}
|
||||
</div>
|
||||
)}
|
||||
{evt.timestamp && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{evt.timestamp}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Card for non-AI/alert tasks */}
|
||||
{selectedTask.action_type !== 'ai_suggestion' &&
|
||||
selectedTask.action_type !== 'alert' && (
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #E2E8F0',
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #F1F5F9',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>
|
||||
任务摘要
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ padding: '12px 20px' }}>
|
||||
<div style={{ fontSize: 13, color: '#475569' }}>
|
||||
{selectedTask.summary ?? selectedTask.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
创建于{' '}
|
||||
{new Date(selectedTask.created_at).toLocaleString(
|
||||
'zh-CN',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: 'flex', gap: 10, padding: '8px 0' }}>
|
||||
{actions.length > 0
|
||||
? actions.map((action: ActionDefinition) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type={action.variant === 'primary' ? 'primary' : 'default'}
|
||||
danger={action.variant === 'danger'}
|
||||
loading={actionLoading}
|
||||
onClick={() =>
|
||||
action.api_endpoint &&
|
||||
handleAction(action.api_endpoint)
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))
|
||||
: renderFallbackButtons()}
|
||||
<Button
|
||||
style={{ marginLeft: 'auto' }}
|
||||
onClick={() => {
|
||||
useWorkbenchStore.getState().selectTask(null);
|
||||
}}
|
||||
>
|
||||
稍后处理
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
apps/web/src/pages/health/components/workbench/TaskQueue.tsx
Normal file
242
apps/web/src/pages/health/components/workbench/TaskQueue.tsx
Normal file
@@ -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<ActionPriority, string> = {
|
||||
urgent: '#DC2626',
|
||||
high: '#D97706',
|
||||
medium: '#2563EB',
|
||||
low: '#94A3B8',
|
||||
};
|
||||
|
||||
const TYPE_TAG: Record<ActionType, { label: string; color: string; bg: string }> = {
|
||||
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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
background: selected ? '#EFF6FF' : 'transparent',
|
||||
borderLeft: selected ? '3px solid #2563EB' : '3px solid transparent',
|
||||
borderBottom: '1px solid #F1F5F9',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!selected) e.currentTarget.style.background = '#EFF6FF';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!selected) e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: PRIORITY_DOT[item.priority] ?? PRIORITY_DOT.low,
|
||||
marginTop: 5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, lineHeight: 1.3 }}>
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
marginTop: 3,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.summary ?? item.patient_name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
{tag && (
|
||||
<span
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
background: tag.bg,
|
||||
color: tag.color,
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#94A3B8',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{formatTime(item.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskQueue() {
|
||||
const { tasks, selectedTaskId, tab, loading, stats, selectTask, setTab } =
|
||||
useWorkbenchStore();
|
||||
|
||||
useEffect(() => {
|
||||
const { refreshTasks, refreshStats } = useWorkbenchStore.getState();
|
||||
refreshTasks();
|
||||
refreshStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 340,
|
||||
background: '#fff',
|
||||
borderRight: '1px solid #E2E8F0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #E2E8F0',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>
|
||||
今日任务流
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
待处理{' '}
|
||||
<b style={{ color: '#DC2626' }}>
|
||||
{stats?.total_pending ?? tasks.length}
|
||||
</b>
|
||||
</span>
|
||||
{stats && (
|
||||
<span>
|
||||
已完成{' '}
|
||||
<b style={{ color: '#16A34A' }}>{stats.completion_rate ?? 0}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #E2E8F0',
|
||||
}}
|
||||
>
|
||||
{(['pending', 'completed'] as const).map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
onClick={() => 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 && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: '#DC2626',
|
||||
color: 'white',
|
||||
fontSize: 9,
|
||||
padding: '0px 5px',
|
||||
borderRadius: 8,
|
||||
marginLeft: 4,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{stats.total_pending}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<Empty
|
||||
description={
|
||||
tab === 'pending'
|
||||
? '所有任务已处理完毕'
|
||||
: '暂无已完成任务'
|
||||
}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
) : (
|
||||
tasks.map((item) => (
|
||||
<TaskItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedTaskId === item.id}
|
||||
onClick={() => selectTask(item.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user