feat: deep statistical analytics — clinical, symptoms, incidence, env correlation, weekday
Adds a substantial layer of data-backed statistics (all grounded in verified, clean source data — no fabricated metrics). Backend (new routers/statistics.py, prefix /api/stats; +106 pytest still green): - /inpatient-clinical: LOS dist + by-disease quartiles, cost dist + by-disease + cost-vs-LOS, outcome counts, admission-route counts, BMI-by-age, KPIs (5822 admissions, median LOS 4d, mean ¥6294, cure 99.1%, emergency 47%) - /symptoms: 主诉 keyword frequencies (发热/咳嗽/肺炎…) + revisit ratio (36%) - /incidence-rate: per-10k-population standardized rate by district (cases ÷ pop) - /env-correlation: pollutant×cases Pearson + 7×7 pairwise matrix + PM2.5 scatter - /temporal: weekday distribution (+ month/yoy returned but UI omits them — data is December-only, so seasonality/YoY would be misleading) Frontend: - NEW 住院临床分析 page (/analysis/clinical, nav 临床分析): 9 charts + KPI row — LOS histogram + box-by-disease, cost histogram + scatter + by-disease, outcome donut (severity-colored), admission-route donut, age-band BMI box - DiseaseAnalysis: 主诉症状词频 horizontal bar + revisit ratio - DistrictComparison: 标化发病率(每万人)with 病例数↔发病率 toggle (rate is epidemiologically correct; raw counts mislead by population) - EnvironmentalHealth: pollutant-cases correlation bar + 7×7 correlation heatmap + PM2.5×cases scatter with least-squares regression line - TrendAnalysis: 星期就诊分布 + honest "data is December-only" note - statsApi client + types Gates: tsc 0 · build ok · functional e2e 43/43 (incl 2 new clinical) · verified live against real backend data via dev proxy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
175
frontend/src/pages/ClinicalAnalysis.tsx
Normal file
175
frontend/src/pages/ClinicalAnalysis.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { statsApi, type InpatientClinicalResponse } from '@/services/api';
|
||||
import { Card, LoadingState, EmptyState } from '@/components/ui';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import { ClinicalKpiRow } from '@/components/clinical/ClinicalKpiRow';
|
||||
import { HistogramChart } from '@/components/clinical/HistogramChart';
|
||||
import { BoxPlotRows, type BoxRow } from '@/components/clinical/BoxPlotRows';
|
||||
import { CostVsLosScatter } from '@/components/clinical/CostVsLosScatter';
|
||||
import { CostByDiseaseChart } from '@/components/clinical/CostByDiseaseChart';
|
||||
import { DonutChart, type DonutSlice } from '@/components/clinical/DonutChart';
|
||||
import { CLINICAL_COLORS } from '@/components/clinical/chartColors';
|
||||
|
||||
/** 数据是否完全为空(KPI 0 人次且各序列均空)。 */
|
||||
function isEmpty(d: InpatientClinicalResponse): boolean {
|
||||
return (
|
||||
(!d.kpis || d.kpis.total_admissions === 0) &&
|
||||
(d.los_histogram?.length ?? 0) === 0 &&
|
||||
(d.outcome_counts?.length ?? 0) === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function ClinicalAnalysis() {
|
||||
const [data, setData] = useState<InpatientClinicalResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await statsApi.getInpatientClinical();
|
||||
if (cancelled) return;
|
||||
setData(res);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setError('住院临床数据加载失败');
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const header = (
|
||||
<div>
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-primary" />
|
||||
住院临床分析
|
||||
</h1>
|
||||
<p className="text-[12px] text-text-secondary">
|
||||
住院天数、费用、出院结局与入院途径等临床特征分析
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
|
||||
{header}
|
||||
<LoadingState label="正在加载住院临床数据…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
|
||||
{header}
|
||||
<ErrorBanner
|
||||
error={error ?? '住院临床数据加载失败'}
|
||||
onRetry={() => window.location.reload()}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty(data)) {
|
||||
return (
|
||||
<div data-testid={TESTIDS.pageClinical} className="flex flex-col h-full overflow-auto p-6 space-y-6">
|
||||
{header}
|
||||
<EmptyState title="暂无住院临床数据" description="当前筛选范围内没有可用的住院记录。" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 出院结局:治愈/好转在前(按严重程度排序展示更直观)。
|
||||
const outcomeSlices: DonutSlice[] = (data.outcome_counts ?? []).map((o) => ({
|
||||
name: o.outcome,
|
||||
value: o.count,
|
||||
}));
|
||||
const routeSlices: DonutSlice[] = (data.admission_route_counts ?? []).map((r) => ({
|
||||
name: r.route,
|
||||
value: r.count,
|
||||
}));
|
||||
|
||||
const losBox: BoxRow[] = (data.los_by_disease ?? []).map((d) => ({
|
||||
label: d.diagnosis,
|
||||
p25: d.p25,
|
||||
median: d.median,
|
||||
p75: d.p75,
|
||||
n: d.n,
|
||||
}));
|
||||
const bmiBox: BoxRow[] = (data.bmi_by_age_band ?? []).map((d) => ({
|
||||
label: d.age_band,
|
||||
p25: d.p25,
|
||||
median: d.median,
|
||||
p75: d.p75,
|
||||
n: d.n,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTIDS.pageClinical}
|
||||
className="flex flex-col h-full overflow-auto"
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{header}
|
||||
|
||||
{/* KPI 行 */}
|
||||
<ClinicalKpiRow kpis={data.kpis} />
|
||||
|
||||
{/* 住院天数:分布 + 各病种箱线 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card title="住院天数分布">
|
||||
<HistogramChart data={data.los_histogram ?? []} color={CLINICAL_COLORS.los} countLabel="人次" />
|
||||
</Card>
|
||||
<Card title="各病种住院天数(P25–中位–P75)">
|
||||
<BoxPlotRows rows={losBox} unit="天" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 费用:分布 + 各病种平均费用 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card title="住院费用分布">
|
||||
<HistogramChart data={data.cost_histogram ?? []} color={CLINICAL_COLORS.cost} countLabel="人次" />
|
||||
</Card>
|
||||
<Card title="各病种平均费用">
|
||||
<CostByDiseaseChart data={data.cost_by_disease ?? []} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 费用 vs 住院天数 散点 */}
|
||||
<Card title="费用 vs 住院天数">
|
||||
<CostVsLosScatter data={data.cost_vs_los ?? []} />
|
||||
</Card>
|
||||
|
||||
{/* 出院结局 + 入院途径 双环 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card title="出院结局构成">
|
||||
<DonutChart data={outcomeSlices} colorMap={CLINICAL_COLORS.outcome} />
|
||||
</Card>
|
||||
<Card title="入院途径构成">
|
||||
<DonutChart data={routeSlices} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 年龄别 BMI 箱线 */}
|
||||
<Card title="年龄别 BMI(P25–中位–P75)">
|
||||
<BoxPlotRows rows={bmiBox} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user