146 lines
5.5 KiB
TypeScript
146 lines
5.5 KiB
TypeScript
|
|
import { memo } from 'react';
|
||
|
|
import {
|
||
|
|
LineChart,
|
||
|
|
Line,
|
||
|
|
BarChart,
|
||
|
|
Bar,
|
||
|
|
XAxis,
|
||
|
|
YAxis,
|
||
|
|
CartesianGrid,
|
||
|
|
Tooltip,
|
||
|
|
Legend,
|
||
|
|
ResponsiveContainer,
|
||
|
|
} from 'recharts';
|
||
|
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||
|
|
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
||
|
|
import type { TopDiagnosis } from './types';
|
||
|
|
|
||
|
|
function formatDateLabel(dateStr: string): string {
|
||
|
|
const d = new Date(dateStr);
|
||
|
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface CaseStatsTabProps {
|
||
|
|
loading: boolean;
|
||
|
|
loaded: boolean;
|
||
|
|
error: string | null;
|
||
|
|
currentDate: string;
|
||
|
|
topDiagnoses: TopDiagnosis[];
|
||
|
|
caseTrend: Array<{ date: string; cases: number; aqi: number }>;
|
||
|
|
heatmapData: Array<{ date: string; value: number }>;
|
||
|
|
heatmapYear: number | null;
|
||
|
|
onRetry: () => void;
|
||
|
|
onDismissError: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 病例统计 tab —— 诊断分布 / 病例与AQI趋势 / 日历热力图。纯展示,数据由父级按需加载。
|
||
|
|
export const CaseStatsTab = memo(function CaseStatsTab({
|
||
|
|
loading,
|
||
|
|
loaded,
|
||
|
|
error,
|
||
|
|
currentDate,
|
||
|
|
topDiagnoses,
|
||
|
|
caseTrend,
|
||
|
|
heatmapData,
|
||
|
|
heatmapYear,
|
||
|
|
onRetry,
|
||
|
|
onDismissError,
|
||
|
|
}: CaseStatsTabProps) {
|
||
|
|
if (loading && !loaded) {
|
||
|
|
return (
|
||
|
|
<div className="flex items-center justify-center h-64">
|
||
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{error && (
|
||
|
|
<ErrorBanner
|
||
|
|
error={error}
|
||
|
|
onRetry={onRetry}
|
||
|
|
onDismiss={onDismissError}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Top 5 诊断分布 */}
|
||
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 诊断分布</h3>
|
||
|
|
{topDiagnoses.length > 0 ? (
|
||
|
|
<ResponsiveContainer width="100%" height={240}>
|
||
|
|
<BarChart
|
||
|
|
data={[...topDiagnoses].reverse()}
|
||
|
|
layout="vertical"
|
||
|
|
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
||
|
|
>
|
||
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||
|
|
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||
|
|
<YAxis
|
||
|
|
type="category"
|
||
|
|
dataKey="diagnosis"
|
||
|
|
tick={{ fontSize: 11, fill: '#374151' }}
|
||
|
|
width={100}
|
||
|
|
axisLine={false}
|
||
|
|
tickLine={false}
|
||
|
|
/>
|
||
|
|
<Tooltip
|
||
|
|
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
||
|
|
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
||
|
|
/>
|
||
|
|
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||
|
|
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
||
|
|
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
||
|
|
</BarChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
) : (
|
||
|
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
|
||
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-1">病例与AQI趋势</h3>
|
||
|
|
<p className="text-xs text-gray-500 mb-4">截至 {currentDate} 的近30日窗口</p>
|
||
|
|
{caseTrend.length > 0 ? (
|
||
|
|
<ResponsiveContainer width="100%" height={260}>
|
||
|
|
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||
|
|
<XAxis
|
||
|
|
dataKey="date"
|
||
|
|
tickFormatter={formatDateLabel}
|
||
|
|
tick={{ fontSize: 10, fill: '#64748B' }}
|
||
|
|
interval="preserveStartEnd"
|
||
|
|
axisLine={{ stroke: '#E2E8F0' }}
|
||
|
|
/>
|
||
|
|
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
||
|
|
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
||
|
|
<Tooltip
|
||
|
|
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
||
|
|
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
||
|
|
/>
|
||
|
|
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||
|
|
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
|
||
|
|
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
|
||
|
|
</LineChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
) : (
|
||
|
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 日历热力图 (year derived from data) */}
|
||
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||
|
|
{heatmapYear ? `${heatmapYear}年 ` : ''}每日病例日历
|
||
|
|
</h3>
|
||
|
|
{heatmapYear && heatmapData.length > 0 ? (
|
||
|
|
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
|
||
|
|
) : (
|
||
|
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|