Files
CA/frontend/src/pages/ReportsCenter.tsx
Akiba So 8ddd8e87bb feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages

Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module

Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter

Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
2026-06-08 18:40:08 +08:00

350 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { FileText, Download, Activity, TrendingUp, TrendingDown, AlertTriangle, ChevronLeft, RefreshCw } from 'lucide-react';
import { useReportsStore } from '@/stores/reportsStore';
import { useDiseaseStore } from '@/stores/diseaseStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import type { ReportResponse } from '@/types';
const TYPE_LABELS: Record<string, string> = {
daily: '日报', weekly: '周报', monthly: '月报', custom: '自定义',
};
const TYPE_COLORS: Record<string, string> = {
daily: 'bg-blue-100 text-blue-700', weekly: 'bg-purple-100 text-purple-700',
monthly: 'bg-green-100 text-green-700', custom: 'bg-gray-100 text-gray-700',
};
const PRIORITY_COLORS: Record<string, string> = {
high: 'bg-red-100 text-red-700 border-red-300',
medium: 'bg-yellow-100 text-yellow-700 border-yellow-300',
low: 'bg-gray-100 text-gray-600 border-gray-300',
};
function downloadCSV(report: ReportResponse) {
const BOM = '';
const headers = ['报告ID', '标题', '类型', '报告日期', '周期开始', '周期结束', '总病例数', '平均风险',
'峰值风险日期', '峰值风险值', '高风险区域数', '趋势方向', '诊断名称', '门诊病例', '住院病例', '诊断总病例'];
const { metadata, summary } = report;
const breakdowns = report.diagnosis_breakdown || [];
const typeLabel = TYPE_LABELS[metadata.type] || metadata.type;
let csv = BOM + headers.join(',') + '\n';
if (breakdowns.length === 0) {
csv += [
metadata.report_id, `"${metadata.title}"`, typeLabel, metadata.generated_at,
metadata.period_start, metadata.period_end, summary.total_cases, summary.avg_risk,
summary.peak_risk_date, summary.peak_risk_value, summary.high_risk_areas, summary.trend_direction,
'', '', '', ''
].join(',');
} else {
breakdowns.forEach((d) => {
csv += [
metadata.report_id, `"${metadata.title}"`, typeLabel, metadata.generated_at,
metadata.period_start, metadata.period_end, summary.total_cases, summary.avg_risk,
summary.peak_risk_date, summary.peak_risk_value, summary.high_risk_areas, summary.trend_direction,
`"${d.diagnosis}"`, d.outpatient, d.inpatient, d.total
].join(',') + '\n';
});
}
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${report.metadata.report_id}.csv`;
link.click();
URL.revokeObjectURL(url);
}
function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
const reports = useReportsStore((s) => s.reports);
const isLoading = useReportsStore((s) => s.isLoading);
const error = useReportsStore((s) => s.error);
const clearError = useReportsStore((s) => s.clearError);
const fetchReportsList = useReportsStore((s) => s.fetchReportsList);
const [filter, setFilter] = useState<string>('all');
useEffect(() => { fetchReportsList(filter === 'all' ? undefined : filter); }, [filter, fetchReportsList]);
const filters = [
{ key: 'all', label: '全部' },
{ key: 'daily', label: '日报' },
{ key: 'weekly', label: '周报' },
{ key: 'monthly', label: '月报' },
];
if (error) return <ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(filter === 'all' ? undefined : filter); }} onDismiss={clearError} />;
return (
<div>
<div className="flex items-center gap-2 mb-4">
{filters.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-3 py-1 text-[12px] rounded transition-colors ${
filter === f.key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>{f.label}</button>
))}
</div>
{isLoading ? (
<div className="text-center py-8 text-sm text-gray-500">...</div>
) : reports.length === 0 ? (
<div className="text-center py-12">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm"></p>
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">ID</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase"></th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase"></th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase"></th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody>
{reports.map((r) => (
<tr
key={r.report_id}
onClick={() => onSelect(r.report_id)}
className="border-b border-gray-100 hover:bg-blue-50 cursor-pointer transition-colors"
>
<td className="px-4 py-3 font-mono text-xs text-gray-900">{r.report_id}</td>
<td className="px-4 py-3 text-gray-900">{r.title}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-[11px] font-medium ${TYPE_COLORS[r.type] || 'bg-gray-100 text-gray-600'}`}>
{TYPE_LABELS[r.type] || r.type}
</span>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{r.period_start} ~ {r.period_end}
</td>
<td className="px-4 py-3 text-gray-500 text-xs">{r.generated_at?.slice(0, 10)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => void }) {
const currentReport = useReportsStore((s) => s.currentReport);
const isLoading = useReportsStore((s) => s.isLoading);
const error = useReportsStore((s) => s.error);
const clearError = useReportsStore((s) => s.clearError);
const fetchReport = useReportsStore((s) => s.fetchReport);
const { selectedDiagnoses } = useDiseaseStore();
useEffect(() => { fetchReport(reportId); }, [reportId]);
if (error) return <ErrorBanner error={error} onRetry={() => { clearError(); fetchReport(reportId); }} onDismiss={clearError} />;
if (isLoading || !currentReport) return <div className="text-center py-8 text-sm text-gray-500">...</div>;
const { metadata, summary, sections, recommendations, diagnosis_breakdown } = currentReport;
const filteredBreakdown = diagnosis_breakdown?.filter(
d => selectedDiagnoses.length === 0 || selectedDiagnoses.includes(d.diagnosis)
) || [];
return (
<div>
<button onClick={onBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">{metadata.title}</h2>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span className={`px-2 py-0.5 rounded font-medium ${TYPE_COLORS[metadata.type] || ''}`}>
{TYPE_LABELS[metadata.type] || metadata.type}
</span>
<span>{metadata.period_start} ~ {metadata.period_end}</span>
<span>: {metadata.generated_at?.slice(0, 10)}</span>
</div>
</div>
<button
onClick={() => downloadCSV(currentReport)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
<Download className="w-3.5 h-3.5" /> CSV
</button>
</div>
{/* Summary cards */}
<div className="grid grid-cols-5 gap-3 mb-6">
{[
{ label: '总病例数', value: summary.total_cases.toLocaleString(), icon: Activity, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '平均风险', value: (summary.avg_risk * 100).toFixed(1) + '%', icon: AlertTriangle, color: 'text-orange-600', bg: 'bg-orange-50' },
{ label: '峰值风险', value: (summary.peak_risk_value * 100).toFixed(1) + '%', icon: TrendingUp, color: 'text-red-600', bg: 'bg-red-50' },
{ label: '高风险区域', value: `${summary.high_risk_areas}`, icon: TrendingUp, color: 'text-red-600', bg: 'bg-red-50' },
{
label: '趋势', value: summary.trend_direction === 'improving' ? '好转' : summary.trend_direction === 'worsening' ? '恶化' : '平稳',
icon: summary.trend_direction === 'improving' ? TrendingDown : summary.trend_direction === 'worsening' ? TrendingUp : Activity,
color: summary.trend_direction === 'improving' ? 'text-green-600' : summary.trend_direction === 'worsening' ? 'text-red-600' : 'text-gray-600',
bg: summary.trend_direction === 'improving' ? 'bg-green-50' : summary.trend_direction === 'worsening' ? 'bg-red-50' : 'bg-gray-50',
},
].map((stat) => (
<div key={stat.label} className="bg-white rounded-lg border border-gray-200 p-3">
<div className="flex items-center gap-2 mb-1">
<div className={`w-7 h-7 rounded ${stat.bg} flex items-center justify-center`}>
<stat.icon className={`w-3.5 h-3.5 ${stat.color}`} />
</div>
<span className="text-[11px] text-gray-500">{stat.label}</span>
</div>
<div className="text-xl font-bold text-gray-900">{stat.value}</div>
</div>
))}
</div>
{/* Sections and charts in 2-column layout */}
<div className="grid grid-cols-2 gap-4 mb-6">
{sections.map((section, idx) => (
<div key={idx} className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2">{section.title}</h3>
<p className="text-xs text-gray-600 leading-relaxed">{section.content}</p>
</div>
))}
</div>
{/* Diagnosis breakdown chart */}
{filteredBreakdown.length > 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-900"></h3>
<DiseaseFilter />
</div>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={filteredBreakdown} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="diagnosis" tick={{ fontSize: 11, fill: '#6b7280' }} />
<YAxis tick={{ fontSize: 11, fill: '#6b7280' }} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #e5e7eb' }} />
<Bar dataKey="outpatient" name="门诊" stackId="a" fill="#3b82f6" radius={[0, 0, 0, 0]} />
<Bar dataKey="inpatient" name="住院" stackId="a" fill="#ef4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-2 pt-2 border-t border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-500"><span className="w-3 h-3 bg-blue-500 rounded-sm" /></div>
<div className="flex items-center gap-1.5 text-xs text-gray-500"><span className="w-3 h-3 bg-red-500 rounded-sm" /></div>
</div>
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6 text-center">
<p className="text-sm text-gray-400"></p>
</div>
)}
{/* Recommendations */}
{recommendations.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3"></h3>
<div className="space-y-2">
{recommendations.map((rec, idx) => (
<div key={idx} className={`flex items-start gap-3 p-3 rounded border ${PRIORITY_COLORS[rec.priority] || ''}`}>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold shrink-0 ${
rec.priority === 'high' ? 'bg-red-500 text-white' :
rec.priority === 'medium' ? 'bg-yellow-500 text-white' : 'bg-gray-400 text-white'
}`}>
{rec.priority === 'high' ? '高' : rec.priority === 'medium' ? '中' : '低'}
</span>
<div>
<div className="text-sm font-medium text-gray-900">{rec.title}</div>
<div className="text-xs text-gray-600 mt-0.5">{rec.description}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
export function ReportsCenter() {
const [view, setView] = useState<'list' | 'detail'>('list');
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
const { error, clearError, fetchReportsList, generateReport } = useReportsStore();
const [genType, setGenType] = useState<string>('daily');
const [isGenerating, setIsGenerating] = useState(false);
const handleGenerate = useCallback(async () => {
setIsGenerating(true);
try {
await generateReport(genType);
setView('detail');
} finally {
setIsGenerating(false);
}
}, [genType, generateReport]);
const handleSelect = useCallback((id: string) => {
setSelectedReportId(id);
setView('detail');
}, []);
const handleBack = useCallback(() => {
setView('list');
setSelectedReportId(null);
fetchReportsList();
}, [fetchReportsList]);
return (
<div>
{error && view === 'list' && (
<ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(); }} onDismiss={clearError} />
)}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-text-muted"></p>
</div>
{view === 'list' && (
<div className="flex items-center gap-2">
<select
value={genType}
onChange={(e) => setGenType(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="daily"></option>
<option value="weekly"></option>
<option value="monthly"></option>
</select>
<button
onClick={handleGenerate}
disabled={isGenerating}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-dark transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${isGenerating ? 'animate-spin' : ''}`} />
</button>
</div>
)}
</div>
{view === 'list' && <ReportList onSelect={handleSelect} />}
{view === 'detail' && selectedReportId && (
<ReportDetail reportId={selectedReportId} onBack={handleBack} />
)}
</div>
);
}