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

2
.gitignore vendored
View File

@@ -14,6 +14,8 @@ cache/
logs/ logs/
mlruns/ mlruns/
.playwright-mcp/ .playwright-mcp/
frontend/playwright-report/
frontend/test-results/
*Zone.Identifier *Zone.Identifier
# Transcription processing intermediates # Transcription processing intermediates
Outputs/transcript/chunks/ Outputs/transcript/chunks/

View File

@@ -1,242 +1,291 @@
/** /**
* US-007 + US-008: E2E user flow and UI state tests. * Phase-1 acceptance tests: URL-based navigation, responsive layout, and core user flows.
* Simulates real user workflows through the CBPOA system. * Rewrites the previous click-nav suite for react-router v6 URL navigation.
*
* Auth strategy: seed localStorage['cbpoa_token'] via addInitScript (App.tsx gates on
* token presence only; no server validation). Backend is mocked via page.route so the
* suite runs hermetically without a live :8000 backend.
*/ */
import { test, expect } from '@playwright/test'; import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
const BASE_URL = 'http://localhost:3000'; // ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
test.describe('认证流程 (Authentication Flow)', () => { /** Seed auth token and mock all /api/** calls before each page load. */
test('显示登录页面', async ({ page }) => { async function seedAuthAndMockApi(page: Page) {
await page.goto(BASE_URL); // Prevent login gate from appearing.
await page.waitForTimeout(1000); await page.addInitScript(() => {
// Should see login form or app (if cached token) localStorage.setItem('cbpoa_token', 'e2e-test-token');
const isLogin = await page.locator('input').count();
const isApp = await page.locator('nav').count();
expect(isLogin > 0 || isApp > 0).toBeTruthy();
}); });
test('登录表单可交互', async ({ page }) => { // Mock backend responses so the suite is hermetic — no live :8000 required.
await page.goto(BASE_URL); // Each response must match the TypeScript interface shape; returning {} causes
await page.waitForTimeout(1000); // pages to throw when accessing expected array properties.
await page.route('/api/**', (route) => {
const url = route.request().url();
const inputs = page.locator('input'); if (url.includes('/alerts')) {
const count = await inputs.count(); route.fulfill({
status: 200,
if (count >= 2) { contentType: 'application/json',
// Login page is shown body: JSON.stringify({ alerts: [], total: 0 }),
await inputs.first().fill('admin'); });
await inputs.nth(1).fill('admin123'); return;
const loginBtn = page.locator('button[type="submit"], button:has-text("登录"), button:has-text("Login")');
const btnCount = await loginBtn.count();
if (btnCount > 0) {
await loginBtn.first().click();
await page.waitForTimeout(2000);
}
} }
// If no inputs, user is already logged in (token in localStorage)
});
});
test.describe('监测面板 (Monitoring Dashboard)', () => { if (url.includes('/history/aggregated')) {
test('面板加载并显示统计卡片', async ({ page }) => { route.fulfill({
await page.goto(BASE_URL); status: 200,
await page.waitForTimeout(3000); contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
// Should show monitoring page by default });
const statCards = page.locator('[class*="stat"], [class*="card"], [class*="Stat"]'); return;
const cardsCount = await statCards.count();
// Should see some content
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
});
test('时间线控件可交互', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(3000);
// Look for timeline controls
const playButton = page.locator('button:has-text("播放"), button[title*="play" i], button[class*="play" i]');
const prevButton = page.locator('button:has-text("前一天"), button[title*="prev" i]');
const nextButton = page.locator('button:has-text("后一天"), button[title*="next" i]');
if (await playButton.count() > 0) {
await playButton.first().click();
await page.waitForTimeout(1000);
} }
});
test('疾病筛选器可用', async ({ page }) => { if (url.includes('/grids')) {
await page.goto(BASE_URL); route.fulfill({
await page.waitForTimeout(3000); status: 200,
contentType: 'application/json',
const selects = page.locator('select, [role="combobox"], [class*="select" i], [class*="filter" i]'); body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
const count = await selects.count(); });
expect(count >= 0).toBeTruthy(); return;
});
});
test.describe('预警面板 (Alerts Dashboard)', () => {
test('导航到预警面板', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
// Navigate to alerts - click sidebar link
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), button:has-text("告警"), span:has-text("预警"), span:has-text("告警")');
if (await alertsLink.count() > 0) {
await alertsLink.first().click();
await page.waitForTimeout(2000);
} }
});
test('预警列表加载', async ({ page }) => { // DemographicsResponse — used by DemographicAnalysis page.
await page.goto(BASE_URL); if (url.includes('/cases/demographics')) {
await page.waitForTimeout(2000); route.fulfill({
status: 200,
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), span:has-text("预警")'); contentType: 'application/json',
if (await alertsLink.count() > 0) { body: JSON.stringify({
await alertsLink.first().click(); age_distribution: [],
await page.waitForTimeout(3000); gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
const bodyText = await page.textContent('body'); }),
expect(bodyText).toBeTruthy(); });
return;
} }
});
});
test.describe('趋势分析 (Trend Analysis)', () => { // DiseaseAnalysis calls: diagnosis-distribution, seasonality, districts.
test('导航到趋势分析页面', async ({ page }) => { if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
await page.goto(BASE_URL); route.fulfill({
await page.waitForTimeout(2000); status: 200,
contentType: 'application/json',
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势"), a[href*="trend" i]'); body: JSON.stringify([]),
if (await trendLink.count() > 0) { });
await trendLink.first().click(); return;
await page.waitForTimeout(2000);
} }
});
test('趋势图渲染', async ({ page }) => { if (url.includes('/cases/districts') || url.includes('/districts')) {
await page.goto(BASE_URL); route.fulfill({
await page.waitForTimeout(2000); status: 200,
contentType: 'application/json',
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势")'); body: JSON.stringify([]),
if (await trendLink.count() > 0) { });
await trendLink.first().click(); return;
await page.waitForTimeout(3000);
// Recharts renders SVG charts
const svgCharts = page.locator('svg.recharts-surface');
const chartCount = await svgCharts.count();
expect(chartCount >= 0).toBeTruthy();
} }
});
});
test.describe('区县对比 (District Comparison)', () => { // Trend / time-series endpoints.
test('导航到区县对比页面', async ({ page }) => { if (url.includes('/cases/trend') || url.includes('/cases')) {
await page.goto(BASE_URL); route.fulfill({
await page.waitForTimeout(2000); status: 200,
contentType: 'application/json',
const districtLink = page.locator('button:has-text("区县"), button:has-text("对比"), span:has-text("区县")'); body: JSON.stringify({ data: [], total: 0 }),
if (await districtLink.count() > 0) { });
await districtLink.first().click(); return;
await page.waitForTimeout(2000);
} }
});
});
test.describe('报告中心 (Reports Center)', () => { // Default fallback — return a safe empty object.
test('导航到报告中心', async ({ page }) => { route.fulfill({
await page.goto(BASE_URL); status: 200,
await page.waitForTimeout(2000); contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告"), a[href*="report" i]');
if (await reportsLink.count() > 0) {
await reportsLink.first().click();
await page.waitForTimeout(2000);
}
});
test('报告列表加载', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告")');
if (await reportsLink.count() > 0) {
await reportsLink.first().click();
await page.waitForTimeout(3000);
const bodyText = await page.textContent('body');
expect(bodyText).toBeTruthy();
}
});
});
test.describe('UI 状态与错误处理 (UI States & Error Handling)', () => {
test('页面加载显示加载指示器而非白屏', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(500);
const bodyHTML = await page.innerHTML('body');
// Should have some content, even during loading
expect(bodyHTML.length).toBeGreaterThan(0);
});
test('侧边栏导航切换页面正常', async ({ page }) => {
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const navLinks = page.locator('nav a, nav button, [class*="side" i] a, [class*="side" i] button');
const count = await navLinks.count();
if (count >= 2) {
await navLinks.first().click();
await page.waitForTimeout(1000);
await navLinks.nth(1).click();
await page.waitForTimeout(1000);
}
});
test('未出现明显 console 报错', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
page.on('pageerror', (err) => {
errors.push(err.message);
}); });
});
}
await page.goto(BASE_URL); // ---------------------------------------------------------------------------
await page.waitForTimeout(3000); // URL-based navigation (react-router v6)
// ---------------------------------------------------------------------------
const filtered = errors.filter( test.describe('URL-based navigation', () => {
(e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap') test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link: /analysis/disease mounts page-disease directly', async ({ page }) => {
await page.goto('/analysis/disease');
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
});
test('refresh preserves page: reload on /analysis/disease keeps URL and mounts page-disease', async ({
page,
}) => {
await page.goto('/analysis/disease');
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
await page.reload();
await expect(page).toHaveURL(/\/analysis\/disease/);
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
});
test('browser back: from /analysis/trend back to /monitoring restores page-monitoring', async ({
page,
}) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
await page.goto('/analysis/trend');
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('NavLink click updates URL to /alerts and mounts page-alerts', async ({ page }) => {
// Start on /monitoring. The SideNav collapses all modules except the active one,
// so nav-alerts (inside the "预警" module) is hidden behind a collapsed section.
// We must expand the "预警" module first by clicking its header button.
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
// Expand the "预警" module section so nav-alerts NavLink becomes visible.
// Both sidebar-rail and app-drawer render a SideNav; scope to sidebar-rail to avoid
// strict-mode ambiguity (the app-drawer's copy is also in the DOM but off-screen).
await page
.locator(`[data-testid="${TESTIDS.sidebarRail}"] button`)
.filter({ hasText: '预警' })
.click();
await expect(
page.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
).toBeVisible();
await page
.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
.click();
await expect(page).toHaveURL(/\/alerts/);
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
});
test('root / redirects to /monitoring', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('unknown path redirects to /monitoring', async ({ page }) => {
await page.goto('/does-not-exist');
await expect(page).toHaveURL(/\/monitoring/);
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Responsive layout
// ---------------------------------------------------------------------------
test.describe('Responsive layout — mobile @375px', () => {
test.use({ viewport: { width: 375, height: 812 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('hamburger is visible and sidebar-rail is hidden at 375px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).not.toBeVisible();
});
test('tapping hamburger slides app-drawer into viewport', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
// Drawer should be off-screen (translate-x-full) before toggle.
const drawer = page.locator(`[data-testid="${TESTIDS.appDrawer}"]`);
await expect(drawer).not.toBeInViewport();
await page.locator(`[data-testid="${TESTIDS.hamburger}"]`).click();
// After toggle, drawer slides in and becomes visible in viewport.
await expect(drawer).toBeInViewport();
});
test('no horizontal scroll on default route at 375px', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
const noHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
); );
expect(filtered).toHaveLength(0); expect(noHorizontalScroll).toBe(true);
}); });
}); });
test.describe('响应式布局 (Responsive Layout)', () => { test.describe('Responsive layout — desktop @1280px', () => {
test('移动端视口下不崩溃', async ({ page }) => { test.use({ viewport: { width: 1280, height: 800 } });
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(BASE_URL);
await page.waitForTimeout(2000);
const bodyText = await page.textContent('body'); test.beforeEach(async ({ page }) => {
expect(bodyText).toBeTruthy(); await seedAuthAndMockApi(page);
}); });
test('平板视口下正常显示', async ({ page }) => { test('sidebar-rail is visible and hamburger is hidden at 1280px', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 }); await page.goto('/monitoring');
await page.goto(BASE_URL); await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
await page.waitForTimeout(2000);
const bodyText = await page.textContent('body'); await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).toBeVisible();
expect(bodyText).toBeTruthy(); await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).not.toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Core page loading
// ---------------------------------------------------------------------------
test.describe('Core pages load via URL nav', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('/monitoring loads page-monitoring', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
});
test('/alerts loads page-alerts', async ({ page }) => {
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
});
test('/analysis/trend loads page-trend', async ({ page }) => {
await page.goto('/analysis/trend');
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
});
test('/analysis/district loads page-district', async ({ page }) => {
await page.goto('/analysis/district');
await expect(page.locator(`[data-testid="${TESTIDS.pageDistrict}"]`)).toBeVisible();
});
test('/analysis/reports loads page-reports', async ({ page }) => {
await page.goto('/analysis/reports');
await expect(page.locator(`[data-testid="${TESTIDS.pageReports}"]`)).toBeVisible();
});
test('/analysis/demographics loads page-demographics', async ({ page }) => {
await page.goto('/analysis/demographics');
await expect(page.locator(`[data-testid="${TESTIDS.pageDemographics}"]`)).toBeVisible();
});
test('/analysis/environment loads page-environment', async ({ page }) => {
await page.goto('/analysis/environment');
await expect(page.locator(`[data-testid="${TESTIDS.pageEnvironment}"]`)).toBeVisible();
}); });
}); });

View File

@@ -15,6 +15,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4",
"recharts": "^2.12.0", "recharts": "^2.12.0",
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },

View File

@@ -26,6 +26,9 @@ importers:
react-leaflet: react-leaflet:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom:
specifier: ^6.30.4
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts: recharts:
specifier: ^2.12.0 specifier: ^2.12.0
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -385,6 +388,10 @@ packages:
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
engines: {node: '>=14.0.0'}
'@rolldown/pluginutils@1.0.0-beta.27': '@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -1504,6 +1511,19 @@ packages:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
react-router-dom@6.30.4:
resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.4:
resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-smooth@4.0.4: react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies: peerDependencies:
@@ -2157,6 +2177,8 @@ snapshots:
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
'@remix-run/router@1.23.3': {}
'@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.60.2': '@rollup/rollup-android-arm-eabi@4.60.2':
@@ -3233,6 +3255,18 @@ snapshots:
react-refresh@0.17.0: {} react-refresh@0.17.0: {}
react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 6.30.4(react@18.3.1)
react-router@6.30.4(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.3
react: 18.3.1
react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
fast-equals: 5.4.0 fast-equals: 5.4.0

View File

@@ -1,19 +1,9 @@
import { useEffect, useState, Component, ReactNode, Suspense, lazy, useCallback } from 'react'; import { useEffect, useState, Component, ReactNode, useCallback } from 'react';
import { TopNav } from '@/components/TopNav'; import { BrowserRouter, Routes, Route, useRoutes } from 'react-router-dom';
import { SideNav } from '@/components/SideNav'; import { AppShell } from '@/components/AppShell';
import { useRiskStore } from '@/stores'; import { useRiskStore } from '@/stores';
import { Login } from '@/pages/Login'; import { Login } from '@/pages/Login';
import { appRoutes } from '@/routes';
const MonitoringDashboard = lazy(() => import('@/pages/MonitoringDashboard').then(m => ({ default: m.MonitoringDashboard })));
const AlertsDashboard = lazy(() => import('@/pages/AlertsDashboard').then(m => ({ default: m.AlertsDashboard })));
const TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ default: m.TrendAnalysis })));
const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison })));
const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights })));
const ReportsCenter = lazy(() => import('@/pages/ReportsCenter').then(m => ({ default: m.ReportsCenter })));
const DemographicAnalysis = lazy(() => import('@/pages/DemographicAnalysis').then(m => ({ default: m.DemographicAnalysis })));
const DiseaseAnalysis = lazy(() => import('@/pages/DiseaseAnalysis').then(m => ({ default: m.DiseaseAnalysis })));
const EnvironmentalHealth = lazy(() => import('@/pages/EnvironmentalHealth').then(m => ({ default: m.EnvironmentalHealth })));
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -55,28 +45,25 @@ class ErrorBoundary extends Component<Props, State> {
} }
} }
function PageLoader() { // 已登录AppShell 提供布局骨架,子路由表渲染到其 <Outlet/>。
return ( function AuthedApp({ onLogout }: { onLogout: () => void }) {
<div className="flex items-center justify-center h-[60vh]"> const element = useRoutes([
<div className="text-text-secondary text-[13px]">...</div> {
</div> element: <AppShell onLogout={onLogout} />,
); children: appRoutes,
},
]);
return element;
} }
function App() { function App() {
const [activePage, setActivePage] = useState('monitoring');
const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token')); const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token'));
const alerts = useRiskStore((s) => s.alerts);
const fetchAlerts = useRiskStore((s) => s.fetchAlerts); const fetchAlerts = useRiskStore((s) => s.fetchAlerts);
useEffect(() => { useEffect(() => {
if (token) fetchAlerts(); if (token) fetchAlerts();
}, [fetchAlerts, token]); }, [fetchAlerts, token]);
const handlePageChange = useCallback((page: string) => {
setActivePage(page);
}, []);
const handleLogin = useCallback((newToken: string) => { const handleLogin = useCallback((newToken: string) => {
setToken(newToken); setToken(newToken);
}, []); }, []);
@@ -86,41 +73,18 @@ function App() {
setToken(null); setToken(null);
}, []); }, []);
if (!token) {
return (
<ErrorBoundary>
<Login onLogin={handleLogin} />
</ErrorBoundary>
);
}
return ( return (
<ErrorBoundary> <ErrorBoundary>
<div className="min-h-screen bg-bg-page flex flex-col"> <BrowserRouter>
<TopNav onLogout={handleLogout} /> {token ? (
<AuthedApp onLogout={handleLogout} />
<div className="flex flex-1 pt-[52px]"> ) : (
<SideNav // 鉴权门:无 token 时所有路由都进入登录页。
activePage={activePage} <Routes>
onPageChange={handlePageChange} <Route path="*" element={<Login onLogin={handleLogin} />} />
alertCount={alerts.length} </Routes>
/> )}
</BrowserRouter>
<main className="flex-1 ml-[200px] p-5 min-w-0">
<Suspense fallback={<PageLoader />}>
{activePage === 'monitoring' && <MonitoringDashboard />}
{activePage === 'alerts' && <AlertsDashboard />}
{activePage === 'trend-analysis' && <TrendAnalysis />}
{activePage === 'district-comparison' && <DistrictComparison />}
{activePage === 'insights' && <Insights />}
{activePage === 'reports' && <ReportsCenter />}
{activePage === 'demographics' && <DemographicAnalysis />}
{activePage === 'disease' && <DiseaseAnalysis />}
{activePage === 'environment' && <EnvironmentalHealth />}
</Suspense>
</main>
</div>
</div>
</ErrorBoundary> </ErrorBoundary>
); );
} }

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 { memo, useEffect, useRef, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { geocodedApi } from '@/services/api'; 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="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"> <div className="text-[11px] text-text-secondary">
{isLoading ? ( {isLoading ? (
<span className="text-text-muted">...</span> <Skeleton className="h-3 w-20 inline-block align-middle" />
) : error ? ( ) : error ? (
<span className="text-danger">: {error}</span> <span className="text-danger">: {error}</span>
) : ( ) : (

View File

@@ -1,76 +1,78 @@
import { useState } from 'react'; import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { TESTIDS } from '@/utils/testids';
interface SideNavProps { interface SideNavProps {
activePage: string;
onPageChange: (page: string) => void;
alertCount?: number; 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', id: 'monitoring',
label: '监测', label: '监测',
icon: ( icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
), ),
items: [ items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }],
{ id: 'monitoring', label: '监测面板' },
],
}, },
{ {
id: 'alert', id: 'alert',
label: '预警', label: '预警',
icon: ( icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
), ),
items: [ items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }],
{ id: 'alerts', label: '预警地图' },
],
}, },
{ {
id: 'analysis', id: 'analysis',
label: '分析', label: '分析',
icon: ( icon: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <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> </svg>
), ),
items: [ items: [
{ id: 'trend-analysis', label: '趋势分析' }, { to: '/overview', label: '总览', testid: TESTIDS.navOverview },
{ id: 'district-comparison', label: '区域对比' }, { to: '/analysis/trend', label: '趋势分析', testid: TESTIDS.navTrend },
{ id: 'insights', label: '智能洞察' }, { to: '/analysis/district', label: '区域对比', testid: TESTIDS.navDistrict },
{ id: 'reports', label: '报表中心' }, { to: '/analysis/insights', label: '智能洞察', testid: TESTIDS.navInsights },
{ id: 'demographics', label: '人群分析' }, { to: '/analysis/reports', label: '报表中心', testid: TESTIDS.navReports },
{ id: 'disease', label: '疾病分析' }, { to: '/analysis/demographics', label: '人群分析', testid: TESTIDS.navDemographics },
{ id: 'environment', label: '环境健康' }, { to: '/analysis/disease', label: '疾病分析', testid: TESTIDS.navDisease },
{ to: '/analysis/environment', label: '环境健康', testid: TESTIDS.navEnvironment },
], ],
}, },
]; ];
export function SideNav({ export function SideNav({ alertCount = 0, onNavigate }: SideNavProps) {
activePage, const location = useLocation();
onPageChange,
alertCount = 0,
}: SideNavProps) {
const [expanded, setExpanded] = useState<string | null>('monitoring');
const handleItemClick = (moduleId: string, itemId: string) => { // 当前路径命中的模块默认展开。
setExpanded(moduleId); const moduleForPath = (pathname: string) =>
onPageChange(itemId); 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 isActiveModule = (moduleId: string) => {
const module = modules.find(m => m.id === moduleId); const module = modules.find((m) => m.id === moduleId);
if (!module) return false; if (!module) return false;
return module.items.some(item => item.id === activePage); return module.items.some((item) => location.pathname.startsWith(item.to));
}; };
return ( 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) => ( {modules.map((module) => (
<div key={module.id} className="mb-4"> <div key={module.id} className="mb-4">
<button <button
@@ -81,9 +83,7 @@ export function SideNav({
: 'text-text-primary hover:bg-bg-hover' : 'text-text-primary hover:bg-bg-hover'
}`} }`}
> >
<span className="w-4 h-4 flex items-center justify-center"> <span className="w-4 h-4 flex items-center justify-center">{module.icon}</span>
{module.icon}
</span>
<span>{module.label}</span> <span>{module.label}</span>
{module.id === 'alert' && alertCount > 0 && ( {module.id === 'alert' && alertCount > 0 && (
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded"> <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 && ( {expanded === module.id && (
<div className="mt-1 pl-7"> <div className="mt-1 pl-7">
{module.items.map((item) => ( {module.items.map((item) => (
<button <NavLink
key={item.id} key={item.to}
onClick={() => handleItemClick(module.id, item.id)} to={item.to}
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${ data-testid={item.testid}
activePage === item.id onClick={onNavigate}
? 'bg-bg-active text-primary' className={({ isActive }) =>
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary' `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} {item.label}
</button> </NavLink>
))} ))}
</div> </div>
)} )}
</div> </div>
))} ))}
</aside> </nav>
); );
} }

View File

@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { TESTIDS } from '@/utils/testids';
interface TopNavProps { interface TopNavProps {
onLogout?: () => void; onLogout?: () => void;
// 移动端汉堡按钮:切换侧栏抽屉。
onToggleMenu?: () => void;
} }
function Clock() { function Clock() {
@@ -13,14 +16,27 @@ function Clock() {
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>; 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 ( 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="flex items-center gap-3">
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center"> <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"> <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> </svg>
</div> </div>
<span className="font-display font-semibold text-[15px] text-text-primary"> <span className="font-display font-semibold text-[15px] text-text-primary">
@@ -28,19 +44,19 @@ export function TopNav({ onLogout }: TopNavProps) {
</span> </span>
</div> </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> </span>
<div className="ml-auto flex items-center gap-5"> <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 /> <Clock />
</span> </span>
<div className="flex items-center gap-2 text-[13px] text-text-secondary"> <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"> <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> </svg>
admin admin
</div> </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';

View File

@@ -1,4 +1,5 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { LoadingState } from '@/components/ui';
import React from 'react'; import React from 'react';
import { useRiskStore } from '@/stores'; import { useRiskStore } from '@/stores';
import { AlertMap } from '@/components/AlertMap'; import { AlertMap } from '@/components/AlertMap';
@@ -241,7 +242,7 @@ export function AlertsDashboard() {
}, [filteredAlerts]); }, [filteredAlerts]);
return ( return (
<div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}> <div data-testid="page-alerts" className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
{error && ( {error && (
<ErrorBanner <ErrorBanner
error={error} error={error}
@@ -511,8 +512,8 @@ export function AlertsDashboard() {
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="card p-8 text-center"> <div className="card p-8">
<div className="text-text-secondary text-[13px]">...</div> <LoadingState />
</div> </div>
) : filteredAlerts.length === 0 ? ( ) : filteredAlerts.length === 0 ? (
<div className="card p-8 text-center"> <div className="card p-8 text-center">
@@ -570,7 +571,7 @@ export function AlertsDashboard() {
{/* Risk trend chart (real data from /api/analysis/trend) */} {/* Risk trend chart (real data from /api/analysis/trend) */}
{trendLoading ? ( {trendLoading ? (
<div className="card p-8 text-center text-text-secondary text-[13px]">...</div> <div className="card p-8"><LoadingState /></div>
) : trendError ? ( ) : trendError ? (
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div> <div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? ( ) : trendData.length === 0 ? (

View File

@@ -14,6 +14,8 @@ import {
import { Users, Activity } from 'lucide-react'; import { Users, Activity } from 'lucide-react';
import { caseApi } from '@/services/api'; import { caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner'; import { ErrorBanner } from '@/components/ErrorBanner';
import { LoadingState } from '@/components/ui';
import { TESTIDS } from '@/utils/testids';
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types'; import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
// --- Chart 3 helpers --- // --- Chart 3 helpers ---
@@ -108,9 +110,11 @@ export function DemographicAnalysis() {
}, []); }, []);
// --- Derived data --- // --- Derived data ---
// 防御性归一化:后端返回意外形状(缺字段/类型不符)时降级为空,避免 .map 抛错
// 冒泡到根 ErrorBoundary 把整页白屏(与 DiseaseAnalysis/EnvironmentalHealth 的处理一致)。
const ageData: AgeBin[] = useMemo(() => { const ageData: AgeBin[] = useMemo(() => {
if (!data) return []; const bins = Array.isArray(data?.age_distribution) ? data!.age_distribution : [];
return data.age_distribution.map((d) => ({ return bins.map((d) => ({
age_bin: d.age_bin, age_bin: d.age_bin,
outpatient: d.outpatient, outpatient: d.outpatient,
inpatient: d.inpatient, inpatient: d.inpatient,
@@ -119,8 +123,8 @@ export function DemographicAnalysis() {
const genderData = useMemo(() => { const genderData = useMemo(() => {
if (!data) return []; if (!data) return [];
const male = data.gender_split.male.inpatient; const male = data.gender_split?.male?.inpatient ?? 0;
const female = data.gender_split.female.inpatient; const female = data.gender_split?.female?.inpatient ?? 0;
return [ return [
{ name: '男性', value: male, color: '#3B82F6' }, { name: '男性', value: male, color: '#3B82F6' },
{ name: '女性', value: female, color: '#EC4899' }, { name: '女性', value: female, color: '#EC4899' },
@@ -132,8 +136,8 @@ export function DemographicAnalysis() {
}, [genderData]); }, [genderData]);
const heatmapData = useMemo(() => { const heatmapData = useMemo(() => {
if (!data) return { diagnoses: [], matrix: [], totals: [] }; const matrix = Array.isArray(data?.age_diagnosis_matrix) ? data!.age_diagnosis_matrix : [];
return buildHeatmapMatrix(data.age_diagnosis_matrix); return buildHeatmapMatrix(matrix);
}, [data]); }, [data]);
const heatmapMax = useMemo(() => { const heatmapMax = useMemo(() => {
@@ -148,11 +152,7 @@ export function DemographicAnalysis() {
// --- Loading state --- // --- Loading state ---
if (isLoading) { if (isLoading) {
return ( return <LoadingState testid={TESTIDS.pageLoading} />;
<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>
);
} }
const isEmpty = const isEmpty =
@@ -162,7 +162,7 @@ export function DemographicAnalysis() {
heatmapData.matrix.length === 0); heatmapData.matrix.length === 0);
return ( return (
<div className="flex flex-col h-full overflow-auto"> <div data-testid="page-demographics" className="flex flex-col h-full overflow-auto">
{error && ( {error && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">
<ErrorBanner <ErrorBanner

View File

@@ -442,7 +442,7 @@ export function DiseaseAnalysis() {
const avgOIRatio = totalIn > 0 ? totalOut / totalIn : 0; const avgOIRatio = totalIn > 0 ? totalOut / totalIn : 0;
return ( return (
<div className="flex flex-col h-full overflow-auto"> <div data-testid="page-disease" className="flex flex-col h-full overflow-auto">
{errors.length > 0 && ( {errors.length > 0 && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">
<ErrorBanner <ErrorBanner

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { LoadingState } from '@/components/ui';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -127,7 +128,7 @@ export function DistrictComparison() {
}; };
return ( return (
<div> <div data-testid="page-district">
{error && ( {error && (
<ErrorBanner <ErrorBanner
error={error} error={error}
@@ -184,8 +185,8 @@ export function DistrictComparison() {
</div> </div>
{isLoading && ( {isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border"> <div className="mb-4 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span> <LoadingState />
</div> </div>
)} )}
@@ -385,7 +386,7 @@ export function DistrictComparison() {
/ (O/I Ratio) / (O/I Ratio)
</div> </div>
{caseDataLoading && ( {caseDataLoading && (
<div className="text-center py-8 text-text-secondary">...</div> <LoadingState />
)} )}
{!caseDataLoading && oiRatioData.data.length > 0 ? ( {!caseDataLoading && oiRatioData.data.length > 0 ? (
<ResponsiveContainer width="100%" height={350}> <ResponsiveContainer width="100%" height={350}>

View File

@@ -276,7 +276,7 @@ export function EnvironmentalHealth() {
} }
return ( return (
<div className="flex flex-col h-full overflow-auto"> <div data-testid="page-environment" className="flex flex-col h-full overflow-auto">
{/* Error banner */} {/* Error banner */}
{errors.length > 0 && ( {errors.length > 0 && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { LoadingState } from '@/components/ui';
import { useAnalysisStore } from '@/stores/analysisStore'; import { useAnalysisStore } from '@/stores/analysisStore';
import { ErrorBanner } from '@/components/ErrorBanner'; import { ErrorBanner } from '@/components/ErrorBanner';
import { ChatBot } from '@/components/ChatBot'; import { ChatBot } from '@/components/ChatBot';
@@ -186,7 +187,7 @@ export function Insights() {
: []; : [];
return ( return (
<div> <div data-testid="page-insights">
{error && ( {error && (
<ErrorBanner <ErrorBanner
error={error} error={error}
@@ -205,8 +206,8 @@ export function Insights() {
</div> </div>
{isLoading && ( {isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border"> <div className="mb-4 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span> <LoadingState />
</div> </div>
)} )}
@@ -307,8 +308,8 @@ export function Insights() {
</p> </p>
{anomalyLoading && ( {anomalyLoading && (
<div className="card p-8 text-center"> <div className="card p-8">
<span className="text-text-secondary">...</span> <LoadingState />
</div> </div>
)} )}

View File

@@ -1,11 +1,14 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '@/services/api'; import api from '@/services/api';
import { TESTIDS } from '@/utils/testids';
interface LoginProps { interface LoginProps {
onLogin: (token: string) => void; onLogin: (token: string) => void;
} }
export function Login({ onLogin }: LoginProps) { export function Login({ onLogin }: LoginProps) {
const navigate = useNavigate();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -20,6 +23,7 @@ export function Login({ onLogin }: LoginProps) {
const token = res.data.access_token; const token = res.data.access_token;
localStorage.setItem('cbpoa_token', token); localStorage.setItem('cbpoa_token', token);
onLogin(token); onLogin(token);
navigate('/monitoring');
} catch { } catch {
setError('用户名或密码错误'); setError('用户名或密码错误');
} finally { } finally {
@@ -28,7 +32,7 @@ export function Login({ onLogin }: LoginProps) {
}; };
return ( return (
<div className="min-h-screen bg-bg-page flex items-center justify-center"> <div data-testid={TESTIDS.pageLogin} className="min-h-screen bg-bg-page flex items-center justify-center">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm" className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
@@ -68,6 +72,7 @@ export function Login({ onLogin }: LoginProps) {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
data-testid={TESTIDS.loginSubmit}
className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50" className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
> >
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}

View File

@@ -342,7 +342,7 @@ export function MonitoringDashboard({
}, [setPlaying]); }, [setPlaying]);
return ( return (
<div className="flex flex-col h-full"> <div data-testid="page-monitoring" className="flex flex-col h-full">
{error && ( {error && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">
<ErrorBanner <ErrorBanner

View File

@@ -239,7 +239,7 @@ export function OverviewDashboard() {
} }
return ( return (
<div className="flex flex-col h-full overflow-auto"> <div data-testid="page-overview" className="flex flex-col h-full overflow-auto">
{/* Error banner */} {/* Error banner */}
{errors.length > 0 && ( {errors.length > 0 && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { LoadingState } from '@/components/ui';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { FileText, Download, Activity, TrendingUp, TrendingDown, AlertTriangle, ChevronLeft, RefreshCw } from 'lucide-react'; import { FileText, Download, Activity, TrendingUp, TrendingDown, AlertTriangle, ChevronLeft, RefreshCw } from 'lucide-react';
import { useReportsStore } from '@/stores/reportsStore'; import { useReportsStore } from '@/stores/reportsStore';
@@ -94,7 +95,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-sm text-gray-500">...</div> <LoadingState />
) : reports.length === 0 ? ( ) : reports.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" /> <FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
@@ -329,7 +330,7 @@ export function ReportsCenter() {
}, [fetchReportsList]); }, [fetchReportsList]);
return ( return (
<div> <div data-testid="page-reports">
{error && view === 'list' && ( {error && view === 'list' && (
<ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(); }} onDismiss={clearError} /> <ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(); }} onDismiss={clearError} />
)} )}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { LoadingState } from '@/components/ui';
import { import {
LineChart, LineChart,
Line, Line,
@@ -148,7 +149,7 @@ export function TrendAnalysis() {
}; };
return ( return (
<div> <div data-testid="page-trend">
{error && ( {error && (
<ErrorBanner <ErrorBanner
error={error} error={error}
@@ -211,8 +212,8 @@ export function TrendAnalysis() {
</div> </div>
{isLoading && ( {isLoading && (
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border"> <div className="mb-4 bg-bg-card rounded-lg border border-border">
<span className="text-text-secondary">...</span> <LoadingState />
</div> </div>
)} )}
@@ -350,7 +351,7 @@ export function TrendAnalysis() {
</div> </div>
{multiYearLoading ? ( {multiYearLoading ? (
<div className="text-center py-8 text-text-secondary text-sm">...</div> <LoadingState />
) : Object.keys(multiYearData).length > 0 ? ( ) : Object.keys(multiYearData).length > 0 ? (
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<LineChart <LineChart

60
frontend/src/routes.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { lazy, Suspense, ComponentType } from 'react';
import { Navigate, RouteObject } from 'react-router-dom';
import { LoadingState } from '@/components/ui/LoadingState';
import { TESTIDS } from '@/utils/testids';
// 按页面懒加载,路由层负责代码分割(原先位于 App.tsx
const MonitoringDashboard = lazy(() =>
import('@/pages/MonitoringDashboard').then((m) => ({ default: m.MonitoringDashboard }))
);
const AlertsDashboard = lazy(() =>
import('@/pages/AlertsDashboard').then((m) => ({ default: m.AlertsDashboard }))
);
const OverviewDashboard = lazy(() =>
import('@/pages/OverviewDashboard').then((m) => ({ default: m.OverviewDashboard }))
);
const TrendAnalysis = lazy(() =>
import('@/pages/TrendAnalysis').then((m) => ({ default: m.TrendAnalysis }))
);
const DistrictComparison = lazy(() =>
import('@/pages/DistrictComparison').then((m) => ({ default: m.DistrictComparison }))
);
const Insights = lazy(() => import('@/pages/Insights').then((m) => ({ default: m.Insights })));
const ReportsCenter = lazy(() =>
import('@/pages/ReportsCenter').then((m) => ({ default: m.ReportsCenter }))
);
const DemographicAnalysis = lazy(() =>
import('@/pages/DemographicAnalysis').then((m) => ({ default: m.DemographicAnalysis }))
);
const DiseaseAnalysis = lazy(() =>
import('@/pages/DiseaseAnalysis').then((m) => ({ default: m.DiseaseAnalysis }))
);
const EnvironmentalHealth = lazy(() =>
import('@/pages/EnvironmentalHealth').then((m) => ({ default: m.EnvironmentalHealth }))
);
// 用 Suspense 包裹懒加载页面,统一加载态。
function lazyElement(Page: ComponentType): JSX.Element {
return (
<Suspense fallback={<LoadingState testid={TESTIDS.pageLoading} />}>
<Page />
</Suspense>
);
}
// AppShell 的子路由表(在 App.tsx 中作为 <Outlet/> 的内容渲染)。
export const appRoutes: RouteObject[] = [
{ index: true, element: <Navigate to="/monitoring" replace /> },
{ path: 'overview', element: lazyElement(OverviewDashboard) },
{ path: 'monitoring', element: lazyElement(MonitoringDashboard) },
{ path: 'alerts', element: lazyElement(AlertsDashboard) },
{ path: 'analysis/trend', element: lazyElement(TrendAnalysis) },
{ path: 'analysis/district', element: lazyElement(DistrictComparison) },
{ path: 'analysis/insights', element: lazyElement(Insights) },
{ path: 'analysis/reports', element: lazyElement(ReportsCenter) },
{ path: 'analysis/demographics', element: lazyElement(DemographicAnalysis) },
{ path: 'analysis/disease', element: lazyElement(DiseaseAnalysis) },
{ path: 'analysis/environment', element: lazyElement(EnvironmentalHealth) },
// 未知路径回退到监测面板。
{ path: '*', element: <Navigate to="/monitoring" replace /> },
];

View File

@@ -0,0 +1,39 @@
// 集中管理所有 data-testid 字符串,供应用与 e2e 测试共享引用。
export const TESTIDS = {
// 布局骨架
appShell: 'app-shell',
hamburger: 'hamburger',
appDrawer: 'app-drawer',
sidebarRail: 'sidebar-rail',
pageLoading: 'page-loading',
// 登录
pageLogin: 'page-login',
loginSubmit: 'login-submit',
// 导航项
navMonitoring: 'nav-monitoring',
navAlerts: 'nav-alerts',
navOverview: 'nav-overview',
navTrend: 'nav-trend',
navDistrict: 'nav-district',
navInsights: 'nav-insights',
navReports: 'nav-reports',
navDemographics: 'nav-demographics',
navDisease: 'nav-disease',
navEnvironment: 'nav-environment',
// 页面挂载点
pageMonitoring: 'page-monitoring',
pageAlerts: 'page-alerts',
pageOverview: 'page-overview',
pageTrend: 'page-trend',
pageDistrict: 'page-district',
pageInsights: 'page-insights',
pageReports: 'page-reports',
pageDemographics: 'page-demographics',
pageDisease: 'page-disease',
pageEnvironment: 'page-environment',
} as const;
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];