Files
CA/frontend/src/pages/DemographicAnalysis.tsx
Akiba So 3db3b12480 feat(frontend): Phase 1 UX foundation — react-router v6 + responsive AppShell
Atomic foundation for the consensus-approved UX modernization (makes the
platform URL-addressable, refresh-safe, and mobile-usable for hospital demos).

- Migrate hand-rolled useState page switching → react-router v6 (routes.tsx,
  thin App.tsx auth gate, NavLink SideNav, lazy+Suspense per route)
- Add responsive AppShell: persistent rail (lg:) ⇄ off-canvas drawer + hamburger
  (<lg); kills hardcoded ml-[200px]; usable at 375px
- Add ui primitive kit (Skeleton/LoadingState/EmptyState/Card/Panel/Segmented)
- Sweep all raw "加载中..." text loaders → skeleton primitives (G4)
- Centralize test ids (utils/testids.ts); rewrite e2e for URL nav (17/17 pass:
  deep-link, refresh-preserves-page, back, 375px drawer/no-scroll)
- Harden DemographicAnalysis against API shape mismatch (defensive normalize)
- gitignore playwright-report/ and test-results/

Gates: tsc --noEmit 0 · pnpm build ok · e2e 17/17 · grep 加载中 zero outside ui/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:12:13 +08:00

367 lines
14 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 { Fragment, useEffect, useState, useMemo, useCallback } from 'react';
import {
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Users, Activity } from 'lucide-react';
import { caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { LoadingState } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
// --- Chart 3 helpers ---
const AGE_GROUPS = ['0-1', '1-3', '3-6', '6-12', '12-18'];
function buildHeatmapMatrix(data: AgeDiagnosisMatrixItem[]): {
diagnoses: string[];
matrix: number[][];
totals: number[];
} {
// Pivot: count per age_group x diagnosis
const map: Record<string, Record<string, number>> = {};
for (const ag of AGE_GROUPS) {
map[ag] = {};
}
for (const item of data) {
if (map[item.age_group] !== undefined) {
map[item.age_group][item.diagnosis] = item.inpatient;
}
}
// Collect all diagnoses and their total counts
const diagTotals: Record<string, number> = {};
for (const ag of AGE_GROUPS) {
for (const [diag, count] of Object.entries(map[ag])) {
diagTotals[diag] = (diagTotals[diag] || 0) + count;
}
}
// Top 8 diagnoses by total count
const top8 = Object.entries(diagTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([diag]) => diag);
const matrix = AGE_GROUPS.map((ag) => top8.map((diag) => map[ag][diag] || 0));
const totals = top8.map((diag) => diagTotals[diag]);
return { diagnoses: top8, matrix, totals };
}
function getColorClass(value: number, maxValue: number): string {
if (maxValue === 0) return 'bg-blue-50 text-gray-800';
const ratio = value / maxValue;
if (ratio === 0) return 'bg-blue-50 text-gray-800';
if (ratio <= 0.125) return 'bg-blue-100 text-gray-800';
if (ratio <= 0.25) return 'bg-blue-200 text-gray-800';
if (ratio <= 0.375) return 'bg-blue-300 text-gray-800';
if (ratio <= 0.5) return 'bg-blue-400 text-white';
if (ratio <= 0.625) return 'bg-blue-500 text-white';
if (ratio <= 0.75) return 'bg-blue-600 text-white';
if (ratio <= 0.875) return 'bg-blue-700 text-white';
return 'bg-blue-800 text-white';
}
export function DemographicAnalysis() {
const [data, setData] = useState<DemographicsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await caseApi.getDemographics();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
let cancelled = false;
const run = async () => {
setIsLoading(true);
setError(null);
try {
const result = await caseApi.getDemographics();
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
if (!cancelled) setIsLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, []);
// --- Derived data ---
// 防御性归一化:后端返回意外形状(缺字段/类型不符)时降级为空,避免 .map 抛错
// 冒泡到根 ErrorBoundary 把整页白屏(与 DiseaseAnalysis/EnvironmentalHealth 的处理一致)。
const ageData: AgeBin[] = useMemo(() => {
const bins = Array.isArray(data?.age_distribution) ? data!.age_distribution : [];
return bins.map((d) => ({
age_bin: d.age_bin,
outpatient: d.outpatient,
inpatient: d.inpatient,
}));
}, [data]);
const genderData = useMemo(() => {
if (!data) return [];
const male = data.gender_split?.male?.inpatient ?? 0;
const female = data.gender_split?.female?.inpatient ?? 0;
return [
{ name: '男性', value: male, color: '#3B82F6' },
{ name: '女性', value: female, color: '#EC4899' },
];
}, [data]);
const genderTotal = useMemo(() => {
return genderData.reduce((s, d) => s + d.value, 0);
}, [genderData]);
const heatmapData = useMemo(() => {
const matrix = Array.isArray(data?.age_diagnosis_matrix) ? data!.age_diagnosis_matrix : [];
return buildHeatmapMatrix(matrix);
}, [data]);
const heatmapMax = useMemo(() => {
let max = 0;
for (const row of heatmapData.matrix) {
for (const v of row) {
if (v > max) max = v;
}
}
return max;
}, [heatmapData]);
// --- Loading state ---
if (isLoading) {
return <LoadingState testid={TESTIDS.pageLoading} />;
}
const isEmpty =
!data ||
(ageData.length === 0 &&
genderData.length === 0 &&
heatmapData.matrix.length === 0);
return (
<div data-testid="page-demographics" className="flex flex-col h-full overflow-auto">
{error && (
<div className="px-6 pt-4">
<ErrorBanner
error={error}
onRetry={fetchData}
onDismiss={() => setError(null)}
/>
</div>
)}
<div className="p-6 space-y-6">
{/* Page header */}
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-gray-500"></p>
</div>
{isEmpty ? (
<div className="card p-8 text-center text-gray-400 text-sm">
<Activity className="w-8 h-8 mx-auto mb-3 opacity-30" />
</div>
) : (
<>
{/* Chart 1: Age Distribution Histogram */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1">
</div>
<p className="text-[11px] text-gray-400 mb-4">
</p>
{ageData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={ageData}
margin={{ top: 5, right: 10, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="age_bin"
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
label={{ value: '年龄(岁)', position: 'insideBottom', offset: -5, fontSize: 11, fill: '#64748B' }}
/>
<YAxis
tick={{ fontSize: 11, fill: '#64748B' }}
axisLine={{ stroke: '#E2E8F0' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value.toLocaleString(), '住院病例']}
labelFormatter={(label: number) => `${label}`}
/>
<Bar dataKey="inpatient" fill="#3B82F6" name="住院" barSize={28} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Chart 2: Gender Distribution Donut */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
</div>
{genderData.length > 0 && genderTotal > 0 ? (
<div className="relative">
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={genderData}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={90}
paddingAngle={3}
dataKey="value"
nameKey="name"
>
{genderData.map((entry, idx) => (
<Cell key={idx} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [
`${value.toLocaleString()} (${((value / genderTotal) * 100).toFixed(1)}%)`,
name,
]}
/>
</PieChart>
</ResponsiveContainer>
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<div className="text-[24px] font-semibold text-gray-800">
{genderTotal.toLocaleString()}
</div>
<div className="text-[11px] text-gray-500"></div>
</div>
</div>
{/* Legend below */}
<div className="flex justify-center gap-6 mt-2">
{genderData.map((d) => (
<div key={d.name} className="flex items-center gap-2 text-[13px] text-gray-700">
<span
className="w-3 h-3 rounded-full inline-block"
style={{ backgroundColor: d.color }}
/>
{d.name}: {d.value.toLocaleString()} ({((d.value / genderTotal) * 100).toFixed(1)}%)
</div>
))}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* Placeholder for future chart or spacing */}
<div className="hidden lg:block" />
</div>
{/* Chart 3: Age x Diagnosis Heatmap */}
<div className="card p-4">
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1">
×
</div>
<p className="text-[11px] text-gray-400 mb-4">
8
</p>
{heatmapData.matrix.length > 0 && heatmapData.diagnoses.length > 0 ? (
<div className="overflow-x-auto">
<div
className="grid gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden"
style={{
gridTemplateColumns: `80px repeat(${heatmapData.diagnoses.length}, minmax(60px, 1fr))`,
}}
>
{/* Header row */}
<div className="bg-gray-100 px-2 py-2 text-[11px] font-medium text-gray-600">
</div>
{heatmapData.diagnoses.map((diag) => (
<div
key={diag}
className="bg-gray-100 px-2 py-2 text-[11px] font-medium text-gray-600 text-center"
title={diag}
>
{diag.length > 6 ? `${diag.slice(0, 6)}` : diag}
</div>
))}
{/* Data rows */}
{AGE_GROUPS.map((ag, rowIdx) => (
<Fragment key={ag}>
<div
key={`label-${ag}`}
className="bg-white px-2 py-2 text-[12px] text-gray-700 font-medium flex items-center"
>
{ag}
</div>
{heatmapData.matrix[rowIdx].map((value, colIdx) => (
<div
key={`${ag}-${colIdx}`}
className={`${getColorClass(value, heatmapMax)} px-2 py-2 text-center text-[12px] font-medium transition-colors cursor-default`}
title={`${ag}岁 | ${heatmapData.diagnoses[colIdx]}: ${value}`}
>
{value > 0 ? value.toLocaleString() : '-'}
</div>
))}
</Fragment>
))}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</>
)}
</div>
</div>
);
}