Files
hms/apps/web/src/pages/Home.tsx
iven 5e52b0a34c
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
feat(health): 工作台遗留项修复 — UNION ALL 聚合 + 团队概览 + 较昨日对比
1. 待办列表 UNION ALL 聚合:list_action_items 现从 ai_suggestion + alerts + follow_up_task 三表查询,
   ActionType 扩展为 AiSuggestion/Alert/Followup/DataAnomaly 四种类型,
   get_action_thread 按类型构建不同线程时间线(AI 建议/告警/随访)
2. 真实团队概览:get_team_overview 从 doctor_profile + follow_up_task + alerts 聚合成员统计和风险分布
3. 统计卡片较昨日描述:PersonalStatsResp 新增 6 个 yesterday_* 字段,
   Home.tsx 统计卡片底部渲染"较昨日+N"绿色/红色描述
4. 前端 ActionDetailDrawer 改用 item.id(action_type:uuid 格式)调用线程 API
2026-05-01 23:25:38 +08:00

439 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react';
import { Row, Col, Spin, Empty } from 'antd';
import {
UserOutlined,
TeamOutlined,
CalendarOutlined,
HeartOutlined,
MedicineBoxOutlined,
SafetyCertificateOutlined,
MessageOutlined,
BellOutlined,
AlertOutlined,
TrophyOutlined,
ShoppingOutlined,
FileTextOutlined,
RightOutlined,
PartitionOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
ThunderboltOutlined,
SettingOutlined,
ApartmentOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useThemeMode } from '../hooks/useThemeMode';
import { useDashboardRole, type DashboardRole } from '../hooks/useDashboardRole';
import { useMessageStore } from '../stores/message';
import { listAuditLogs, type AuditLogItem } from '../api/auditLogs';
import { listPendingTasks, type TaskInfo } from '../api/workflowTasks';
import { pointsApi, type PersonalStats } from '../api/health/points';
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 type { ActionItem } from '../api/health/actionInbox';
// --- Shared utilities ---
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} 小时前`;
const days = Math.floor(hours / 24);
return `${days} 天前`;
}
const ACTION_LABELS: Record<string, string> = {
create: '创建', created: '创建', update: '更新', updated: '更新', delete: '删除', deleted: '删除',
login: '登录', 'user.create': '创建', 'user.update': '更新', 'user.delete': '删除',
'patient.create': '创建', 'patient.update': '更新', 'appointment.create': '创建',
};
const RESOURCE_LABELS: Record<string, string> = {
user: '用户', role: '角色', patient: '患者', doctor: '医护', appointment: '预约',
follow_up_task: '随访', consultation_session: '咨询', message: '消息', plugin: '插件',
process_instance: '流程实例', organization: '组织',
};
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
user: <UserOutlined />, role: <SafetyCertificateOutlined />,
patient: <UserOutlined />, organization: <ApartmentOutlined />,
process_instance: <FileTextOutlined />, message: <BellOutlined />,
};
function formatActionLabel(action: string): string {
return ACTION_LABELS[action] || ACTION_LABELS[action.split('.').pop() || ''] || action;
}
function formatResourceLabel(resource: string): string {
return RESOURCE_LABELS[resource] || RESOURCE_LABELS[resource.split('.').pop() || ''] || resource;
}
// --- Role configs ---
interface StatCardDef {
key: string;
title: string;
getValue: (p: PersonalStats | null, s: ReturnType<typeof useStatsData>) => number;
getDiff?: (p: PersonalStats | null) => number | undefined;
icon: React.ReactNode;
suffix?: string;
path: string;
}
interface QuickActionDef {
icon: React.ReactNode;
label: string;
path: string;
}
const ROLE_WELCOME: Record<DashboardRole, { title: string; subtitle: string }> = {
doctor: { title: '今日工作台', subtitle: '患者概览与待办事项' },
nurse: { title: '随访监控台', subtitle: '今日随访与体征上报' },
admin: { title: '管理中心', subtitle: '平台运营数据概览' },
operator: { title: '运营中心', subtitle: '积分、内容与活动' },
};
const STAT_BAR_COLORS: string[] = [
'linear-gradient(90deg, #2563EB, #60A5FA)',
'linear-gradient(90deg, #7C3AED, #A78BFA)',
'linear-gradient(90deg, #DC2626, #F87171)',
'linear-gradient(90deg, #D97706, #FBBF24)',
];
const STAT_TEXT_COLORS: string[] = ['#2563EB', '#7C3AED', '#DC2626', '#D97706'];
const ROLE_STATS: Record<DashboardRole, StatCardDef[]> = {
doctor: [
{ key: 'my-patients', title: '我的患者', getValue: (p) => p?.my_patients ?? 0, getDiff: (p) => { const c = p?.my_patients, y = p?.yesterday_my_patients; return c != null && y != null ? c - y : undefined; }, icon: <TeamOutlined />, path: '/health/patients' },
{ 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: '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' },
],
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' },
{ key: 'overdue', title: '逾期随访', getValue: (p) => p?.overdue_follow_ups ?? 0, getDiff: (p) => { const c = p?.overdue_follow_ups, y = p?.yesterday_overdue_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: <AlertOutlined />, path: '/health/follow-ups' },
{ key: 'vital-rate', title: '体征上报率', getValue: (p) => p?.vital_signs_report_rate ?? 0, icon: <MedicineBoxOutlined />, suffix: '%', path: '/health/vital-signs' },
],
admin: [
{ key: 'patients', title: '患者总数', getValue: (_p, s) => s.patientStats?.total_patients ?? 0, icon: <TeamOutlined />, path: '/health/patients' },
{ key: 'appointments', title: '本月预约', getValue: (_p, s) => s.healthDataStats?.appointments?.this_month ?? 0, icon: <CalendarOutlined />, path: '/health/appointments' },
{ key: 'followup-rate', title: '随访完成率', getValue: (_p, s) => s.followUpStats?.completion_rate ?? 0, icon: <SafetyCertificateOutlined />, suffix: '%', path: '/health/follow-ups' },
{ key: 'vital-rate', title: '体征上报率', getValue: (_p, s) => s.healthDataStats?.vital_signs_report_rate?.report_rate ?? 0, icon: <MedicineBoxOutlined />, suffix: '%', path: '/health/vital-signs' },
],
operator: [
{ key: 'issued', title: '积分发放', getValue: (_p, s) => s.pointsStats?.total_issued ?? 0, icon: <TrophyOutlined />, path: '/health/points' },
{ key: 'spent', title: '积分消费', getValue: (_p, s) => s.pointsStats?.total_spent ?? 0, icon: <ShoppingOutlined />, path: '/health/mall' },
{ key: 'active', title: '活跃账户', getValue: (_p, s) => s.pointsStats?.active_accounts ?? 0, icon: <TeamOutlined />, path: '/health/points' },
{ key: 'articles', title: '内容发布', getValue: () => 0, icon: <FileTextOutlined />, path: '/health/content' },
],
};
const ROLE_ACTIONS: Record<DashboardRole, QuickActionDef[]> = {
doctor: [
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
{ icon: <CalendarOutlined />, label: '预约管理', path: '/health/appointments' },
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
{ icon: <MessageOutlined />, label: '咨询管理', path: '/health/consultations' },
{ icon: <AlertOutlined />, label: '告警中心', path: '/health/alert-dashboard' },
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/statistics' },
],
nurse: [
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/vital-signs' },
{ icon: <CalendarOutlined />, label: '预约管理', path: '/health/appointments' },
{ icon: <AlertOutlined />, label: '告警中心', path: '/health/alert-dashboard' },
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
{ icon: <SafetyCertificateOutlined />, label: '健康统计', path: '/health/statistics' },
],
admin: [
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
{ icon: <CalendarOutlined />, label: '预约管理', path: '/health/appointments' },
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/vital-signs' },
{ icon: <TrophyOutlined />, label: '积分商城', path: '/health/points' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings' },
],
operator: [
{ icon: <TrophyOutlined />, label: '积分管理', path: '/health/points' },
{ icon: <FileTextOutlined />, label: '内容管理', path: '/health/content' },
{ icon: <CalendarOutlined />, label: '线下活动', path: '/health/events' },
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
{ icon: <SafetyCertificateOutlined />, label: '健康统计', path: '/health/statistics' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings' },
],
};
// --- Components ---
function StatValue({ value, loading }: { value: number; loading: boolean }) {
const animatedValue = useCountUp(value);
if (loading) return <Spin size="small" />;
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
}
export default function Home() {
const navigate = useNavigate();
const role = useDashboardRole();
const isDark = useThemeMode();
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
const [personalStats, setPersonalStats] = useState<PersonalStats | null>(null);
const [personalLoading, setPersonalLoading] = useState(true);
const [pendingTasks, setPendingTasks] = useState<TaskInfo[]>([]);
const [recentActivities, setRecentActivities] = useState<AuditLogItem[]>([]);
const [activitiesLoading, setActivitiesLoading] = useState(true);
const [drawerItem, setDrawerItem] = useState<ActionItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const statsData = useStatsData();
const loading = personalLoading || statsData.loading;
const welcome = ROLE_WELCOME[role];
const statDefs = ROLE_STATS[role];
const quickActions = ROLE_ACTIONS[role];
useEffect(() => {
let cancelled = false;
fetchUnreadCount();
if (role === 'doctor' || role === 'nurse') {
pointsApi.getPersonalStats()
.then((data) => { if (!cancelled) setPersonalStats(data); })
.catch(() => {})
.finally(() => { if (!cancelled) setPersonalLoading(false); });
} else {
setPersonalLoading(false);
}
listPendingTasks(1, 5)
.then((result) => { if (!cancelled) setPendingTasks(result.data); })
.catch(() => {});
listAuditLogs({ page: 1, page_size: 5 })
.then((result) => {
if (!cancelled) setRecentActivities(result.data.filter((a) => a.action !== 'login_failed'));
})
.catch(() => {})
.finally(() => { if (!cancelled) setActivitiesLoading(false); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [role]);
const handleNavigate = useCallback((path: string) => {
navigate(path);
}, [navigate]);
return (
<div>
{/* 欢迎语 */}
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
<h2 style={{
fontSize: 24,
fontWeight: 700,
color: isDark ? '#f8fafc' : 'var(--erp-text-primary)',
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}>
{welcome.title}
</h2>
<p style={{ fontSize: 14, color: 'var(--erp-text-secondary)', margin: 0 }}>
{welcome.subtitle}
</p>
</div>
{/* 统计卡片行 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
{statDefs.map((def, i) => {
const value = def.getValue(personalStats, statsData);
const diff = def.getDiff?.(personalStats);
return (
<div
key={def.key}
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={() => handleNavigate(def.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(def.path); }}
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.06)'; e.currentTarget.style.transform = 'translateY(-1px)'; }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'none'; }}
>
<div style={{ height: 3, background: STAT_BAR_COLORS[i] || STAT_BAR_COLORS[0] }} />
<div style={{ padding: '16px 20px' }}>
<div style={{ fontSize: 12, color: '#94A3B8', marginBottom: 6 }}>{def.title}</div>
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1.2, color: STAT_TEXT_COLORS[i] || STAT_TEXT_COLORS[0] }}>
<StatValue value={value} loading={loading} />
{def.suffix && <span style={{ fontSize: 14, marginLeft: 2 }}>{def.suffix}</span>}
</div>
{diff != null && (
<div style={{ fontSize: 11, marginTop: 4, color: diff > 0 ? '#16A34A' : diff < 0 ? '#DC2626' : '#94A3B8' }}>
{diff === 0 ? '与昨日持平' : `较昨日 ${diff > 0 ? '+' : ''}${diff}`}
</div>
)}
</div>
</div>
);
})}
</div>
{/* 双栏布局 */}
{(role === 'doctor' || role === 'nurse') ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: 20, marginBottom: 24 }}>
{/* 左:待办列表 */}
<div style={{
background: 'var(--erp-bg-card, white)',
borderRadius: 12,
border: '1px solid var(--erp-border, #E2E8F0)',
overflow: 'hidden',
}}>
<div style={{
padding: '16px 20px',
borderBottom: '1px solid #F1F5F9',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontSize: 15, fontWeight: 600 }}></span>
</div>
<TodoList onItemClick={(item) => { setDrawerItem(item); setDrawerOpen(true); }} />
</div>
{/* 右AI 洞察 */}
<AiInsightPanel />
</div>
) : (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={14}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2">
<div className="erp-section-header">
<CheckCircleOutlined className="erp-section-icon" />
<span className="erp-section-title"></span>
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--erp-text-secondary)' }}>
{pendingTasks.length}
</span>
</div>
<div className="erp-task-list">
{pendingTasks.length === 0 ? (
<Empty description="暂无待办任务" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
pendingTasks.map((task) => (
<div
key={task.id}
className="erp-task-item"
style={{ '--task-color': 'var(--erp-primary)' } as React.CSSProperties}
onClick={() => handleNavigate('/workflow')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate('/workflow'); }}
>
<div className="erp-task-item-icon"><PartitionOutlined /></div>
<div className="erp-task-item-content">
<div className="erp-task-item-title">{task.node_name || task.definition_name || '流程任务'}</div>
<div className="erp-task-item-meta">
<span>{task.definition_name || '工作流'}</span>
<span>{task.status === 'pending' ? '待处理' : task.status}</span>
</div>
</div>
<span className="erp-task-priority erp-task-priority-medium"></span>
<RightOutlined style={{ color: 'var(--erp-text-tertiary)', fontSize: 12 }} />
</div>
))
)}
</div>
</div>
</Col>
<Col xs={24} lg={10}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}>
<div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" />
<span className="erp-section-title"></span>
</div>
<div className="erp-activity-list">
{activitiesLoading ? (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
) : recentActivities.length === 0 ? (
<Empty description="暂无动态" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
recentActivities.map((log) => (
<div key={log.id} className="erp-activity-item">
<div className="erp-activity-dot">
{RESOURCE_ICONS[log.resource_type] || <FileTextOutlined />}
</div>
<div className="erp-activity-content">
<div className="erp-activity-text">
{formatActionLabel(log.action)}{formatResourceLabel(log.resource_type)}
</div>
<div className="erp-activity-time">{formatTimeAgo(log.created_at)}</div>
</div>
</div>
))
)}
</div>
</div>
</Col>
</Row>
)}
{/* 快捷入口 */}
<Row gutter={[16, 16]}>
<Col span={24}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3">
<div className="erp-section-header">
<ThunderboltOutlined className="erp-section-icon" />
<span className="erp-section-title"></span>
</div>
<Row gutter={[12, 12]}>
{quickActions.map((action) => (
<Col xs={12} sm={8} md={4} key={action.path}>
<div
className="erp-quick-action"
style={{ '--action-color': 'var(--erp-primary)' } as React.CSSProperties}
onClick={() => handleNavigate(action.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(action.path); }}
>
<div className="erp-quick-action-icon">{action.icon}</div>
<span className="erp-quick-action-label">{action.label}</span>
</div>
</Col>
))}
</Row>
</div>
</Col>
</Row>
{/* 主任团队概览 — 在快捷入口上方,全宽展示 */}
{role === 'admin' && (
<div style={{ marginBottom: 24 }}>
<TeamOverviewPanel />
</div>
)}
{/* 行动详情抽屉 */}
<ActionDetailDrawer
item={drawerItem}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setDrawerItem(null); }}
onActionComplete={() => {
setDrawerOpen(false);
setDrawerItem(null);
}}
/>
</div>
);
}