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:
2026-06-21 21:42:52 +08:00
parent 33f0f497d3
commit 4df6c71628
19 changed files with 1963 additions and 10 deletions

View File

@@ -55,6 +55,7 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: NavIte
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
{ to: '/analysis/clinical', label: '临床分析', testid: TESTIDS.navClinical },
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
],
},

View File

@@ -0,0 +1,87 @@
import { memo } from 'react';
import { CLINICAL_COLORS } from './chartColors';
export interface BoxRow {
label: string;
p25: number;
median: number;
p75: number;
n: number;
}
interface BoxPlotRowsProps {
rows: BoxRow[];
/** 数值单位后缀,如 "天" / ""。 */
unit?: string;
/** 标签列宽px。 */
labelWidth?: number;
}
/**
* 横向箱线图p25中位p75。Recharts 无原生 box plot
* 故用纯 div 渲染:每行一条从 p25 到 p75 的横条,中位处一根竖向刻度。
* 复用于「各病种住院天数」与「年龄别BMI」。
*/
export const BoxPlotRows = memo(function BoxPlotRows({
rows,
unit = '',
labelWidth = 96,
}: BoxPlotRowsProps) {
if (!rows || rows.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
// 统一横轴域:覆盖所有行的 p25..p75留一点边距。
const domainMin = Math.min(...rows.map((r) => r.p25));
const domainMax = Math.max(...rows.map((r) => r.p75));
const span = domainMax - domainMin || 1;
const pct = (v: number) => ((v - domainMin) / span) * 100;
return (
<div className="space-y-2.5">
{rows.map((r) => {
const left = pct(r.p25);
const right = pct(r.p75);
const width = Math.max(right - left, 0.5);
const medianLeft = pct(r.median);
return (
<div key={r.label} className="flex items-center gap-2 text-[11px]">
<div
className="shrink-0 truncate text-text-secondary text-right"
style={{ width: labelWidth }}
title={r.label}
>
{r.label}
</div>
<div className="relative flex-1 h-5 rounded bg-bg-hover">
{/* p25p75 箱体 */}
<div
className="absolute top-1 bottom-1 rounded-sm"
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: CLINICAL_COLORS.box,
opacity: 0.35,
}}
/>
{/* 中位刻度 */}
<div
className="absolute top-0.5 bottom-0.5 w-[2px] rounded"
style={{
left: `${medianLeft}%`,
backgroundColor: CLINICAL_COLORS.boxMedian,
}}
title={`中位 ${r.median}${unit}`}
/>
</div>
<div className="shrink-0 w-28 text-text-muted tabular-nums">
{r.p25}<span className="font-semibold text-text-secondary">{r.median}</span>{r.p75}
{unit}
<span className="ml-1 text-[10px] text-text-muted">n={r.n}</span>
</div>
</div>
);
})}
</div>
);
});

View File

@@ -0,0 +1,47 @@
import { memo } from 'react';
import { Users, CalendarDays, Wallet, HeartPulse, Siren } from 'lucide-react';
import { StatCard } from '@/components/StatCard';
import { TESTIDS } from '@/utils/testids';
import type { InpatientClinicalResponse } from '@/services/api';
interface ClinicalKpiRowProps {
kpis: InpatientClinicalResponse['kpis'];
}
/** 住院临床 5 项核心指标。375px 下 2 列sm 起 5 列。 */
export const ClinicalKpiRow = memo(function ClinicalKpiRow({ kpis }: ClinicalKpiRowProps) {
return (
<div
data-testid={TESTIDS.clinicalKpis}
className="grid grid-cols-2 sm:grid-cols-5 gap-3"
>
<StatCard
icon={<Users className="w-4 h-4 text-primary" />}
label="住院总人次"
value={kpis.total_admissions.toLocaleString()}
/>
<StatCard
icon={<CalendarDays className="w-4 h-4 text-primary" />}
label="中位住院日"
value={`${kpis.median_los_days}`}
/>
<StatCard
icon={<Wallet className="w-4 h-4 text-primary" />}
label="人均费用"
value={`¥${Math.round(kpis.mean_cost).toLocaleString()}`}
/>
<StatCard
icon={<HeartPulse className="w-4 h-4 text-success" />}
label="治愈好转率"
value={`${(kpis.cure_rate * 100).toFixed(1)}%`}
color="#16A34A"
/>
<StatCard
icon={<Siren className="w-4 h-4 text-warning" />}
label="急诊入院占比"
value={`${(kpis.emergency_admit_ratio * 100).toFixed(1)}%`}
color="#D97706"
/>
</div>
);
});

View File

@@ -0,0 +1,62 @@
import { memo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
interface CostByDiseaseChartProps {
data: { diagnosis: string; mean_cost: number; n: number }[];
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + '…' : s;
}
/** 各病种平均费用横向柱状图。 */
export const CostByDiseaseChart = memo(function CostByDiseaseChart({
data,
}: CostByDiseaseChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
const chartData = [...data]
.sort((a, b) => a.mean_cost - b.mean_cost)
.map((d) => ({ ...d, displayName: truncate(d.diagnosis, 8) }));
return (
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 34)}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 20, left: 12, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
/>
<YAxis
type="category"
dataKey="displayName"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axisLabel }}
width={72}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number) => [`¥${Math.round(v).toLocaleString()}`, '人均费用']}
/>
<Bar dataKey="mean_cost" fill={CLINICAL_COLORS.cost} barSize={16} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,55 @@
import { memo } from 'react';
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
interface CostVsLosScatterProps {
data: { los: number; cost: number }[];
}
/** 费用 vs 住院天数散点。 */
export const CostVsLosScatter = memo(function CostVsLosScatter({ data }: CostVsLosScatterProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<ScatterChart margin={{ top: 10, right: 16, left: 6, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} />
<XAxis
type="number"
dataKey="los"
name="住院天数"
unit="天"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
/>
<YAxis
type="number"
dataKey="cost"
name="费用"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
width={52}
tickFormatter={(v: number) => `¥${(v / 1000).toFixed(0)}k`}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
cursor={{ strokeDasharray: '3 3' }}
formatter={(value: number, name: string) =>
name === '费用'
? [`¥${value.toLocaleString()}`, name]
: [`${value}`, name]
}
/>
<Scatter data={data} fill={CLINICAL_COLORS.scatter} fillOpacity={0.5} />
</ScatterChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,56 @@
import { memo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
export interface DonutSlice {
name: string;
value: number;
}
interface DonutChartProps {
data: DonutSlice[];
/** name -> color。未命中时按 palette 顺序回退。 */
colorMap?: Record<string, string>;
}
/** 通用环形图。复用于「出院结局构成」与「入院途径构成」。 */
export const DonutChart = memo(function DonutChart({ data, colorMap }: DonutChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
const total = data.reduce((s, d) => s + d.value, 0);
const colorFor = (name: string, idx: number) =>
colorMap?.[name] ??
CLINICAL_COLORS.routePalette[idx % CLINICAL_COLORS.routePalette.length] ??
CLINICAL_COLORS.outcomeFallback;
return (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={56}
outerRadius={88}
paddingAngle={2}
>
{data.map((d, idx) => (
<Cell key={d.name} fill={colorFor(d.name, idx)} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number, name: string) => [
`${v.toLocaleString()}${total > 0 ? ((v / total) * 100).toFixed(1) : '0'}%`,
name,
]}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
</PieChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,51 @@
import { memo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { CLINICAL_COLORS, TOOLTIP_STYLE } from './chartColors';
interface HistogramChartProps {
data: { bin_label: string; count: number }[];
color?: string;
/** tooltip 中数量的标签,如 "住院天数" / "费用区间"。 */
countLabel?: string;
}
/** 通用直方图。复用于「住院天数分布」与「住院费用分布」。 */
export const HistogramChart = memo(function HistogramChart({
data,
color = CLINICAL_COLORS.los,
countLabel = '人次',
}: HistogramChartProps) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-text-muted text-sm"></div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 5, right: 12, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CLINICAL_COLORS.grid} vertical={false} />
<XAxis
dataKey="bin_label"
tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }}
interval={0}
angle={-30}
textAnchor="end"
height={50}
/>
<YAxis tick={{ fontSize: 10, fill: CLINICAL_COLORS.axis }} width={40} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v: number) => [`${v.toLocaleString()}`, countLabel]}
/>
<Bar dataKey="count" fill={color} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
});

View File

@@ -0,0 +1,36 @@
/**
* 住院临床分析页图表字面色值集中处。
* Recharts 需要原始 hex无法用 Tailwind class故在此集中定义避免散落 magic hex。
*/
export const CLINICAL_COLORS = {
primary: '#2563EB', // primary
los: '#2563EB',
cost: '#0891B2', // cyan — 费用维度
scatter: '#7C3AED', // violet — 散点
box: '#3B82F6', // 箱体填充
boxMedian: '#1D4ED8', // 中位刻度
grid: '#E2E8F0',
axis: '#64748B',
axisLabel: '#374151',
tooltipBorder: '#E2E8F0',
tooltipText: '#1E293B',
// 出院结局按严重程度配色:治愈/好转偏绿,未愈/死亡偏红,其他中性
outcome: {
: '#16A34A',
: '#4ADE80',
: '#94A3B8',
: '#F97316',
: '#DC2626',
} as Record<string, string>,
outcomeFallback: '#94A3B8',
// 入院途径 donut 顺序色板
routePalette: ['#2563EB', '#0891B2', '#7C3AED', '#D97706', '#16A34A', '#DC2626'],
} as const;
/** Recharts tooltip 通用样式。 */
export const TOOLTIP_STYLE = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
borderRadius: '8px',
fontSize: '12px',
} as const;