Files
hms/apps/web/src/pages/Home.tsx
iven 07217336e7
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
fix(web): 运营仪表盘数据映射错误和浮点精度修复
- OperatorWorkbench: "今日活跃用户" 错误使用体征上报率数据源,改为 pointsStats.active_accounts
- OperatorWorkbench: AI 摘要体征上报率显示原始浮点数(22.413793...),改为保留两位小数
- OperatorWorkbench: "科普阅读量" fallback 错误回退到积分发放数据,移除错误 fallback
- Home.tsx: 运营角色 ROLE_STATS "内容发布" 数据源错误,修正为 patientStats
- Home.tsx: 移除未使用的 TodoList/AiInsightPanel import
- .lintstagedrc.js: 修复 Windows 平台 eslint 命令,使用函数式获取文件名列表
2026-05-09 02:27:38 +08:00

444 lines
22 KiB
TypeScript

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 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 NurseWorkbench from './health/components/workbench/NurseWorkbench';
import OperatorWorkbench from './health/components/workbench/OperatorWorkbench';
import AdminDashboard from './health/components/workbench/AdminDashboard';
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: '患者概览与待办事项' },
health_manager: { 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' },
],
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' },
{ 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: (_p, s) => s.patientStats?.total_patients ?? 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' },
],
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' },
{ 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>
{/* 角色工作台路由 */}
{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={{
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 === 'nurse' ? (
<NurseWorkbench />
) : (
<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>
{/* 行动详情抽屉 */}
<ActionDetailDrawer
item={drawerItem}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setDrawerItem(null); }}
onActionComplete={() => {
setDrawerOpen(false);
setDrawerItem(null);
}}
/>
</>
)}
</div>
);
}