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>
);
}