refactor(web): 前端工程化 — 组件拆分 + 名称缓存统一
- useHealthStore 新增 batchResolvePatientNames/batchResolveDoctorNames 批量解析方法(去重 → 过滤已缓存 → 5 并发批次加载) - PointsOrderList 移除局部 nameCache,改用 useHealthStore 全局缓存 - PluginCRUDPage (871L) 拆分为 usePluginData + DetailDrawer + ImportModal + PluginCRUDPageInner,原文件改为 re-export - PluginGraphPage (765L) 拆分为 useGraphData + useGraphCanvas hooks - StatisticsDashboard (580L) 拆分为 useStatsData + HealthDataCenter
This commit is contained in:
@@ -22,8 +22,8 @@ import {
|
||||
pointsApi,
|
||||
type PointsOrder,
|
||||
} from '../../api/health/points';
|
||||
import { patientApi } from '../../api/health/patients';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
|
||||
/** 订单状态映射 */
|
||||
const STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
@@ -56,8 +56,7 @@ export default function PointsOrderList() {
|
||||
const [verifyForm] = Form.useForm();
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
// 名称缓存
|
||||
const [nameCache, setNameCache] = useState<Record<string, string>>({});
|
||||
const { batchResolvePatientNames, getPatientName } = useHealthStore();
|
||||
|
||||
// ---- 数据获取 ----
|
||||
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
||||
@@ -71,29 +70,14 @@ export default function PointsOrderList() {
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
|
||||
// 批量解析患者名称
|
||||
const patientIds = [...new Set(result.data.map((o) => o.patient_id))];
|
||||
const missingIds = patientIds.filter((id) => !nameCache[id]);
|
||||
if (missingIds.length > 0) {
|
||||
const newNames: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
missingIds.map(async (id) => {
|
||||
try {
|
||||
const detail = await patientApi.get(id);
|
||||
newNames[id] = detail.name;
|
||||
} catch {
|
||||
newNames[id] = id.slice(0, 8);
|
||||
}
|
||||
}),
|
||||
);
|
||||
setNameCache((prev) => ({ ...prev, ...newNames }));
|
||||
}
|
||||
const patientIds = result.data.map((o) => o.patient_id);
|
||||
batchResolvePatientNames(patientIds);
|
||||
} catch {
|
||||
message.error('加载订单列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, statusFilter, nameCache]);
|
||||
}, [page, pageSize, statusFilter, batchResolvePatientNames]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -136,7 +120,7 @@ export default function PointsOrderList() {
|
||||
dataIndex: 'patient_id',
|
||||
key: 'patient_id',
|
||||
width: 100,
|
||||
render: (id: string) => nameCache[id] || id.slice(0, 8),
|
||||
render: (id: string) => getPatientName(id),
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
@@ -182,7 +166,7 @@ export default function PointsOrderList() {
|
||||
dataIndex: 'verified_by',
|
||||
key: 'verified_by',
|
||||
width: 100,
|
||||
render: (val: string | null) => val ? <Tag color="blue">{nameCache[val] || val.slice(0, 8)}</Tag> : '-',
|
||||
render: (val: string | null) => val ? <Tag color="blue">{truncateId(val)}</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
Button,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
@@ -28,30 +26,23 @@ import {
|
||||
ArrowUpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
pointsApi,
|
||||
type PatientStatistics,
|
||||
type ConsultationStatistics,
|
||||
type FollowUpStatistics,
|
||||
type PointsStatistics,
|
||||
type HealthDataStats,
|
||||
} from '../../api/health/points';
|
||||
import type { PointsStatistics } from '../../api/health/points';
|
||||
import { useStatsData } from './StatisticsDashboard/useStatsData';
|
||||
import HealthDataCenter from './StatisticsDashboard/HealthDataCenter';
|
||||
|
||||
const { Title: AntTitle, Text } = Typography;
|
||||
|
||||
/** Top-level stat card configuration */
|
||||
interface StatCardConfig {
|
||||
title: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
precision?: number;
|
||||
prefix?: React.ReactNode;
|
||||
prefix: React.ReactNode;
|
||||
subtitle?: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
/** Quick-link card configuration */
|
||||
interface QuickLinkConfig {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -59,7 +50,6 @@ interface QuickLinkConfig {
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Top earner row from points statistics */
|
||||
interface TopEarnerRow {
|
||||
rank: number;
|
||||
patient_id: string;
|
||||
@@ -77,137 +67,78 @@ const QUICK_LINKS: QuickLinkConfig[] = [
|
||||
{ title: '线下活动', icon: <CalendarOutlined />, path: '/health/offline-events', color: '#be185d' },
|
||||
];
|
||||
|
||||
export default function StatisticsDashboard() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [patientStats, setPatientStats] = useState<PatientStatistics | null>(null);
|
||||
const [consultationStats, setConsultationStats] = useState<ConsultationStatistics | null>(null);
|
||||
const [followUpStats, setFollowUpStats] = useState<FollowUpStatistics | null>(null);
|
||||
const [pointsStats, setPointsStats] = useState<PointsStatistics | null>(null);
|
||||
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
|
||||
|
||||
const fetchAllStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
let hasAnyError = false;
|
||||
const errors: string[] = [];
|
||||
|
||||
const tryFetch = async <T,>(fn: () => Promise<T>, setter: (v: T) => void, label: string) => {
|
||||
try {
|
||||
const data = await fn();
|
||||
setter(data);
|
||||
} catch {
|
||||
hasAnyError = true;
|
||||
errors.push(label);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
tryFetch(pointsApi.getPatientStats, setPatientStats, '患者'),
|
||||
tryFetch(pointsApi.getConsultationStats, setConsultationStats, '咨询'),
|
||||
tryFetch(pointsApi.getFollowUpStats, setFollowUpStats, '随访'),
|
||||
tryFetch(pointsApi.getStatistics, setPointsStats, '积分'),
|
||||
tryFetch(pointsApi.getHealthDataStats, setHealthDataStats, '健康数据'),
|
||||
]);
|
||||
|
||||
if (hasAnyError && errors.length === 5) {
|
||||
setError('加载统计数据失败');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllStats();
|
||||
}, [fetchAllStats]);
|
||||
|
||||
// ---- Derived stat cards ----
|
||||
const statCards: StatCardConfig[] = [
|
||||
function buildStatCards(stats: ReturnType<typeof useStatsData>): StatCardConfig[] {
|
||||
return [
|
||||
{
|
||||
title: '患者总数',
|
||||
value: patientStats?.total_patients ?? 0,
|
||||
value: stats.patientStats?.total_patients ?? 0,
|
||||
prefix: <UserOutlined />,
|
||||
subtitle: patientStats?.new_this_month ? `本月 +${patientStats.new_this_month}` : undefined,
|
||||
color: '#2563eb',
|
||||
bgColor: '#eff6ff',
|
||||
subtitle: stats.patientStats?.new_this_month ? `本月 +${stats.patientStats.new_this_month}` : undefined,
|
||||
color: '#2563eb', bgColor: '#eff6ff',
|
||||
},
|
||||
{
|
||||
title: '咨询总量',
|
||||
value: consultationStats?.total_sessions ?? 0,
|
||||
value: stats.consultationStats?.total_sessions ?? 0,
|
||||
prefix: <MessageOutlined />,
|
||||
subtitle: consultationStats?.this_month ? `本月 +${consultationStats.this_month}` : undefined,
|
||||
color: '#7c3aed',
|
||||
bgColor: '#f5f3ff',
|
||||
subtitle: stats.consultationStats?.this_month ? `本月 +${stats.consultationStats.this_month}` : undefined,
|
||||
color: '#7c3aed', bgColor: '#f5f3ff',
|
||||
},
|
||||
{
|
||||
title: '随访完成率',
|
||||
value: followUpStats?.completion_rate ?? 0,
|
||||
suffix: '%',
|
||||
precision: 1,
|
||||
value: stats.followUpStats?.completion_rate ?? 0,
|
||||
suffix: '%', precision: 1,
|
||||
prefix: <PhoneOutlined />,
|
||||
subtitle: followUpStats?.pending ? `待处理: ${followUpStats.pending}` : undefined,
|
||||
color: '#059669',
|
||||
bgColor: '#ecfdf5',
|
||||
subtitle: stats.followUpStats?.pending ? `待处理: ${stats.followUpStats.pending}` : undefined,
|
||||
color: '#059669', bgColor: '#ecfdf5',
|
||||
},
|
||||
{
|
||||
title: '积分总发放',
|
||||
value: pointsStats?.total_issued ?? 0,
|
||||
value: stats.pointsStats?.total_issued ?? 0,
|
||||
prefix: <TrophyOutlined />,
|
||||
subtitle: pointsStats?.active_accounts ? `活跃账户: ${pointsStats.active_accounts}` : undefined,
|
||||
color: '#d97706',
|
||||
bgColor: '#fffbeb',
|
||||
subtitle: stats.pointsStats?.active_accounts ? `活跃账户: ${stats.pointsStats.active_accounts}` : undefined,
|
||||
color: '#d97706', bgColor: '#fffbeb',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Top earners table ----
|
||||
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>
|
||||
);
|
||||
},
|
||||
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>,
|
||||
},
|
||||
];
|
||||
},
|
||||
{
|
||||
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>,
|
||||
},
|
||||
];
|
||||
|
||||
const topEarnerData: TopEarnerRow[] = (pointsStats?.top_earners ?? []).map((item, idx) => ({
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Loading / Error states ----
|
||||
if (loading) {
|
||||
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="加载统计数据中..." />
|
||||
@@ -215,56 +146,35 @@ export default function StatisticsDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (stats.error) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
message="加载统计数据失败"
|
||||
description={error}
|
||||
description={stats.error}
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={fetchAllStats}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
action={<Button size="small" icon={<ReloadOutlined />} onClick={stats.refresh}>重试</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* Section 1: Top Stats Cards */}
|
||||
<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
|
||||
>
|
||||
<Card bordered={false} style={{ borderRadius: 12 }} bodyStyle={{ padding: '20px 24px' }} hoverable>
|
||||
<Statistic
|
||||
title={
|
||||
<span style={{ fontSize: 14, color: '#64748b' }}>{card.title}</span>
|
||||
}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
}
|
||||
@@ -272,8 +182,7 @@ export default function StatisticsDashboard() {
|
||||
/>
|
||||
{card.subtitle && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#94a3b8' }}>
|
||||
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />
|
||||
{card.subtitle}
|
||||
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />{card.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -281,235 +190,32 @@ export default function StatisticsDashboard() {
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* Section 2: Points Statistics Details */}
|
||||
<Card
|
||||
title={
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
<TrophyOutlined style={{ marginRight: 8, color: '#d97706' }} />
|
||||
积分统计
|
||||
</span>
|
||||
}
|
||||
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={fetchAllStats}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
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={pointsStats?.total_issued ?? 0}
|
||||
valueStyle={{ color: '#059669', fontSize: 22 }}
|
||||
prefix={<ArrowUpOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="总消费"
|
||||
value={pointsStats?.total_spent ?? 0}
|
||||
valueStyle={{ color: '#dc2626', fontSize: 22 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="总过期"
|
||||
value={pointsStats?.total_expired ?? 0}
|
||||
valueStyle={{ color: '#6b7280', fontSize: 22 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="活跃账户"
|
||||
value={pointsStats?.active_accounts ?? 0}
|
||||
valueStyle={{ color: '#2563eb', fontSize: 22 }}
|
||||
prefix={<TeamOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<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 }}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Section 2.5: Health Data Statistics */}
|
||||
<Card
|
||||
title={
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
<MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />
|
||||
健康数据中心
|
||||
</span>
|
||||
}
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />健康数据中心</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 透析统计 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title={<span style={{ fontSize: 14, fontWeight: 600 }}>透析记录</span>}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={8}>
|
||||
<Statistic title="总记录" value={healthDataStats?.dialysis.total_records ?? 0} valueStyle={{ fontSize: 20 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="本月新增" value={healthDataStats?.dialysis.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="待审核" value={healthDataStats?.dialysis.pending_review ?? 0} valueStyle={{ fontSize: 20, color: '#d97706' }} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
||||
<Col span={8}>
|
||||
<Statistic title="并发症率" value={healthDataStats?.dialysis.complication_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 18 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="平均超滤(ml)" value={healthDataStats?.dialysis.avg_ultrafiltration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="平均时长(分)" value={healthDataStats?.dialysis.avg_duration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} />
|
||||
</Col>
|
||||
</Row>
|
||||
{(healthDataStats?.dialysis.type_distribution ?? []).length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
||||
{healthDataStats!.dialysis.type_distribution.map((item) => (
|
||||
<Tag key={item.name} color="blue" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 化验报告 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title={<span style={{ fontSize: 14, fontWeight: 600 }}>化验报告</span>}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={8}>
|
||||
<Statistic title="总报告" value={healthDataStats?.lab_reports.total_reports ?? 0} valueStyle={{ fontSize: 20 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="本月新增" value={healthDataStats?.lab_reports.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="异常项" value={healthDataStats?.lab_reports.abnormal_items ?? 0} valueStyle={{ fontSize: 20, color: '#dc2626' }} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
||||
<Col span={12}>
|
||||
<Statistic title="待审核" value={healthDataStats?.lab_reports.pending_review ?? 0} valueStyle={{ fontSize: 18, color: '#d97706' }} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic title="已审核" value={healthDataStats?.lab_reports.reviewed ?? 0} valueStyle={{ fontSize: 18, color: '#059669' }} />
|
||||
</Col>
|
||||
</Row>
|
||||
{(healthDataStats?.lab_reports.type_distribution ?? []).length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
||||
{healthDataStats!.lab_reports.type_distribution.map((item) => (
|
||||
<Tag key={item.name} color="green" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 预约统计 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title={<span style={{ fontSize: 14, fontWeight: 600 }}>预约统计</span>}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={8}>
|
||||
<Statistic title="总预约" value={healthDataStats?.appointments.total_appointments ?? 0} valueStyle={{ fontSize: 20 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="本月" value={healthDataStats?.appointments.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="取消率" value={healthDataStats?.appointments.cancel_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#dc2626' }} />
|
||||
</Col>
|
||||
</Row>
|
||||
{(healthDataStats?.appointments.status_distribution ?? []).length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>状态: </Text>
|
||||
{healthDataStats!.appointments.status_distribution.map((item) => (
|
||||
<Tag key={item.name} color="purple" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 体征上报率 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title={<span style={{ fontSize: 14, fontWeight: 600 }}>体征上报率</span>}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={8}>
|
||||
<Statistic title="总患者" value={healthDataStats?.vital_signs_report_rate.total_patients ?? 0} valueStyle={{ fontSize: 20 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="本月上报" value={healthDataStats?.vital_signs_report_rate.reported_patients ?? 0} valueStyle={{ fontSize: 20, color: '#059669' }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="上报率" value={healthDataStats?.vital_signs_report_rate.report_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#7c3aed' }} />
|
||||
</Col>
|
||||
</Row>
|
||||
{(healthDataStats?.vital_signs_report_rate.daily_trend ?? []).length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>近 7 天: </Text>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{healthDataStats!.vital_signs_report_rate.daily_trend.map((d) => (
|
||||
<Tag key={d.date} color={d.rate >= 50 ? 'green' : d.rate >= 20 ? 'orange' : 'red'}>
|
||||
{d.date.slice(5)} {d.reported}/{d.total}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<HealthDataCenter data={stats.healthDataStats} />
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Quick Links */}
|
||||
<Card
|
||||
title={
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
<MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />
|
||||
快捷入口
|
||||
</span>
|
||||
}
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />快捷入口</span>}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
@@ -517,62 +223,32 @@ export default function StatisticsDashboard() {
|
||||
{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',
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: '#334155' }}>{link.title}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Section 4: Recent Activity (top earners as proxy) */}
|
||||
{topEarnerData.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 8, color: '#7c3aed' }} />
|
||||
最近活动
|
||||
</span>
|
||||
}
|
||||
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: '暂无活动记录' }}
|
||||
/>
|
||||
<Table rowKey="rank" columns={topEarnerColumns} dataSource={topEarnerData} pagination={{ pageSize: 5, size: 'small' }} size="small" locale={{ emptyText: '暂无活动记录' }} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Row, Col, Card, Statistic, Tag, Typography } from 'antd';
|
||||
import type { HealthDataStats } from '../../../api/health/points';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface HealthDataCenterProps {
|
||||
data: HealthDataStats | null;
|
||||
}
|
||||
|
||||
export default function HealthDataCenter({ data }: HealthDataCenterProps) {
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card type="inner" title={<span style={{ fontSize: 14, fontWeight: 600 }}>透析记录</span>} style={{ borderRadius: 8 }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={8}><Statistic title="总记录" value={data?.dialysis.total_records ?? 0} valueStyle={{ fontSize: 20 }} /></Col>
|
||||
<Col span={8}><Statistic title="本月新增" value={data?.dialysis.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} /></Col>
|
||||
<Col span={8}><Statistic title="待审核" value={data?.dialysis.pending_review ?? 0} valueStyle={{ fontSize: 20, color: '#d97706' }} /></Col>
|
||||
</Row>
|
||||
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
||||
<Col span={8}><Statistic title="并发症率" value={data?.dialysis.complication_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 18 }} /></Col>
|
||||
<Col span={8}><Statistic title="平均超滤(ml)" value={data?.dialysis.avg_ultrafiltration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} /></Col>
|
||||
<Col span={8}><Statistic title="平均时长(分)" value={data?.dialysis.avg_duration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} /></Col>
|
||||
</Row>
|
||||
{(data?.dialysis.type_distribution ?? []).length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
||||
{data!.dialysis.type_distribution.map((item) => (
|
||||
<Tag key={item.name} color="blue" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Card type="inner" title={<span style={{ fontSize: 14, fontWeight: 600 }}>化验报告</span>} style={{ borderRadius: 8 }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={8}><Statistic title="总报告" value={data?.lab_reports.total_reports ?? 0} valueStyle={{ fontSize: 20 }} /></Col>
|
||||
<Col span={8}><Statistic title="本月新增" value={data?.lab_reports.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} /></Col>
|
||||
<Col span={8}><Statistic title="异常项" value={data?.lab_reports.abnormal_items ?? 0} valueStyle={{ fontSize: 20, color: '#dc2626' }} /></Col>
|
||||
</Row>
|
||||
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
||||
<Col span={12}><Statistic title="待审核" value={data?.lab_reports.pending_review ?? 0} valueStyle={{ fontSize: 18, color: '#d97706' }} /></Col>
|
||||
<Col span={12}><Statistic title="已审核" value={data?.lab_reports.reviewed ?? 0} valueStyle={{ fontSize: 18, color: '#059669' }} /></Col>
|
||||
</Row>
|
||||
{(data?.lab_reports.type_distribution ?? []).length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
||||
{data!.lab_reports.type_distribution.map((item) => (
|
||||
<Tag key={item.name} color="green" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Card type="inner" title={<span style={{ fontSize: 14, fontWeight: 600 }}>预约统计</span>} style={{ borderRadius: 8 }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={8}><Statistic title="总预约" value={data?.appointments.total_appointments ?? 0} valueStyle={{ fontSize: 20 }} /></Col>
|
||||
<Col span={8}><Statistic title="本月" value={data?.appointments.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} /></Col>
|
||||
<Col span={8}><Statistic title="取消率" value={data?.appointments.cancel_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#dc2626' }} /></Col>
|
||||
</Row>
|
||||
{(data?.appointments.status_distribution ?? []).length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>状态: </Text>
|
||||
{data!.appointments.status_distribution.map((item) => (
|
||||
<Tag key={item.name} color="purple" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Card type="inner" title={<span style={{ fontSize: 14, fontWeight: 600 }}>体征上报率</span>} style={{ borderRadius: 8 }}>
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={8}><Statistic title="总患者" value={data?.vital_signs_report_rate.total_patients ?? 0} valueStyle={{ fontSize: 20 }} /></Col>
|
||||
<Col span={8}><Statistic title="本月上报" value={data?.vital_signs_report_rate.reported_patients ?? 0} valueStyle={{ fontSize: 20, color: '#059669' }} /></Col>
|
||||
<Col span={8}><Statistic title="上报率" value={data?.vital_signs_report_rate.report_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#7c3aed' }} /></Col>
|
||||
</Row>
|
||||
{(data?.vital_signs_report_rate.daily_trend ?? []).length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>近 7 天: </Text>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{data!.vital_signs_report_rate.daily_trend.map((d) => (
|
||||
<Tag key={d.date} color={d.rate >= 50 ? 'green' : d.rate >= 20 ? 'orange' : 'red'}>
|
||||
{d.date.slice(5)} {d.reported}/{d.total}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
pointsApi,
|
||||
type PatientStatistics,
|
||||
type ConsultationStatistics,
|
||||
type FollowUpStatistics,
|
||||
type PointsStatistics,
|
||||
type HealthDataStats,
|
||||
} from '../../../api/health/points';
|
||||
|
||||
export interface StatsData {
|
||||
patientStats: PatientStatistics | null;
|
||||
consultationStats: ConsultationStatistics | null;
|
||||
followUpStats: FollowUpStatistics | null;
|
||||
pointsStats: PointsStatistics | null;
|
||||
healthDataStats: HealthDataStats | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useStatsData(): StatsData {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [patientStats, setPatientStats] = useState<PatientStatistics | null>(null);
|
||||
const [consultationStats, setConsultationStats] = useState<ConsultationStatistics | null>(null);
|
||||
const [followUpStats, setFollowUpStats] = useState<FollowUpStatistics | null>(null);
|
||||
const [pointsStats, setPointsStats] = useState<PointsStatistics | null>(null);
|
||||
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
|
||||
|
||||
const fetchAllStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
let hasAnyError = false;
|
||||
const errors: string[] = [];
|
||||
|
||||
const tryFetch = async <T,>(fn: () => Promise<T>, setter: (v: T) => void, label: string) => {
|
||||
try {
|
||||
const data = await fn();
|
||||
setter(data);
|
||||
} catch {
|
||||
hasAnyError = true;
|
||||
errors.push(label);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
tryFetch(pointsApi.getPatientStats, setPatientStats, '患者'),
|
||||
tryFetch(pointsApi.getConsultationStats, setConsultationStats, '咨询'),
|
||||
tryFetch(pointsApi.getFollowUpStats, setFollowUpStats, '随访'),
|
||||
tryFetch(pointsApi.getStatistics, setPointsStats, '积分'),
|
||||
tryFetch(pointsApi.getHealthDataStats, setHealthDataStats, '健康数据'),
|
||||
]);
|
||||
|
||||
if (hasAnyError && errors.length === 5) {
|
||||
setError('加载统计数据失败');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllStats();
|
||||
}, [fetchAllStats]);
|
||||
|
||||
return {
|
||||
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats,
|
||||
loading, error, refresh: fetchAllStats,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user