176 lines
5.6 KiB
TypeScript
176 lines
5.6 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 { 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>
|
|||
|
|
);
|
|||
|
|
}
|