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

@@ -0,0 +1,104 @@
/**
* 住院临床分析页(/analysis/clinical验收测试。
* 与 user-flows.spec.ts 一致的鉴权策略addInitScript 注入 cbpoa_token
* 用 page.route 拦截 /api/**,对 inpatient-clinical 返回合法小样本,其余返回 {}。
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
const CLINICAL_FIXTURE = {
kpis: {
total_admissions: 5822,
median_los_days: 4,
mean_cost: 6294,
cure_rate: 0.991,
emergency_admit_ratio: 0.47,
},
los_histogram: [
{ bin_label: '1-2', count: 1200 },
{ bin_label: '3-4', count: 2100 },
{ bin_label: '5-7', count: 1500 },
],
los_by_disease: [
{ diagnosis: '肺炎', p25: 3, median: 5, p75: 7, n: 800 },
{ diagnosis: '支气管炎', p25: 2, median: 4, p75: 6, n: 600 },
],
cost_histogram: [
{ bin_label: '0-3k', count: 1800 },
{ bin_label: '3k-6k', count: 2200 },
],
cost_by_disease: [
{ diagnosis: '肺炎', mean_cost: 7200, n: 800 },
{ diagnosis: '支气管炎', mean_cost: 5100, n: 600 },
],
cost_vs_los: [
{ los: 3, cost: 5000 },
{ los: 5, cost: 7200 },
{ los: 7, cost: 9100 },
],
outcome_counts: [
{ outcome: '治愈', count: 3474 },
{ outcome: '好转', count: 2298 },
{ outcome: '其他', count: 35 },
{ outcome: '未愈', count: 12 },
{ outcome: '死亡', count: 3 },
],
admission_route_counts: [
{ route: '急诊', count: 2700 },
{ route: '门诊', count: 3122 },
],
bmi_by_age_band: [
{ age_band: '0-2', p25: 14, median: 16, p75: 18, n: 400 },
{ age_band: '3-6', p25: 15, median: 17, p75: 19, n: 500 },
],
};
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/stats/inpatient-clinical')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(CLINICAL_FIXTURE),
});
return;
}
// 其余接口返回空对象,本页不依赖。
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
}
test.describe('住院临床分析页', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link /analysis/clinical mounts page-clinical + clinical-kpis', async ({ page }) => {
await page.goto('/analysis/clinical');
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
});
test('no horizontal scroll at 375px', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/analysis/clinical');
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
const noHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
);
expect(noHorizontalScroll).toBe(true);
});
});

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;

View File

@@ -0,0 +1,175 @@
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="年龄别 BMIP25中位P75">
<BoxPlotRows rows={bmiBox} />
</Card>
</div>
</div>
);
}

View File

@@ -11,8 +11,9 @@ import {
Cell,
ReferenceLine,
} from 'recharts';
import { Stethoscope, Activity } from 'lucide-react';
import { caseApi } from '@/services/api';
import { Stethoscope, Activity, MessageSquareText } from 'lucide-react';
import { caseApi, statsApi } from '@/services/api';
import type { SymptomsResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import type {
DiagnosisDistributionItem,
@@ -374,12 +375,64 @@ function DiagnosisSummaryTable({
);
}
// --- Chart 5: Outpatient Symptom Keyword Frequency ---
function SymptomFrequencyChart({ data }: { data: SymptomsResponse['symptoms'] }) {
if (!data || data.length === 0) {
return <div className="text-center py-8 text-gray-400 text-sm"></div>;
}
// Sort by count desc; chart renders bottom-up so reverse for top-at-top display.
const sorted = [...data].sort((a, b) => b.count - a.count);
const chartData = sorted.map((d) => ({
...d,
displayName: truncate(d.keyword, 8),
}));
return (
<ResponsiveContainer width="100%" height={Math.max(320, chartData.length * 22)}>
<BarChart
data={[...chartData].reverse()}
layout="vertical"
margin={{ top: 5, right: 20, left: 40, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 10, fill: '#64748B' }}
tickFormatter={(v) => v.toLocaleString()}
/>
<YAxis
type="category"
dataKey="displayName"
tick={{ fontSize: 10, fill: '#374151' }}
width={70}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '出现次数']}
/>
<Bar dataKey="count" name="count" fill="#3B82F6" barSize={14} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
// --- Page Component ---
export function DiseaseAnalysis() {
const [diagDistribution, setDiagDistribution] = useState<DiagnosisDistributionItem[]>([]);
const [seasonality, setSeasonality] = useState<DiseaseSeasonalityPoint[]>([]);
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
const [symptoms, setSymptoms] = useState<SymptomsResponse['symptoms']>([]);
const [revisitRatio, setRevisitRatio] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]);
@@ -390,10 +443,11 @@ export function DiseaseAnalysis() {
setIsLoading(true);
setErrors([]);
const [distR, seasonR, districtR] = await Promise.allSettled([
const [distR, seasonR, districtR, symptomR] = await Promise.allSettled([
caseApi.getDiagnosisDistribution(15),
caseApi.getDiseaseSeasonality(),
caseApi.getDistricts(),
statsApi.getSymptoms(20),
]);
if (cancelled) return;
@@ -418,6 +472,15 @@ export function DiseaseAnalysis() {
newErrors.push('区县数据加载失败');
}
if (symptomR.status === 'fulfilled') {
setSymptoms(symptomR.value.symptoms || []);
setRevisitRatio(
typeof symptomR.value.revisit_ratio === 'number' ? symptomR.value.revisit_ratio : null,
);
} else {
newErrors.push('症状词频数据加载失败');
}
setErrors(newErrors);
setIsLoading(false);
};
@@ -497,6 +560,26 @@ export function DiseaseAnalysis() {
</div>
<DiagnosisSummaryTable diagnoses={diagDistribution} districtsData={districts} />
</div>
{/* Chart 5: Outpatient Symptom Keyword Frequency */}
<div data-testid="symptom-freq" className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1 flex items-center gap-2">
<MessageSquareText className="w-3.5 h-3.5 text-gray-400" />
</div>
<div className="text-[11px] text-gray-500 mb-4">
/
{revisitRatio !== null && (
<span className="ml-2">
<span className="font-semibold text-gray-700">
{(revisitRatio * 100).toFixed(1)}%
</span>
</span>
)}
</div>
<SymptomFrequencyChart data={symptoms} />
</div>
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { LoadingState } from '@/components/ui';
import { LoadingState, Segmented } from '@/components/ui';
import {
BarChart,
Bar,
@@ -12,7 +12,8 @@ import {
ReferenceLine,
} from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore';
import { caseApi } from '@/services/api';
import { caseApi, statsApi } from '@/services/api';
import type { IncidenceRateResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
@@ -36,6 +37,10 @@ export function DistrictComparison() {
const [caseDistrictData, setCaseDistrictData] = useState<DistrictCaseData[]>([]);
const [caseDataLoading, setCaseDataLoading] = useState(false);
const [caseDataError, setCaseDataError] = useState<string | null>(null);
const [incidence, setIncidence] = useState<IncidenceRateResponse['districts']>([]);
const [incidenceLoading, setIncidenceLoading] = useState(false);
const [incidenceError, setIncidenceError] = useState<string | null>(null);
const [incidenceMetric, setIncidenceMetric] = useState<'rate' | 'count'>('rate');
useEffect(() => {
fetchDistricts();
@@ -61,6 +66,26 @@ export function DistrictComparison() {
return () => { cancelled = true; };
}, []);
useEffect(() => {
let cancelled = false;
setIncidenceLoading(true);
setIncidenceError(null);
statsApi.getIncidenceRate()
.then((res) => {
if (!cancelled) {
setIncidence(res.districts || []);
setIncidenceLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setIncidenceError((e as Error).message || '加载发病率数据失败');
setIncidenceLoading(false);
}
});
return () => { cancelled = true; };
}, []);
const metricConfig = {
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
@@ -104,6 +129,12 @@ export function DistrictComparison() {
return { data, cityOIAvg };
}, [caseDistrictData]);
const incidenceChart = useMemo(() => {
const valueKey = incidenceMetric === 'rate' ? 'rate_per_10k' : 'total_cases';
const data = [...incidence].sort((a, b) => b[valueKey] - a[valueKey]);
return { data, valueKey };
}, [incidence, incidenceMetric]);
const heatmapMetrics = useMemo(() => {
const caseMap = new Map(caseDistrictData.map((d) => [d.district, d]));
const rows: string[] = [];
@@ -155,6 +186,25 @@ export function DistrictComparison() {
onDismiss={() => setCaseDataError(null)}
/>
)}
{incidenceError && (
<ErrorBanner
error={incidenceError}
onRetry={() => {
setIncidenceError(null);
setIncidenceLoading(true);
statsApi.getIncidenceRate()
.then((res) => {
setIncidence(res.districts || []);
setIncidenceLoading(false);
})
.catch((e) => {
setIncidenceError((e as Error).message || '加载发病率数据失败');
setIncidenceLoading(false);
});
}}
onDismiss={() => setIncidenceError(null)}
/>
)}
<div className="mb-5">
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary" />
@@ -380,6 +430,79 @@ export function DistrictComparison() {
)}
</div>
{/* Standardized Incidence Rate (per 10k) with count/rate toggle */}
<div data-testid="incidence-rate" className="card p-4 mb-4">
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
{incidenceMetric === 'rate' ? '标化发病率(每万人)区域排名' : '病例数 区域排名'}
</div>
<Segmented
testid="incidence-rate-toggle"
size="sm"
value={incidenceMetric}
onChange={setIncidenceMetric}
options={[
{ value: 'count', label: '病例数' },
{ value: 'rate', label: '每万人发病率' },
]}
/>
</div>
<p className="text-[11px] text-text-muted mb-4">
</p>
{incidenceLoading && <LoadingState />}
{!incidenceLoading && incidenceChart.data.length > 0 ? (
<ResponsiveContainer width="100%" height={380}>
<BarChart
data={incidenceChart.data}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
tickFormatter={(v: number) => v.toLocaleString()}
/>
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 12, fill: '#1E293B', fontWeight: 500 }}
axisLine={{ stroke: '#E2E8F0' }}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(_value: number, _name, item) => {
const p = item?.payload as IncidenceRateResponse['districts'][number];
return [
`病例数 ${p.total_cases.toLocaleString()} · 人口 ${p.population.toLocaleString()} · 每万人 ${p.rate_per_10k.toLocaleString()}`,
incidenceMetric === 'rate' ? '标化发病率' : '病例数',
];
}}
/>
<Bar
dataKey={incidenceChart.valueKey}
name={incidenceMetric === 'rate' ? '每万人发病率' : '病例数'}
radius={[0, 4, 4, 0]}
maxBarSize={32}
fill={incidenceMetric === 'rate' ? '#DC2626' : '#2563EB'}
/>
</BarChart>
</ResponsiveContainer>
) : (
!incidenceLoading && (
<div className="text-center py-8 text-text-secondary"></div>
)
)}
</div>
{/* O/I Ratio Comparison Bar Chart */}
<div className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, Fragment } from 'react';
import {
LineChart,
Line,
@@ -12,9 +12,13 @@ import {
ResponsiveContainer,
ReferenceLine,
Cell,
ScatterChart,
Scatter,
ZAxis,
} from 'recharts';
import { Wind } from 'lucide-react';
import { envApi, caseApi } from '@/services/api';
import { envApi, caseApi, statsApi } from '@/services/api';
import type { EnvCorrelationResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
import type {
@@ -58,6 +62,55 @@ function getAQICategory(aqi: number): typeof AQI_CATEGORIES[number] {
return AQI_CATEGORIES[AQI_CATEGORIES.length - 1];
}
// Canonical pollutant order for the 7×7 correlation matrix.
const CORR_POLLUTANTS = ['AQI', 'PM25', 'PM10', 'SO2', 'NO2', 'O3', 'CO'];
function pollutantLabel(key: string): string {
switch (key) {
case 'PM25':
return 'PM2.5';
case 'SO2':
return 'SO₂';
case 'NO2':
return 'NO₂';
case 'O3':
return 'O₃';
default:
return key;
}
}
// Diverging color scale for a correlation value in [-1, 1].
// Blue (negative) → white (0) → red (positive); opacity scales with |corr|.
function corrColor(corr: number): string {
const v = Math.max(-1, Math.min(1, corr));
if (v >= 0) return `rgba(220, 38, 38, ${0.12 + 0.88 * v})`;
return `rgba(37, 99, 235, ${0.12 + 0.88 * -v})`;
}
// Least-squares linear regression: returns slope/intercept over (x, y) points.
function linearRegression(
points: { x: number; y: number }[],
): { slope: number; intercept: number } | null {
const n = points.length;
if (n < 2) return null;
let sx = 0;
let sy = 0;
let sxx = 0;
let sxy = 0;
for (const p of points) {
sx += p.x;
sy += p.y;
sxx += p.x * p.x;
sxy += p.x * p.y;
}
const denom = n * sxx - sx * sx;
if (Math.abs(denom) < 1e-9) return null;
const slope = (n * sxy - sx * sy) / denom;
const intercept = (sy - slope * sx) / n;
return { slope, intercept };
}
function findMaxLag(correlations: LagCorrelationItem[], pollutant: string): number | null {
if (correlations.length === 0) return null;
const pollData = correlations.filter(
@@ -83,6 +136,7 @@ export function EnvironmentalHealth() {
const [pollutants365, setPollutants365] = useState<PollutantPoint[]>([]);
const [pollutants30, setPollutants30] = useState<PollutantPoint[]>([]);
const [caseTrend, setCaseTrend] = useState<CaseTrendPoint[]>([]);
const [envCorr, setEnvCorr] = useState<EnvCorrelationResponse | null>(null);
// UI states
const [isLoading, setIsLoading] = useState(true);
@@ -104,11 +158,12 @@ export function EnvironmentalHealth() {
setIsLoading(true);
setErrors([]);
const [lagR, p365R, p30R, caseTrendR] = await Promise.allSettled([
const [lagR, p365R, p30R, caseTrendR, envCorrR] = await Promise.allSettled([
envApi.getLagCorrelations(),
envApi.getPollutants(365),
envApi.getPollutants(30),
caseApi.getTrend({ group_by: 'day' }),
statsApi.getEnvCorrelation(),
]);
if (cancelled) return;
@@ -145,6 +200,12 @@ export function EnvironmentalHealth() {
newErrors.push('病例趋势数据加载失败');
}
if (envCorrR.status === 'fulfilled') {
setEnvCorr(envCorrR.value);
} else {
newErrors.push('污染物关联分析数据加载失败');
}
setErrors(newErrors);
setIsLoading(false);
};
@@ -266,6 +327,61 @@ export function EnvironmentalHealth() {
}));
}, [pollutants30]);
// --- Correlation: pollutant × cases (grid-level Pearson) ---
const corrWithCases = useMemo(() => {
const matrix = envCorr?.correlation_matrix ?? [];
return matrix
.map((m) => ({
pollutant: m.pollutant,
label: pollutantLabel(m.pollutant),
corr: m.corr_with_cases,
}))
.sort((a, b) => Math.abs(b.corr) - Math.abs(a.corr));
}, [envCorr]);
// --- Correlation: pollutant pairwise 7×7 heatmap ---
// pollutant_pairwise carries the upper triangle (21 pairs); we mirror it into
// a symmetric lookup and fill the diagonal with 1.
const pairwiseGrid = useMemo(() => {
const pairs = envCorr?.pollutant_pairwise ?? [];
if (pairs.length === 0) return null;
const lookup = new Map<string, number>();
for (const p of pairs) {
lookup.set(`${p.a}|${p.b}`, p.corr);
lookup.set(`${p.b}|${p.a}`, p.corr);
}
return CORR_POLLUTANTS.map((rowKey) =>
CORR_POLLUTANTS.map((colKey) => {
if (rowKey === colKey) return 1;
return lookup.get(`${rowKey}|${colKey}`) ?? null;
}),
);
}, [envCorr]);
// --- Scatter: PM2.5 × cases + least-squares regression line ---
const scatterPoints = useMemo(() => {
return (envCorr?.scatter ?? []).map((s) => ({ pm25: s.pm25, cases: s.cases }));
}, [envCorr]);
const regression = useMemo(() => {
return linearRegression(scatterPoints.map((p) => ({ x: p.pm25, y: p.cases })));
}, [scatterPoints]);
// Two endpoints across the observed PM2.5 range to draw the trend line.
const regressionLine = useMemo(() => {
if (!regression || scatterPoints.length === 0) return [];
let minX = Infinity;
let maxX = -Infinity;
for (const p of scatterPoints) {
if (p.pm25 < minX) minX = p.pm25;
if (p.pm25 > maxX) maxX = p.pm25;
}
return [
{ pm25: minX, cases: regression.slope * minX + regression.intercept },
{ pm25: maxX, cases: regression.slope * maxX + regression.intercept },
];
}, [regression, scatterPoints]);
// --- Loading state ---
if (isLoading) {
return (
@@ -706,6 +822,229 @@ export function EnvironmentalHealth() {
</div>
)}
</div>
{/* === 污染物-病例关联分析 === */}
<div data-testid="env-correlation" className="space-y-6">
<div>
<h2 className="font-display text-[15px] font-semibold mb-1">
-
</h2>
<p className="text-[11px] text-text-muted">
Pearson grid-level
</p>
</div>
{/* (a) Pollutant × Cases correlation */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
×
</div>
{corrWithCases.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={corrWithCases}
layout="vertical"
margin={{ top: 5, right: 20, left: 50, bottom: 5 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#E2E8F0"
horizontal={false}
/>
<XAxis
type="number"
domain={[-1, 1]}
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
type="category"
dataKey="label"
tick={{ fontSize: 11, fill: '#374151' }}
width={50}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toFixed(3), '相关系数']}
/>
<ReferenceLine x={0} stroke="#94A3B8" strokeWidth={1} />
<Bar dataKey="corr" barSize={20} radius={[0, 4, 4, 0]}>
{corrWithCases.map((entry, idx) => (
<Cell key={idx} fill={corrColor(entry.corr)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<p className="text-[10px] text-text-muted mt-3 text-center">
Pearson ==
</p>
</>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
{/* (b) Pollutant pairwise correlation heatmap */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{pairwiseGrid ? (
<>
<div className="overflow-x-auto">
<div
className="grid gap-px min-w-[320px]"
style={{
gridTemplateColumns: `48px repeat(${CORR_POLLUTANTS.length}, minmax(0, 1fr))`,
}}
>
{/* Header row */}
<div />
{CORR_POLLUTANTS.map((key) => (
<div
key={`h-${key}`}
className="text-[10px] font-medium text-text-muted text-center py-1"
>
{pollutantLabel(key)}
</div>
))}
{/* Body rows */}
{pairwiseGrid.map((row, ri) => (
<Fragment key={`r-${CORR_POLLUTANTS[ri]}`}>
<div className="text-[10px] font-medium text-text-muted flex items-center justify-end pr-2">
{pollutantLabel(CORR_POLLUTANTS[ri])}
</div>
{row.map((val, ci) => (
<div
key={`c-${ri}-${ci}`}
className="aspect-square flex items-center justify-center text-[9px] font-medium rounded-sm"
style={{
backgroundColor:
val === null ? '#F1F5F9' : corrColor(val),
color:
val !== null && Math.abs(val) > 0.55
? '#FFFFFF'
: '#475569',
}}
title={`${pollutantLabel(CORR_POLLUTANTS[ri])} × ${pollutantLabel(
CORR_POLLUTANTS[ci],
)}: ${val === null ? 'N/A' : val.toFixed(2)}`}
>
{val === null ? '-' : val.toFixed(2)}
</div>
))}
</Fragment>
))}
</div>
</div>
<p className="text-[10px] text-text-muted mt-3 text-center">
线=1.00==
</p>
</>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
{/* (c) PM2.5 × cases scatter + regression line */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
PM2.5 × + 线
</div>
{scatterPoints.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={320}>
<ScatterChart margin={{ top: 5, right: 20, left: 10, bottom: 15 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
type="number"
dataKey="pm25"
name="PM2.5"
unit="μg/m³"
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{
value: 'PM2.5 (μg/m³)',
position: 'insideBottom',
offset: -8,
fontSize: 11,
fill: '#64748B',
}}
/>
<YAxis
type="number"
dataKey="cases"
name="病例数"
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{
value: '病例数',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 11,
fill: '#64748B',
}}
/>
<ZAxis range={[30, 30]} />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [
Math.round(value).toLocaleString(),
name,
]}
/>
<Scatter
name="网格点"
data={scatterPoints}
fill="#7C3AED"
fillOpacity={0.4}
/>
{regressionLine.length === 2 && (
<Scatter
name="回归线"
data={regressionLine}
line={{ stroke: '#DC2626', strokeWidth: 2 }}
lineType="joint"
fill="#DC2626"
shape={() => <g />}
legendType="none"
/>
)}
</ScatterChart>
</ResponsiveContainer>
{regression && (
<p className="text-[10px] text-text-muted mt-3 text-center">
{regression.slope.toFixed(2)} × PM2.5 +{' '}
{regression.intercept.toFixed(1)}
</p>
)}
</>
) : (
<div className="text-center py-8 text-text-muted text-sm">
</div>
)}
</div>
</div>
</div>
</div>
);

View File

@@ -16,7 +16,8 @@ import {
ReferenceLine,
} from 'recharts';
import { useAnalysisStore } from '@/stores/analysisStore';
import { caseApi } from '@/services/api';
import { caseApi, statsApi } from '@/services/api';
import type { TemporalResponse } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TrendingUp, Calendar, Activity } from 'lucide-react';
import type { CaseTrendPoint } from '@/types';
@@ -48,6 +49,7 @@ export function TrendAnalysis() {
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
const [multiYearData, setMultiYearData] = useState<Record<string, CaseTrendPoint[]>>({});
const [multiYearLoading, setMultiYearLoading] = useState(false);
const [weekdayData, setWeekdayData] = useState<TemporalResponse['weekday']>([]);
useEffect(() => {
fetchTrend(selectedDays);
@@ -82,6 +84,19 @@ export function TrendAnalysis() {
return () => { cancelled = true; };
}, []);
useEffect(() => {
let cancelled = false;
statsApi
.getTemporal()
.then((res) => {
if (!cancelled) setWeekdayData(res.weekday || []);
})
.catch(() => {
// weekday distribution is supplementary — fail silently
});
return () => { cancelled = true; };
}, []);
// Merge multi-year data by month (data is monthly) over a fixed 1..12 sequence
const mergedMultiYearData = (() => {
const yearColors: Record<string, string> = { '2022': '#94A3B8', '2023': '#3B82F6', '2024': '#EF4444' };
@@ -444,6 +459,52 @@ export function TrendAnalysis() {
</BarChart>
</ResponsiveContainer>
</div>
{/* Weekday case distribution (门诊/住院) */}
<div data-testid="weekday-dist" className="card p-4 mb-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
{weekdayData.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={weekdayData}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="weekday"
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{ value: '就诊量', angle: -90, position: 'insideLeft', offset: 0, fontSize: 11, fill: '#64748B' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '12px' }} />
<Bar dataKey="outpatient" name="门诊" stackId="visits" fill="#3B82F6" radius={[0, 0, 0, 0]} />
<Bar dataKey="inpatient" name="住院" stackId="visits" fill="#EF4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<p className="text-[10px] text-text-muted mt-3 text-center">
12/
</p>
</>
) : (
<div className="text-center py-8 text-text-muted text-sm"></div>
)}
</div>
</div>
);
}

View File

@@ -33,6 +33,9 @@ const DiseaseAnalysis = lazy(() =>
const EnvironmentalHealth = lazy(() =>
import('@/pages/EnvironmentalHealth').then((m) => ({ default: m.EnvironmentalHealth }))
);
const ClinicalAnalysis = lazy(() =>
import('@/pages/ClinicalAnalysis').then((m) => ({ default: m.ClinicalAnalysis }))
);
// 用 Suspense 包裹懒加载页面,统一加载态。
function lazyElement(Page: ComponentType): JSX.Element {
@@ -56,6 +59,7 @@ export const appRoutes: RouteObject[] = [
{ path: 'analysis/demographics', element: lazyElement(DemographicAnalysis) },
{ path: 'analysis/disease', element: lazyElement(DiseaseAnalysis) },
{ path: 'analysis/environment', element: lazyElement(EnvironmentalHealth) },
{ path: 'analysis/clinical', element: lazyElement(ClinicalAnalysis) },
// 未知路径回退到监测面板。
{ path: '*', element: <Navigate to="/monitoring" replace /> },
];

View File

@@ -343,4 +343,48 @@ export const reportApi = {
getLatestSummary: (): Promise<ReportSummary> => cachedGet('/reports/summary/latest'),
};
// ===== 深度统计分析(/api/stats=====
export interface InpatientClinicalResponse {
kpis: {
total_admissions: number;
median_los_days: number;
mean_cost: number;
cure_rate: number;
emergency_admit_ratio: number;
};
los_histogram: { bin_label: string; count: number }[];
los_by_disease: { diagnosis: string; p25: number; median: number; p75: number; n: number }[];
cost_histogram: { bin_label: string; count: number }[];
cost_by_disease: { diagnosis: string; mean_cost: number; n: number }[];
cost_vs_los: { los: number; cost: number }[];
outcome_counts: { outcome: string; count: number }[];
admission_route_counts: { route: string; count: number }[];
bmi_by_age_band: { age_band: string; p25: number; median: number; p75: number; n: number }[];
}
export interface SymptomsResponse {
symptoms: { keyword: string; count: number }[];
revisit_ratio: number;
}
export interface IncidenceRateResponse {
districts: { district: string; total_cases: number; population: number; rate_per_10k: number }[];
}
export interface EnvCorrelationResponse {
correlation_matrix: { pollutant: string; corr_with_cases: number }[];
scatter: { pm25: number; aqi: number; cases: number }[];
pollutant_pairwise: { a: string; b: string; corr: number }[];
}
export interface TemporalResponse {
weekday: { weekday: string; outpatient: number; inpatient: number; total: number }[];
month_year: { year: number; month: number; total: number }[];
yoy: { period: string; current: number; previous: number; growth_pct: number }[];
}
export const statsApi = {
getInpatientClinical: (): Promise<InpatientClinicalResponse> => cachedGet('/stats/inpatient-clinical'),
getSymptoms: (top: number = 20): Promise<SymptomsResponse> => cachedGet('/stats/symptoms', { top }),
getIncidenceRate: (): Promise<IncidenceRateResponse> => cachedGet('/stats/incidence-rate'),
getEnvCorrelation: (): Promise<EnvCorrelationResponse> => cachedGet('/stats/env-correlation'),
getTemporal: (): Promise<TemporalResponse> => cachedGet('/stats/temporal'),
};
export default api;

View File

@@ -22,6 +22,7 @@ export const TESTIDS = {
navDemographics: 'nav-demographics',
navDisease: 'nav-disease',
navEnvironment: 'nav-environment',
navClinical: 'nav-clinical',
// 页面挂载点
pageMonitoring: 'page-monitoring',
@@ -34,6 +35,8 @@ export const TESTIDS = {
pageDemographics: 'page-demographics',
pageDisease: 'page-disease',
pageEnvironment: 'page-environment',
pageClinical: 'page-clinical',
clinicalKpis: 'clinical-kpis',
// 综合概览 大屏
kpiRow: 'kpi-row',