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:
41
frontend/src/components/ui/Card.tsx
Normal file
41
frontend/src/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
29
frontend/src/components/ui/EmptyState.tsx
Normal file
29
frontend/src/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
30
frontend/src/components/ui/LoadingState.tsx
Normal file
30
frontend/src/components/ui/LoadingState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/Panel.tsx
Normal file
22
frontend/src/components/ui/Panel.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
53
frontend/src/components/ui/Segmented.tsx
Normal file
53
frontend/src/components/ui/Segmented.tsx
Normal 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;
|
||||
22
frontend/src/components/ui/Skeleton.tsx
Normal file
22
frontend/src/components/ui/Skeleton.tsx
Normal 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(' ')}
|
||||
/>
|
||||
);
|
||||
});
|
||||
6
frontend/src/components/ui/index.ts
Normal file
6
frontend/src/components/ui/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user