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>
This commit is contained in:
2026-06-21 20:12:13 +08:00
parent e95e2f1338
commit 3db3b12480
29 changed files with 796 additions and 357 deletions

View File

@@ -14,6 +14,8 @@ import {
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 ---
@@ -108,9 +110,11 @@ export function DemographicAnalysis() {
}, []);
// --- Derived data ---
// 防御性归一化:后端返回意外形状(缺字段/类型不符)时降级为空,避免 .map 抛错
// 冒泡到根 ErrorBoundary 把整页白屏(与 DiseaseAnalysis/EnvironmentalHealth 的处理一致)。
const ageData: AgeBin[] = useMemo(() => {
if (!data) return [];
return data.age_distribution.map((d) => ({
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,
@@ -119,8 +123,8 @@ export function DemographicAnalysis() {
const genderData = useMemo(() => {
if (!data) return [];
const male = data.gender_split.male.inpatient;
const female = data.gender_split.female.inpatient;
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' },
@@ -132,8 +136,8 @@ export function DemographicAnalysis() {
}, [genderData]);
const heatmapData = useMemo(() => {
if (!data) return { diagnoses: [], matrix: [], totals: [] };
return buildHeatmapMatrix(data.age_diagnosis_matrix);
const matrix = Array.isArray(data?.age_diagnosis_matrix) ? data!.age_diagnosis_matrix : [];
return buildHeatmapMatrix(matrix);
}, [data]);
const heatmapMax = useMemo(() => {
@@ -148,11 +152,7 @@ export function DemographicAnalysis() {
// --- Loading state ---
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
return <LoadingState testid={TESTIDS.pageLoading} />;
}
const isEmpty =
@@ -162,7 +162,7 @@ export function DemographicAnalysis() {
heatmapData.matrix.length === 0);
return (
<div className="flex flex-col h-full overflow-auto">
<div data-testid="page-demographics" className="flex flex-col h-full overflow-auto">
{error && (
<div className="px-6 pt-4">
<ErrorBanner