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>
2026-06-21 21:42:52 +08:00
|
|
|
|
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 { 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">
|
2026-06-21 21:50:30 +08:00
|
|
|
|
住院天数、出院结局、入院途径与年龄别 BMI 等临床特征分析
|
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>
2026-06-21 21:42:52 +08:00
|
|
|
|
</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="出院结局构成">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|