feat: 仪表盘角色自适应重构 — 4角色视图 + 后端个人工作量API
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled

后端:
- 新增 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:
iven
2026-04-28 07:54:08 +08:00
parent 35d4f6c843
commit 2f42ebff1d
11 changed files with 788 additions and 254 deletions

View File

@@ -141,6 +141,22 @@ export interface FollowUpStatistics {
completion_rate: number;
}
export interface PersonalStats {
my_patients: number;
new_patients_this_month: number;
follow_up_rate: number;
consultations_this_month: number;
pending_consultations: number;
vital_signs_report_rate: number;
today_appointments: number;
overdue_follow_ups: number;
today_follow_ups: number;
abnormal_vital_signs: number;
vital_signs_reported: number;
vital_signs_total: number;
pending_lab_reviews: number;
}
export interface OverviewStatistics {
patients: PatientStatistics;
consultations: ConsultationStatistics;
@@ -359,4 +375,12 @@ export const pointsApi = {
}>('/health/admin/statistics/health-data');
return data.data;
},
getPersonalStats: async (): Promise<PersonalStats> => {
const { data } = await client.get<{
success: boolean;
data: PersonalStats;
}>('/health/admin/statistics/personal-stats');
return data.data;
},
};

View File

@@ -0,0 +1,19 @@
import { useAuthStore } from '../stores/auth';
type DashboardRole = 'doctor' | 'nurse' | 'admin' | 'operator';
const ROLE_PRIORITY: DashboardRole[] = ['doctor', 'nurse', 'admin', 'operator'];
export function useDashboardRole(): DashboardRole {
const user = useAuthStore(s => s.user);
if (!user?.roles?.length) return 'admin';
const codes = user.roles.map(r => r.code);
for (const role of ROLE_PRIORITY) {
if (codes.some(c => c === role || c.startsWith(role))) return role;
}
return 'admin';
}
export type { DashboardRole };

View File

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

View File

@@ -0,0 +1,81 @@
import { Row, Col, Card, Statistic, Tabs, Spin, Typography, Flex } from 'antd';
import {
TeamOutlined,
CalendarOutlined,
SafetyCertificateOutlined,
MedicineBoxOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useStatsData } from './useStatsData';
import { useCountUp } from '../../../hooks/useCountUp';
import HealthDataCenter from './HealthDataCenter';
export function AdminDashboard() {
const { patientStats, followUpStats, healthDataStats, loading } = useStatsData();
if (loading && !patientStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
return (
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
</Flex>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic title="患者总数" value={useCountUp(patientStats?.total_patients ?? 0)} prefix={<TeamOutlined />} />
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic title="本月预约" value={useCountUp(healthDataStats?.appointments?.this_month ?? 0)} prefix={<CalendarOutlined />} />
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic
title="随访完成"
value={followUpStats?.completion_rate ?? 0}
precision={0}
suffix="%"
prefix={<SafetyCertificateOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic
title="体征上报"
value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}
precision={0}
suffix="%"
prefix={<MedicineBoxOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic title="医护人数" value={useCountUp(0)} prefix={<UserOutlined />} />
</Card>
</Col>
</Row>
{/* 健康数据中心 Tab */}
<Card style={{ marginTop: 16 }}>
<Tabs
defaultActiveKey="dialysis"
items={[
{ key: 'dialysis', label: '透析管理', children: <HealthDataCenter data={healthDataStats} /> },
{ key: 'lab', label: '化验报告', children: <HealthDataCenter data={healthDataStats} /> },
{ key: 'appointments', label: '预约分析', children: <HealthDataCenter data={healthDataStats} /> },
{ key: 'vital-signs', label: '体征数据', children: <HealthDataCenter data={healthDataStats} /> },
]}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { Row, Col, Card, Statistic, List, Tag, Spin, Typography, Flex } from 'antd';
import {
TeamOutlined,
MessageOutlined,
SafetyCertificateOutlined,
MedicineBoxOutlined,
ArrowUpOutlined,
} from '@ant-design/icons';
import { useEffect, useState, useCallback } from 'react';
import { pointsApi, type PersonalStats } from '../../../api/health/points';
import { useStatsData } from './useStatsData';
import { useCountUp } from '../../../hooks/useCountUp';
export function DoctorDashboard() {
const [personal, setPersonal] = useState<PersonalStats | null>(null);
const [loading, setLoading] = useState(true);
const { consultationStats } = useStatsData();
const fetchPersonal = useCallback(async () => {
try {
const data = await pointsApi.getPersonalStats();
setPersonal(data);
} catch {
// 个人统计可能无权限,静默降级
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchPersonal(); }, [fetchPersonal]);
if (loading && !personal) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
const p = personal;
return (
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
{p ? `${p.today_appointments} 个预约 · ${p.today_follow_ups} 条待随访` : '加载中...'}
</Typography.Text>
</div>
</Flex>
<Row gutter={[16, 16]}>
{/* 紧急提醒 */}
{p && p.abnormal_vital_signs > 0 && (
<Col span={24}>
<Card size="small" style={{ borderLeft: '4px solid #ff4d4f' }}>
<Typography.Text type="danger" strong>
{p.abnormal_vital_signs}
</Typography.Text>
{p.pending_lab_reviews > 0 && (
<Typography.Text style={{ marginLeft: 16 }}>
<Tag color="orange">{p.pending_lab_reviews} </Tag>
</Typography.Text>
)}
</Card>
</Col>
)}
{/* 统计卡片 */}
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="我的患者"
value={useCountUp(p?.my_patients ?? 0)}
prefix={<TeamOutlined />}
suffix={p && p.new_patients_this_month > 0 ? (
<Typography.Text type="success" style={{ fontSize: 12 }}>
<ArrowUpOutlined /> {p.new_patients_this_month}
</Typography.Text>
) : undefined}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="随访完成率"
value={p?.follow_up_rate ?? 0}
precision={0}
suffix="%"
prefix={<SafetyCertificateOutlined />}
valueStyle={{ color: (p?.follow_up_rate ?? 0) >= 80 ? '#3f8600' : '#cf1322' }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="本月咨询"
value={useCountUp(p?.consultations_this_month ?? 0)}
prefix={<MessageOutlined />}
suffix={p && p.pending_consultations > 0 ? (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
{p.pending_consultations}
</Typography.Text>
) : undefined}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="体征上报率"
value={p?.vital_signs_report_rate ?? 0}
precision={0}
suffix="%"
prefix={<MedicineBoxOutlined />}
valueStyle={{ color: (p?.vital_signs_report_rate ?? 0) >= 70 ? '#3f8600' : '#cf1322' }}
/>
</Card>
</Col>
{/* 化验审核 */}
{p && p.pending_lab_reviews > 0 && (
<Col xs={24} md={12}>
<Card title={`化验审核 (${p.pending_lab_reviews}待审)`} size="small">
<List
size="small"
dataSource={[]}
locale={{ emptyText: '暂无待审核化验' }}
renderItem={() => <List.Item />}
/>
</Card>
</Col>
)}
{/* 咨询消息 */}
<Col xs={24} md={p && p.pending_lab_reviews > 0 ? 12 : 24}>
<Card title={`咨询消息 (${consultationStats?.pending_reply ?? 0}未读)`} size="small">
<List
size="small"
dataSource={[]}
locale={{ emptyText: '暂无未读消息' }}
renderItem={() => <List.Item />}
/>
</Card>
</Col>
</Row>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd';
import {
TeamOutlined,
SafetyCertificateOutlined,
CalendarOutlined,
AlertOutlined,
} from '@ant-design/icons';
import { useEffect, useState, useCallback } from 'react';
import { pointsApi, type PersonalStats } from '../../../api/health/points';
import { useCountUp } from '../../../hooks/useCountUp';
export function NurseDashboard() {
const [personal, setPersonal] = useState<PersonalStats | null>(null);
const [loading, setLoading] = useState(true);
const fetchPersonal = useCallback(async () => {
try {
const data = await pointsApi.getPersonalStats();
setPersonal(data);
} catch {
// 静默降级
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchPersonal(); }, [fetchPersonal]);
if (loading && !personal) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
const p = personal;
return (
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>访</Typography.Title>
<Typography.Text type="secondary">
{p ? `今日待随访 ${p.today_follow_ups} 人 · 体征异常 ${p.abnormal_vital_signs}` : '加载中...'}
</Typography.Text>
</div>
</Flex>
<Row gutter={[16, 16]}>
{/* 异常提醒 */}
{p && p.abnormal_vital_signs > 0 && (
<Col span={24}>
<Card size="small" style={{ borderLeft: '4px solid #ff4d4f' }}>
<Flex justify="space-between" align="center">
<Space>
<AlertOutlined style={{ color: '#ff4d4f' }} />
<Typography.Text type="danger" strong>
{p.abnormal_vital_signs}
</Typography.Text>
</Space>
<Typography.Link> </Typography.Link>
</Flex>
</Card>
</Col>
)}
{/* 今日随访队列 */}
<Col xs={24} md={12}>
<Card title={`今日随访队列 (${p?.today_follow_ups ?? 0}人)`} size="small">
<List
size="small"
dataSource={[]}
locale={{ emptyText: '今日暂无随访任务' }}
renderItem={() => <List.Item />}
/>
</Card>
</Col>
{/* 今日体征上报 */}
<Col xs={24} md={12}>
<Card title="今日体征上报" size="small">
<Flex vertical align="center" style={{ padding: '16px 0' }}>
<Progress
type="circle"
percent={Math.round(p?.vital_signs_report_rate ?? 0)}
size={120}
format={pct => <span style={{ fontSize: 24 }}>{pct}%</span>}
/>
<Space style={{ marginTop: 12 }}>
<Typography.Text type="secondary">
{p?.vital_signs_reported ?? 0}
</Typography.Text>
<Typography.Text type="secondary">|</Typography.Text>
<Typography.Text type="secondary">
{(p?.vital_signs_total ?? 0) - (p?.vital_signs_reported ?? 0)}
</Typography.Text>
</Space>
</Flex>
</Card>
</Col>
{/* 统计卡 */}
<Col xs={8}>
<Card size="small">
<Statistic title="今日预约" value={useCountUp(p?.today_appointments ?? 0)} prefix={<CalendarOutlined />} />
</Card>
</Col>
<Col xs={8}>
<Card size="small">
<Statistic
title="随访完成率"
value={p?.follow_up_rate ?? 0}
precision={0}
suffix="%"
prefix={<SafetyCertificateOutlined />}
/>
</Card>
</Col>
<Col xs={8}>
<Card size="small">
<Statistic title="逾期随访" value={useCountUp(p?.overdue_follow_ups ?? 0)} prefix={<TeamOutlined />}
valueStyle={{ color: (p?.overdue_follow_ups ?? 0) > 0 ? '#cf1322' : undefined }}
/>
</Card>
</Col>
</Row>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex } from 'antd';
import {
TrophyOutlined,
FileTextOutlined,
CalendarOutlined,
ShoppingOutlined,
} from '@ant-design/icons';
import { useStatsData } from './useStatsData';
import { useCountUp } from '../../../hooks/useCountUp';
export function OperatorDashboard() {
const { pointsStats, loading } = useStatsData();
if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
return (
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
</Flex>
<Row gutter={[16, 16]}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="积分发放" value={useCountUp(pointsStats?.total_issued ?? 0)} prefix={<TrophyOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="积分消费" value={useCountUp(pointsStats?.total_spent ?? 0)} prefix={<ShoppingOutlined />}
suffix={pointsStats ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{pointsStats.total_issued > 0 ? Math.round(pointsStats.total_spent / pointsStats.total_issued * 100) : 0}%
</Typography.Text>
) : undefined}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="活跃账户" value={useCountUp(pointsStats?.active_accounts ?? 0)} prefix={<FileTextOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="线下活动" value={0} prefix={<CalendarOutlined />} />
</Card>
</Col>
{/* 积分排行 */}
<Col xs={24} md={12}>
<Card title="积分消费排行" size="small">
<List
size="small"
dataSource={pointsStats?.top_earners?.slice(0, 5) ?? []}
locale={{ emptyText: '暂无数据' }}
renderItem={(item, idx) => (
<List.Item>
<Typography.Text>{idx + 1}. {item.patient_id?.slice(0, 8) ?? '未知'}</Typography.Text>
<Typography.Text type="secondary">{item.total_earned} </Typography.Text>
</List.Item>
)}
/>
</Card>
</Col>
{/* 热门文章 */}
<Col xs={24} md={12}>
<Card title="热门文章" size="small">
<List
size="small"
dataSource={[]}
locale={{ emptyText: '暂无数据' }}
renderItem={() => <List.Item />}
/>
</Card>
</Col>
</Row>
</div>
);
}