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

@@ -0,0 +1,56 @@
import { useState, useCallback } from 'react';
import { Outlet } from 'react-router-dom';
import { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav';
import { useRiskStore } from '@/stores';
import { TESTIDS } from '@/utils/testids';
interface AppShellProps {
onLogout?: () => void;
}
// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。
export function AppShell({ onLogout }: AppShellProps) {
const alerts = useRiskStore((s) => s.alerts);
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
return (
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} />
<div className="flex flex-1 min-h-0">
{/* lg 及以上:持久侧栏导轨 */}
<aside
data-testid={TESTIDS.sidebarRail}
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
>
<SideNav alertCount={alerts.length} />
</aside>
{/* lg 以下:离屏抽屉 + 遮罩 */}
{drawerOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
onClick={closeDrawer}
aria-hidden="true"
/>
)}
<aside
data-testid={TESTIDS.appDrawer}
className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${
drawerOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<SideNav alertCount={alerts.length} onNavigate={closeDrawer} />
</aside>
<main className="flex-1 min-w-0 overflow-auto p-5">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { memo, useEffect, useRef, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { geocodedApi } from '@/services/api';
@@ -340,7 +341,7 @@ function CaseMapComponent({ height = '480px' }: CaseMapProps) {
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
<div className="text-[11px] text-text-secondary">
{isLoading ? (
<span className="text-text-muted">...</span>
<Skeleton className="h-3 w-20 inline-block align-middle" />
) : error ? (
<span className="text-danger">: {error}</span>
) : (

View File

@@ -1,76 +1,78 @@
import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { TESTIDS } from '@/utils/testids';
interface SideNavProps {
activePage: string;
onPageChange: (page: string) => void;
alertCount?: number;
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
onNavigate?: () => void;
}
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
interface NavItem {
to: string;
label: string;
testid: string;
}
const modules: { id: string; label: string; icon: React.ReactNode; items: NavItem[] }[] = [
{
id: 'monitoring',
label: '监测',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" />
</svg>
),
items: [
{ id: 'monitoring', label: '监测面板' },
],
items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }],
},
{
id: 'alert',
label: '预警',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
</svg>
),
items: [
{ id: 'alerts', label: '预警地图' },
],
items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }],
},
{
id: 'analysis',
label: '分析',
icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z" />
</svg>
),
items: [
{ id: 'trend-analysis', label: '趋势分析' },
{ id: 'district-comparison', label: '区域对比' },
{ id: 'insights', label: '智能洞察' },
{ id: 'reports', label: '报表中心' },
{ id: 'demographics', label: '人群分析' },
{ id: 'disease', label: '疾病分析' },
{ id: 'environment', label: '环境健康' },
{ to: '/overview', label: '总览', testid: TESTIDS.navOverview },
{ to: '/analysis/trend', label: '趋势分析', testid: TESTIDS.navTrend },
{ to: '/analysis/district', label: '区域对比', testid: TESTIDS.navDistrict },
{ to: '/analysis/insights', label: '智能洞察', testid: TESTIDS.navInsights },
{ to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
{ to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
{ to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
],
},
];
export function SideNav({
activePage,
onPageChange,
alertCount = 0,
}: SideNavProps) {
const [expanded, setExpanded] = useState<string | null>('monitoring');
export function SideNav({ alertCount = 0, onNavigate }: SideNavProps) {
const location = useLocation();
const handleItemClick = (moduleId: string, itemId: string) => {
setExpanded(moduleId);
onPageChange(itemId);
};
// 当前路径命中的模块默认展开。
const moduleForPath = (pathname: string) =>
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
const [expanded, setExpanded] = useState<string | null>(() => moduleForPath(location.pathname));
const isActiveModule = (moduleId: string) => {
const module = modules.find(m => m.id === moduleId);
const module = modules.find((m) => m.id === moduleId);
if (!module) return false;
return module.items.some(item => item.id === activePage);
return module.items.some((item) => location.pathname.startsWith(item.to));
};
return (
<aside className="w-[200px] bg-bg-card border-r border-border fixed top-[52px] left-0 bottom-0 overflow-y-auto py-4 px-2">
<nav className="h-full overflow-y-auto py-4 px-2">
{modules.map((module) => (
<div key={module.id} className="mb-4">
<button
@@ -81,9 +83,7 @@ export function SideNav({
: 'text-text-primary hover:bg-bg-hover'
}`}
>
<span className="w-4 h-4 flex items-center justify-center">
{module.icon}
</span>
<span className="w-4 h-4 flex items-center justify-center">{module.icon}</span>
<span>{module.label}</span>
{module.id === 'alert' && alertCount > 0 && (
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
@@ -95,22 +95,26 @@ export function SideNav({
{expanded === module.id && (
<div className="mt-1 pl-7">
{module.items.map((item) => (
<button
key={item.id}
onClick={() => handleItemClick(module.id, item.id)}
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
activePage === item.id
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`}
<NavLink
key={item.to}
to={item.to}
data-testid={item.testid}
onClick={onNavigate}
className={({ isActive }) =>
`block w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
isActive
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
}`
}
>
{item.label}
</button>
</NavLink>
))}
</div>
)}
</div>
))}
</aside>
</nav>
);
}

View File

@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react';
import { TESTIDS } from '@/utils/testids';
interface TopNavProps {
onLogout?: () => void;
// 移动端汉堡按钮:切换侧栏抽屉。
onToggleMenu?: () => void;
}
function Clock() {
@@ -13,14 +16,27 @@ function Clock() {
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
}
export function TopNav({ onLogout }: TopNavProps) {
export function TopNav({ onLogout, onToggleMenu }: TopNavProps) {
return (
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 fixed top-0 left-0 right-0 z-50">
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
{onToggleMenu && (
<button
type="button"
onClick={onToggleMenu}
aria-label="打开菜单"
data-testid={TESTIDS.hamburger}
className="lg:hidden mr-3 -ml-1 w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-bg-hover transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z"/>
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z" />
</svg>
</div>
<span className="font-display font-semibold text-[15px] text-text-primary">
@@ -28,19 +44,19 @@ export function TopNav({ onLogout }: TopNavProps) {
</span>
</div>
<div className="w-px h-5 bg-border ml-4 mr-4" />
<div className="w-px h-5 bg-border ml-4 mr-4 hidden sm:block" />
<span className="text-[13px] text-text-secondary">
<span className="text-[13px] text-text-secondary hidden sm:inline">
</span>
<div className="ml-auto flex items-center gap-5">
<span className="text-[12px] text-text-muted">
<span className="text-[12px] text-text-muted hidden sm:inline">
<Clock />
</span>
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
admin
</div>

View File

@@ -0,0 +1,41 @@
import React, { memo } from 'react';
export const Card = memo(function Card({
children,
className,
title,
actions,
testid,
}: {
children: React.ReactNode;
className?: string;
title?: React.ReactNode;
actions?: React.ReactNode;
testid?: string;
}): JSX.Element {
const hasHeader = title != null || actions != null;
return (
<div
data-testid={testid}
className={[
'bg-bg-card rounded-lg border border-border',
className,
]
.filter(Boolean)
.join(' ')}
>
{hasHeader && (
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border-light">
{title != null && (
<div className="text-sm font-medium text-text-primary">{title}</div>
)}
{actions != null && (
<div className="flex items-center gap-2 shrink-0">{actions}</div>
)}
</div>
)}
<div className="p-4">{children}</div>
</div>
);
});

View File

@@ -0,0 +1,29 @@
import React, { memo } from 'react';
export const EmptyState = memo(function EmptyState({
title,
description,
icon,
action,
}: {
title: string;
description?: string;
icon?: React.ReactNode;
action?: React.ReactNode;
}): JSX.Element {
return (
<div
data-testid="empty-state"
className="flex flex-col items-center justify-center gap-3 py-12 px-6 text-center"
>
{icon && (
<div className="text-text-muted text-4xl">{icon}</div>
)}
<p className="text-sm font-medium text-text-secondary">{title}</p>
{description && (
<p className="text-xs text-text-muted max-w-xs">{description}</p>
)}
{action && <div className="mt-2">{action}</div>}
</div>
);
});

View File

@@ -0,0 +1,30 @@
import { Skeleton } from './Skeleton';
export function LoadingState({
label,
testid,
lines = 3,
}: {
label?: string;
testid?: string;
lines?: number;
}): JSX.Element {
return (
<div
data-testid={testid ?? 'loading-state'}
className="flex flex-col items-center justify-center gap-3 p-6 w-full"
>
<div className="flex flex-col gap-2 w-full max-w-sm">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`}
/>
))}
</div>
{label && (
<p className="text-xs text-text-muted">{label}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import React, { memo } from 'react';
export const Panel = memo(function Panel({
children,
className,
testid,
}: {
children: React.ReactNode;
className?: string;
testid?: string;
}): JSX.Element {
return (
<div
data-testid={testid}
className={['bg-bg-hover/50 rounded-md p-3', className]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
);
});

View File

@@ -0,0 +1,53 @@
import { memo } from 'react';
export const Segmented = memo(function Segmented<T extends string>({
options,
value,
onChange,
size = 'md',
testid,
}: {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
size?: 'sm' | 'md';
testid?: string;
}): JSX.Element {
const sizeClasses = size === 'sm'
? 'px-2.5 py-0.5 text-xs'
: 'px-3.5 py-1 text-[13px]';
return (
<div
data-testid={testid}
className="inline-flex items-center gap-0.5 rounded-full bg-bg-hover p-0.5"
>
{options.map((opt) => {
const isActive = opt.value === value;
return (
<button
key={opt.value}
type="button"
data-testid={testid ? `${testid}-${opt.value}` : undefined}
onClick={() => onChange(opt.value)}
className={[
'rounded-full font-medium transition-colors',
sizeClasses,
isActive
? 'bg-primary text-white shadow-sm'
: 'text-text-secondary hover:bg-bg-active',
].join(' ')}
>
{opt.label}
</button>
);
})}
</div>
);
}) as <T extends string>(props: {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
size?: 'sm' | 'md';
testid?: string;
}) => JSX.Element;

View File

@@ -0,0 +1,22 @@
import { memo } from 'react';
export const Skeleton = memo(function Skeleton({
className,
rounded,
}: {
className?: string;
rounded?: boolean;
}): JSX.Element {
return (
<div
data-testid="skeleton"
className={[
'animate-pulse bg-bg-hover',
rounded ? 'rounded-full' : 'rounded',
className,
]
.filter(Boolean)
.join(' ')}
/>
);
});

View File

@@ -0,0 +1,6 @@
export { Skeleton } from './Skeleton';
export { LoadingState } from './LoadingState';
export { EmptyState } from './EmptyState';
export { Card } from './Card';
export { Panel } from './Panel';
export { Segmented } from './Segmented';