Files
CA/frontend/src/pages/ClinicalAnalysis.tsx
Akiba So fe8bed58f5 feat: remove cost/费用 statistics from clinical analytics
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>
2026-06-21 21:50:30 +08:00

159 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="年龄别 BMIP25中位P75">
<BoxPlotRows rows={bmiBox} />
</Card>
</div>
</div>
);
}