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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user