From 3db3b12480ec6c59418c5b83d8a19072db3ab555 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Sun, 21 Jun 2026 20:12:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=201=20UX=20foundation?= =?UTF-8?q?=20=E2=80=94=20react-router=20v6=20+=20responsive=20AppShell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ( --- .gitignore | 2 + frontend/e2e/user-flows.spec.ts | 465 +++++++++++--------- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 34 ++ frontend/src/App.tsx | 82 +--- frontend/src/components/AppShell.tsx | 56 +++ frontend/src/components/CaseMap.tsx | 3 +- frontend/src/components/SideNav.tsx | 94 ++-- frontend/src/components/TopNav.tsx | 32 +- frontend/src/components/ui/Card.tsx | 41 ++ frontend/src/components/ui/EmptyState.tsx | 29 ++ frontend/src/components/ui/LoadingState.tsx | 30 ++ frontend/src/components/ui/Panel.tsx | 22 + frontend/src/components/ui/Segmented.tsx | 53 +++ frontend/src/components/ui/Skeleton.tsx | 22 + frontend/src/components/ui/index.ts | 6 + frontend/src/pages/AlertsDashboard.tsx | 9 +- frontend/src/pages/DemographicAnalysis.tsx | 24 +- frontend/src/pages/DiseaseAnalysis.tsx | 2 +- frontend/src/pages/DistrictComparison.tsx | 9 +- frontend/src/pages/EnvironmentalHealth.tsx | 2 +- frontend/src/pages/Insights.tsx | 11 +- frontend/src/pages/Login.tsx | 7 +- frontend/src/pages/MonitoringDashboard.tsx | 2 +- frontend/src/pages/OverviewDashboard.tsx | 2 +- frontend/src/pages/ReportsCenter.tsx | 5 +- frontend/src/pages/TrendAnalysis.tsx | 9 +- frontend/src/routes.tsx | 60 +++ frontend/src/utils/testids.ts | 39 ++ 29 files changed, 796 insertions(+), 357 deletions(-) create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/ui/Card.tsx create mode 100644 frontend/src/components/ui/EmptyState.tsx create mode 100644 frontend/src/components/ui/LoadingState.tsx create mode 100644 frontend/src/components/ui/Panel.tsx create mode 100644 frontend/src/components/ui/Segmented.tsx create mode 100644 frontend/src/components/ui/Skeleton.tsx create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/routes.tsx create mode 100644 frontend/src/utils/testids.ts diff --git a/.gitignore b/.gitignore index 110a771..4520878 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ cache/ logs/ mlruns/ .playwright-mcp/ +frontend/playwright-report/ +frontend/test-results/ *Zone.Identifier # Transcription processing intermediates Outputs/transcript/chunks/ diff --git a/frontend/e2e/user-flows.spec.ts b/frontend/e2e/user-flows.spec.ts index 013cf50..da6b478 100644 --- a/frontend/e2e/user-flows.spec.ts +++ b/frontend/e2e/user-flows.spec.ts @@ -1,242 +1,291 @@ /** - * US-007 + US-008: E2E user flow and UI state tests. - * Simulates real user workflows through the CBPOA system. + * Phase-1 acceptance tests: URL-based navigation, responsive layout, and core user flows. + * 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)', () => { - test('显示登录页面', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(1000); - // Should see login form or app (if cached token) - const isLogin = await page.locator('input').count(); - const isApp = await page.locator('nav').count(); - expect(isLogin > 0 || isApp > 0).toBeTruthy(); +/** Seed auth token and mock all /api/** calls before each page load. */ +async function seedAuthAndMockApi(page: Page) { + // Prevent login gate from appearing. + await page.addInitScript(() => { + localStorage.setItem('cbpoa_token', 'e2e-test-token'); }); - test('登录表单可交互', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(1000); + // Mock backend responses so the suite is hermetic — no live :8000 required. + // Each response must match the TypeScript interface shape; returning {} causes + // pages to throw when accessing expected array properties. + await page.route('/api/**', (route) => { + const url = route.request().url(); - const inputs = page.locator('input'); - const count = await inputs.count(); - - if (count >= 2) { - // Login page is shown - await inputs.first().fill('admin'); - await inputs.nth(1).fill('admin123'); - - 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 (url.includes('/alerts')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ alerts: [], total: 0 }), + }); + return; } - // If no inputs, user is already logged in (token in localStorage) - }); -}); -test.describe('监测面板 (Monitoring Dashboard)', () => { - test('面板加载并显示统计卡片', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(3000); - - // Should show monitoring page by default - const statCards = page.locator('[class*="stat"], [class*="card"], [class*="Stat"]'); - 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); + if (url.includes('/history/aggregated')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ aggregations: [], total_records: 0 }), + }); + return; } - }); - test('疾病筛选器可用', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(3000); - - const selects = page.locator('select, [role="combobox"], [class*="select" i], [class*="filter" i]'); - const count = await selects.count(); - expect(count >= 0).toBeTruthy(); - }); -}); - -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); + if (url.includes('/grids')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ type: 'FeatureCollection', features: [] }), + }); + return; } - }); - test('预警列表加载', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), span:has-text("预警")'); - if (await alertsLink.count() > 0) { - await alertsLink.first().click(); - await page.waitForTimeout(3000); - - const bodyText = await page.textContent('body'); - expect(bodyText).toBeTruthy(); + // DemographicsResponse — used by DemographicAnalysis page. + if (url.includes('/cases/demographics')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + age_distribution: [], + gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } }, + age_diagnosis_matrix: [], + }), + }); + return; } - }); -}); -test.describe('趋势分析 (Trend Analysis)', () => { - test('导航到趋势分析页面', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势"), a[href*="trend" i]'); - if (await trendLink.count() > 0) { - await trendLink.first().click(); - await page.waitForTimeout(2000); + // DiseaseAnalysis calls: diagnosis-distribution, seasonality, districts. + if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + return; } - }); - test('趋势图渲染', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势")'); - if (await trendLink.count() > 0) { - await trendLink.first().click(); - await page.waitForTimeout(3000); - - // Recharts renders SVG charts - const svgCharts = page.locator('svg.recharts-surface'); - const chartCount = await svgCharts.count(); - expect(chartCount >= 0).toBeTruthy(); + if (url.includes('/cases/districts') || url.includes('/districts')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + return; } - }); -}); -test.describe('区县对比 (District Comparison)', () => { - test('导航到区县对比页面', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - const districtLink = page.locator('button:has-text("区县"), button:has-text("对比"), span:has-text("区县")'); - if (await districtLink.count() > 0) { - await districtLink.first().click(); - await page.waitForTimeout(2000); + // Trend / time-series endpoints. + if (url.includes('/cases/trend') || url.includes('/cases')) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0 }), + }); + return; } - }); -}); -test.describe('报告中心 (Reports Center)', () => { - test('导航到报告中心', async ({ page }) => { - await page.goto(BASE_URL); - await page.waitForTimeout(2000); - - 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); + // Default fallback — return a safe empty object. + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], items: [], total: 0 }), }); + }); +} - await page.goto(BASE_URL); - await page.waitForTimeout(3000); +// --------------------------------------------------------------------------- +// URL-based navigation (react-router v6) +// --------------------------------------------------------------------------- - const filtered = errors.filter( - (e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap') +test.describe('URL-based navigation', () => { + 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('移动端视口下不崩溃', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 812 }); - await page.goto(BASE_URL); - await page.waitForTimeout(2000); +test.describe('Responsive layout — desktop @1280px', () => { + test.use({ viewport: { width: 1280, height: 800 } }); - const bodyText = await page.textContent('body'); - expect(bodyText).toBeTruthy(); + test.beforeEach(async ({ page }) => { + await seedAuthAndMockApi(page); }); - test('平板视口下正常显示', async ({ page }) => { - await page.setViewportSize({ width: 768, height: 1024 }); - await page.goto(BASE_URL); - await page.waitForTimeout(2000); + test('sidebar-rail is visible and hamburger is hidden at 1280px', async ({ page }) => { + await page.goto('/monitoring'); + await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible(); - const bodyText = await page.textContent('body'); - expect(bodyText).toBeTruthy(); + await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).toBeVisible(); + 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(); }); }); diff --git a/frontend/package.json b/frontend/package.json index 3b3c401..a8194e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-leaflet": "^4.2.1", + "react-router-dom": "^6.30.4", "recharts": "^2.12.0", "zustand": "^4.5.0" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d52ecb1..065fa83 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: react-leaflet: 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) + 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: specifier: ^2.12.0 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-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': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1504,6 +1511,19 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} 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: resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} peerDependencies: @@ -2157,6 +2177,8 @@ snapshots: 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': {} '@rollup/rollup-android-arm-eabi@4.60.2': @@ -3233,6 +3255,18 @@ snapshots: 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): dependencies: fast-equals: 5.4.0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2209371..62c0bfe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,19 +1,9 @@ -import { useEffect, useState, Component, ReactNode, Suspense, lazy, useCallback } from 'react'; -import { TopNav } from '@/components/TopNav'; -import { SideNav } from '@/components/SideNav'; +import { useEffect, useState, Component, ReactNode, useCallback } from 'react'; +import { BrowserRouter, Routes, Route, useRoutes } from 'react-router-dom'; +import { AppShell } from '@/components/AppShell'; import { useRiskStore } from '@/stores'; import { Login } from '@/pages/Login'; - -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 }))); - +import { appRoutes } from '@/routes'; interface Props { children: ReactNode; @@ -55,28 +45,25 @@ class ErrorBoundary extends Component { } } -function PageLoader() { - return ( -
-
加载中...
-
- ); +// 已登录:AppShell 提供布局骨架,子路由表渲染到其 。 +function AuthedApp({ onLogout }: { onLogout: () => void }) { + const element = useRoutes([ + { + element: , + children: appRoutes, + }, + ]); + return element; } function App() { - const [activePage, setActivePage] = useState('monitoring'); const [token, setToken] = useState(() => localStorage.getItem('cbpoa_token')); - const alerts = useRiskStore((s) => s.alerts); const fetchAlerts = useRiskStore((s) => s.fetchAlerts); useEffect(() => { if (token) fetchAlerts(); }, [fetchAlerts, token]); - const handlePageChange = useCallback((page: string) => { - setActivePage(page); - }, []); - const handleLogin = useCallback((newToken: string) => { setToken(newToken); }, []); @@ -86,41 +73,18 @@ function App() { setToken(null); }, []); - if (!token) { - return ( - - - - ); - } - return ( -
- - -
- - -
- }> - {activePage === 'monitoring' && } - {activePage === 'alerts' && } - {activePage === 'trend-analysis' && } - {activePage === 'district-comparison' && } - {activePage === 'insights' && } - {activePage === 'reports' && } - {activePage === 'demographics' && } - {activePage === 'disease' && } - {activePage === 'environment' && } - -
-
-
+ + {token ? ( + + ) : ( + // 鉴权门:无 token 时所有路由都进入登录页。 + + } /> + + )} +
); } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..9a766ee --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -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 ( +
+ + +
+ {/* lg 及以上:持久侧栏导轨 */} + + + {/* lg 以下:离屏抽屉 + 遮罩 */} + {drawerOpen && ( + +
+ ); +} diff --git a/frontend/src/components/CaseMap.tsx b/frontend/src/components/CaseMap.tsx index 6c5c430..2bb0e39 100644 --- a/frontend/src/components/CaseMap.tsx +++ b/frontend/src/components/CaseMap.tsx @@ -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) {
{isLoading ? ( - 数据加载中... + ) : error ? ( 加载失败: {error} ) : ( diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx index e071c23..742098c 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -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: ( - + ), - items: [ - { id: 'monitoring', label: '监测面板' }, - ], + items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }], }, { id: 'alert', label: '预警', icon: ( - + ), - items: [ - { id: 'alerts', label: '预警地图' }, - ], + items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }], }, { id: 'analysis', label: '分析', icon: ( - + ), 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('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(() => 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 ( -
))} - + ); } diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index d661a2d..92da3cc 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -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 {time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}; } -export function TopNav({ onLogout }: TopNavProps) { - +export function TopNav({ onLogout, onToggleMenu }: TopNavProps) { return ( -