Per request — drop all monetary statistics (住院费用 is sensitive). - Backend statistics.py: remove mean_cost KPI + cost_histogram / cost_by_disease / cost_vs_los from /inpatient-clinical (models, computation, response) - Frontend: drop 人均费用 KPI card (now 4 KPIs), 住院费用分布, 各病种平均费用, 费用×住院天数散点; delete CostByDiseaseChart + CostVsLosScatter components; trim statsApi type + e2e fixture + chartColors Clinical page now: KPI(总人次/中位住院日/治愈好转率/急诊占比) + LOS dist + LOS-by-disease box + outcome donut + admission-route donut + age-band BMI box. Gates: backend 106 pytest · tsc 0 · build ok · clinical+user-flows e2e 19/19 · live endpoint confirmed cost-free Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
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">
|
||
住院天数、出院结局、入院途径与年龄别 BMI 等临床特征分析
|
||
</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>
|
||
);
|
||
}
|