feat: 仪表盘角色自适应重构 — 4角色视图 + 后端个人工作量API
后端: - 新增 GET /health/admin/statistics/personal-stats 接口 - PersonalStatsResp: 13个个人维度统计字段 - 按医生/护士/管理员/运营角色聚合工作量数据 前端: - useDashboardRole hook: 按优先级 doctor>nurse>admin>operator 匹配角色 - DoctorDashboard: 今日工作台(日程/审核/消息/统计卡) - NurseDashboard: 随访监控台(异常提醒/队列/上报率) - AdminDashboard: 管理中心(5KPI + 健康数据Tab) - OperatorDashboard: 运营中心(积分/文章/活动) - StatisticsDashboard.tsx 重写为角色路由组件 - 删除旧区块:快捷入口/积分排行Top10/最近活动
This commit is contained in:
@@ -1,256 +1,18 @@
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Statistic,
|
||||
Table,
|
||||
Spin,
|
||||
Alert,
|
||||
Button,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
PhoneOutlined,
|
||||
TrophyOutlined,
|
||||
TeamOutlined,
|
||||
CalendarOutlined,
|
||||
ShoppingOutlined,
|
||||
ReloadOutlined,
|
||||
CommentOutlined,
|
||||
MedicineBoxOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined,
|
||||
ArrowUpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { PointsStatistics } from '../../api/health/points';
|
||||
import { useStatsData } from './StatisticsDashboard/useStatsData';
|
||||
import HealthDataCenter from './StatisticsDashboard/HealthDataCenter';
|
||||
import { useDashboardRole } from '../../hooks/useDashboardRole';
|
||||
import { DoctorDashboard } from './StatisticsDashboard/DoctorDashboard';
|
||||
import { NurseDashboard } from './StatisticsDashboard/NurseDashboard';
|
||||
import { AdminDashboard } from './StatisticsDashboard/AdminDashboard';
|
||||
import { OperatorDashboard } from './StatisticsDashboard/OperatorDashboard';
|
||||
|
||||
const { Title: AntTitle, Text } = Typography;
|
||||
|
||||
interface StatCardConfig {
|
||||
title: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
precision?: number;
|
||||
prefix: React.ReactNode;
|
||||
subtitle?: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
interface QuickLinkConfig {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
path: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface TopEarnerRow {
|
||||
rank: number;
|
||||
patient_id: string;
|
||||
total_earned: number;
|
||||
}
|
||||
|
||||
const QUICK_LINKS: QuickLinkConfig[] = [
|
||||
{ title: '患者管理', icon: <TeamOutlined />, path: '/health/patients', color: '#2563eb' },
|
||||
{ title: '预约排班', icon: <CalendarOutlined />, path: '/health/appointments', color: '#059669' },
|
||||
{ title: '随访管理', icon: <PhoneOutlined />, path: '/health/follow-up-tasks', color: '#d97706' },
|
||||
{ title: '咨询管理', icon: <CommentOutlined />, path: '/health/consultations', color: '#7c3aed' },
|
||||
{ title: '积分规则', icon: <TrophyOutlined />, path: '/health/points-rules', color: '#dc2626' },
|
||||
{ title: '商品管理', icon: <ShoppingOutlined />, path: '/health/points-products', color: '#0891b2' },
|
||||
{ title: '订单管理', icon: <FileTextOutlined />, path: '/health/points-orders', color: '#4f46e5' },
|
||||
{ title: '线下活动', icon: <CalendarOutlined />, path: '/health/offline-events', color: '#be185d' },
|
||||
];
|
||||
|
||||
function buildStatCards(stats: ReturnType<typeof useStatsData>): StatCardConfig[] {
|
||||
return [
|
||||
{
|
||||
title: '患者总数',
|
||||
value: stats.patientStats?.total_patients ?? 0,
|
||||
prefix: <UserOutlined />,
|
||||
subtitle: stats.patientStats?.new_this_month ? `本月 +${stats.patientStats.new_this_month}` : undefined,
|
||||
color: '#2563eb', bgColor: '#eff6ff',
|
||||
},
|
||||
{
|
||||
title: '咨询总量',
|
||||
value: stats.consultationStats?.total_sessions ?? 0,
|
||||
prefix: <MessageOutlined />,
|
||||
subtitle: stats.consultationStats?.this_month ? `本月 +${stats.consultationStats.this_month}` : undefined,
|
||||
color: '#7c3aed', bgColor: '#f5f3ff',
|
||||
},
|
||||
{
|
||||
title: '随访完成率',
|
||||
value: stats.followUpStats?.completion_rate ?? 0,
|
||||
suffix: '%', precision: 1,
|
||||
prefix: <PhoneOutlined />,
|
||||
subtitle: stats.followUpStats?.pending ? `待处理: ${stats.followUpStats.pending}` : undefined,
|
||||
color: '#059669', bgColor: '#ecfdf5',
|
||||
},
|
||||
{
|
||||
title: '积分总发放',
|
||||
value: stats.pointsStats?.total_issued ?? 0,
|
||||
prefix: <TrophyOutlined />,
|
||||
subtitle: stats.pointsStats?.active_accounts ? `活跃账户: ${stats.pointsStats.active_accounts}` : undefined,
|
||||
color: '#d97706', bgColor: '#fffbeb',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const topEarnerColumns = [
|
||||
{
|
||||
title: '排名', dataIndex: 'rank', key: 'rank', width: 70,
|
||||
render: (rank: number) => {
|
||||
const medalColors = ['#d97706', '#6b7280', '#b45309'];
|
||||
const color = rank <= 3 ? medalColors[rank - 1] : undefined;
|
||||
return <Text strong={rank <= 3} style={color ? { color } : undefined}>{rank}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '患者 ID', dataIndex: 'patient_id', key: 'patient_id', width: 180,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<Text copyable={{ text: id }}>{id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '累计积分', dataIndex: 'total_earned', key: 'total_earned', width: 140,
|
||||
render: (val: number) => <Text strong>{val.toLocaleString()}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
function buildTopEarnerData(pointsStats: PointsStatistics | null): TopEarnerRow[] {
|
||||
return (pointsStats?.top_earners ?? []).map((item, idx) => ({
|
||||
rank: idx + 1,
|
||||
patient_id: item.patient_id,
|
||||
total_earned: item.total_earned,
|
||||
}));
|
||||
}
|
||||
const DASHBOARD_MAP = {
|
||||
doctor: DoctorDashboard,
|
||||
nurse: NurseDashboard,
|
||||
admin: AdminDashboard,
|
||||
operator: OperatorDashboard,
|
||||
} as const;
|
||||
|
||||
export default function StatisticsDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const stats = useStatsData();
|
||||
const statCards = buildStatCards(stats);
|
||||
const topEarnerData = buildTopEarnerData(stats.pointsStats);
|
||||
|
||||
if (stats.loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<Spin size="large" tip="加载统计数据中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stats.error) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
message="加载统计数据失败"
|
||||
description={stats.error}
|
||||
showIcon
|
||||
action={<Button size="small" icon={<ReloadOutlined />} onClick={stats.refresh}>重试</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{statCards.map((card) => (
|
||||
<Col xs={24} sm={12} md={6} key={card.title}>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }} bodyStyle={{ padding: '20px 24px' }} hoverable>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 14, color: '#64748b' }}>{card.title}</span>}
|
||||
value={card.value}
|
||||
precision={card.precision}
|
||||
suffix={card.suffix}
|
||||
prefix={
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 40, height: 40, borderRadius: 10, backgroundColor: card.bgColor,
|
||||
color: card.color, fontSize: 20, marginRight: 12,
|
||||
}}>
|
||||
{card.prefix}
|
||||
</span>
|
||||
}
|
||||
valueStyle={{ color: card.color, fontSize: 28, fontWeight: 700 }}
|
||||
/>
|
||||
{card.subtitle && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#94a3b8' }}>
|
||||
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />{card.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><TrophyOutlined style={{ marginRight: 8, color: '#d97706' }} />积分统计</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
extra={<Button type="text" icon={<ReloadOutlined />} onClick={stats.refresh} loading={stats.loading}>刷新</Button>}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} sm={6}><Statistic title="总发放" value={stats.pointsStats?.total_issued ?? 0} valueStyle={{ color: '#059669', fontSize: 22 }} prefix={<ArrowUpOutlined />} /></Col>
|
||||
<Col xs={12} sm={6}><Statistic title="总消费" value={stats.pointsStats?.total_spent ?? 0} valueStyle={{ color: '#dc2626', fontSize: 22 }} /></Col>
|
||||
<Col xs={12} sm={6}><Statistic title="总过期" value={stats.pointsStats?.total_expired ?? 0} valueStyle={{ color: '#6b7280', fontSize: 22 }} /></Col>
|
||||
<Col xs={12} sm={6}><Statistic title="活跃账户" value={stats.pointsStats?.active_accounts ?? 0} valueStyle={{ color: '#2563eb', fontSize: 22 }} prefix={<TeamOutlined />} /></Col>
|
||||
</Row>
|
||||
<AntTitle level={5} style={{ marginBottom: 16 }}>积分排行 Top 10</AntTitle>
|
||||
<Table rowKey="rank" columns={topEarnerColumns} dataSource={topEarnerData} pagination={false} size="small" locale={{ emptyText: '暂无数据' }} style={{ marginTop: 8 }} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />健康数据中心</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<HealthDataCenter data={stats.healthDataStats} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />快捷入口</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{QUICK_LINKS.map((link) => (
|
||||
<Col xs={12} sm={8} md={6} key={link.path}>
|
||||
<Card
|
||||
hoverable bordered={false}
|
||||
style={{ borderRadius: 10, cursor: 'pointer', textAlign: 'center', transition: 'transform 0.2s, box-shadow 0.2s' }}
|
||||
bodyStyle={{ padding: '20px 12px' }}
|
||||
onClick={() => navigate(link.path)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 48, height: 48, borderRadius: 12, backgroundColor: `${link.color}15`,
|
||||
color: link.color, fontSize: 24, marginBottom: 10,
|
||||
}}>
|
||||
{link.icon}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: '#334155' }}>{link.title}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{topEarnerData.length > 0 && (
|
||||
<Card
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><ClockCircleOutlined style={{ marginRight: 8, color: '#7c3aed' }} />最近活动</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<Table rowKey="rank" columns={topEarnerColumns} dataSource={topEarnerData} pagination={{ pageSize: 5, size: 'small' }} size="small" locale={{ emptyText: '暂无活动记录' }} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const role = useDashboardRole();
|
||||
const DashboardComponent = DASHBOARD_MAP[role];
|
||||
return <DashboardComponent />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user