feat(web): 工作台页面改造 — 管理员/运营数据改用真实 API
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 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:
iven
2026-05-02 11:56:26 +08:00
parent 0006e427e2
commit dd44c1526f
8 changed files with 1589 additions and 15 deletions

View 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),
};

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}