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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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 (url.includes('/history/aggregated')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// If no inputs, user is already logged in (token in localStorage)
|
|
||||||
|
if (url.includes('/grids')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||||
});
|
});
|
||||||
});
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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('疾病筛选器可用', 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 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();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('趋势分析 (Trend Analysis)', () => {
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
test('导航到趋势分析页面', async ({ page }) => {
|
route.fulfill({
|
||||||
await page.goto(BASE_URL);
|
status: 200,
|
||||||
await page.waitForTimeout(2000);
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify([]),
|
||||||
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势"), a[href*="trend" i]');
|
});
|
||||||
if (await trendLink.count() > 0) {
|
return;
|
||||||
await trendLink.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('趋势图渲染', 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default fallback — return a safe empty object.
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// URL-based navigation (react-router v6)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('报告中心 (Reports Center)', () => {
|
// ---------------------------------------------------------------------------
|
||||||
test('导航到报告中心', async ({ page }) => {
|
// Responsive layout
|
||||||
await page.goto(BASE_URL);
|
// ---------------------------------------------------------------------------
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告"), a[href*="report" i]');
|
test.describe('Responsive layout — mobile @375px', () => {
|
||||||
if (await reportsLink.count() > 0) {
|
test.use({ viewport: { width: 375, height: 812 } });
|
||||||
await reportsLink.first().click();
|
|
||||||
await page.waitForTimeout(2000);
|
test.beforeEach(async ({ page }) => {
|
||||||
}
|
await seedAuthAndMockApi(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('报告列表加载', async ({ page }) => {
|
test('hamburger is visible and sidebar-rail is hidden at 375px', async ({ page }) => {
|
||||||
await page.goto(BASE_URL);
|
await page.goto('/monitoring');
|
||||||
await page.waitForTimeout(2000);
|
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||||
|
|
||||||
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告")');
|
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).toBeVisible();
|
||||||
if (await reportsLink.count() > 0) {
|
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).not.toBeVisible();
|
||||||
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('tapping hamburger slides app-drawer into viewport', async ({ page }) => {
|
||||||
test('页面加载显示加载指示器而非白屏', async ({ page }) => {
|
await page.goto('/monitoring');
|
||||||
await page.goto(BASE_URL);
|
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const bodyHTML = await page.innerHTML('body');
|
// Drawer should be off-screen (translate-x-full) before toggle.
|
||||||
// Should have some content, even during loading
|
const drawer = page.locator(`[data-testid="${TESTIDS.appDrawer}"]`);
|
||||||
expect(bodyHTML.length).toBeGreaterThan(0);
|
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('侧边栏导航切换页面正常', async ({ page }) => {
|
test('no horizontal scroll on default route at 375px', async ({ page }) => {
|
||||||
await page.goto(BASE_URL);
|
await page.goto('/monitoring');
|
||||||
await page.waitForTimeout(2000);
|
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||||
|
|
||||||
const navLinks = page.locator('nav a, nav button, [class*="side" i] a, [class*="side" i] button');
|
const noHorizontalScroll = await page.evaluate(
|
||||||
const count = await navLinks.count();
|
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const filtered = errors.filter(
|
|
||||||
(e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap')
|
|
||||||
);
|
);
|
||||||
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
34
frontend/pnpm-lock.yaml
generated
34
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Login onLogin={handleLogin} />
|
<BrowserRouter>
|
||||||
</ErrorBoundary>
|
{token ? (
|
||||||
);
|
<AuthedApp onLogout={handleLogout} />
|
||||||
}
|
) : (
|
||||||
|
// 鉴权门:无 token 时所有路由都进入登录页。
|
||||||
return (
|
<Routes>
|
||||||
<ErrorBoundary>
|
<Route path="*" element={<Login onLogin={handleLogin} />} />
|
||||||
<div className="min-h-screen bg-bg-page flex flex-col">
|
</Routes>
|
||||||
<TopNav onLogout={handleLogout} />
|
)}
|
||||||
|
</BrowserRouter>
|
||||||
<div className="flex flex-1 pt-[52px]">
|
|
||||||
<SideNav
|
|
||||||
activePage={activePage}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
alertCount={alerts.length}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
56
frontend/src/components/AppShell.tsx
Normal file
56
frontend/src/components/AppShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
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: '监测',
|
||||||
@@ -15,9 +23,7 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: { id:
|
|||||||
<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',
|
||||||
@@ -27,9 +33,7 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: { id:
|
|||||||
<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',
|
||||||
@@ -40,37 +44,35 @@ const modules: { id: string; label: string; icon: React.ReactNode; items: { id:
|
|||||||
</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}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
? 'bg-bg-active text-primary'
|
? 'bg-bg-active text-primary'
|
||||||
: 'text-text-secondary hover:bg-bg-hover hover:text-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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +16,23 @@ 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">
|
||||||
@@ -28,14 +44,14 @@ 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">
|
||||||
|
|||||||
41
frontend/src/components/ui/Card.tsx
Normal file
41
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
export const Card = memo(function Card({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
testid,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
testid?: string;
|
||||||
|
}): JSX.Element {
|
||||||
|
const hasHeader = title != null || actions != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={testid}
|
||||||
|
className={[
|
||||||
|
'bg-bg-card rounded-lg border border-border',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{hasHeader && (
|
||||||
|
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border-light">
|
||||||
|
{title != null && (
|
||||||
|
<div className="text-sm font-medium text-text-primary">{title}</div>
|
||||||
|
)}
|
||||||
|
{actions != null && (
|
||||||
|
<div className="flex items-center gap-2 shrink-0">{actions}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
29
frontend/src/components/ui/EmptyState.tsx
Normal file
29
frontend/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
export const EmptyState = memo(function EmptyState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="empty-state"
|
||||||
|
className="flex flex-col items-center justify-center gap-3 py-12 px-6 text-center"
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<div className="text-text-muted text-4xl">{icon}</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium text-text-secondary">{title}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-text-muted max-w-xs">{description}</p>
|
||||||
|
)}
|
||||||
|
{action && <div className="mt-2">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
30
frontend/src/components/ui/LoadingState.tsx
Normal file
30
frontend/src/components/ui/LoadingState.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
|
||||||
|
export function LoadingState({
|
||||||
|
label,
|
||||||
|
testid,
|
||||||
|
lines = 3,
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
testid?: string;
|
||||||
|
lines?: number;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={testid ?? 'loading-state'}
|
||||||
|
className="flex flex-col items-center justify-center gap-3 p-6 w-full"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 w-full max-w-sm">
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<p className="text-xs text-text-muted">{label}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/components/ui/Panel.tsx
Normal file
22
frontend/src/components/ui/Panel.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
|
export const Panel = memo(function Panel({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
testid,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
testid?: string;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={testid}
|
||||||
|
className={['bg-bg-hover/50 rounded-md p-3', className]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
53
frontend/src/components/ui/Segmented.tsx
Normal file
53
frontend/src/components/ui/Segmented.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
export const Segmented = memo(function Segmented<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
size = 'md',
|
||||||
|
testid,
|
||||||
|
}: {
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
value: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
testid?: string;
|
||||||
|
}): JSX.Element {
|
||||||
|
const sizeClasses = size === 'sm'
|
||||||
|
? 'px-2.5 py-0.5 text-xs'
|
||||||
|
: 'px-3.5 py-1 text-[13px]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={testid}
|
||||||
|
className="inline-flex items-center gap-0.5 rounded-full bg-bg-hover p-0.5"
|
||||||
|
>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const isActive = opt.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
data-testid={testid ? `${testid}-${opt.value}` : undefined}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={[
|
||||||
|
'rounded-full font-medium transition-colors',
|
||||||
|
sizeClasses,
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-white shadow-sm'
|
||||||
|
: 'text-text-secondary hover:bg-bg-active',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) as <T extends string>(props: {
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
value: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
testid?: string;
|
||||||
|
}) => JSX.Element;
|
||||||
22
frontend/src/components/ui/Skeleton.tsx
Normal file
22
frontend/src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
export const Skeleton = memo(function Skeleton({
|
||||||
|
className,
|
||||||
|
rounded,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
rounded?: boolean;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="skeleton"
|
||||||
|
className={[
|
||||||
|
'animate-pulse bg-bg-hover',
|
||||||
|
rounded ? 'rounded-full' : 'rounded',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
6
frontend/src/components/ui/index.ts
Normal file
6
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { Skeleton } from './Skeleton';
|
||||||
|
export { LoadingState } from './LoadingState';
|
||||||
|
export { EmptyState } from './EmptyState';
|
||||||
|
export { Card } from './Card';
|
||||||
|
export { Panel } from './Panel';
|
||||||
|
export { Segmented } from './Segmented';
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ? '登录中...' : '登录'}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
60
frontend/src/routes.tsx
Normal 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 /> },
|
||||||
|
];
|
||||||
39
frontend/src/utils/testids.ts
Normal file
39
frontend/src/utils/testids.ts
Normal 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];
|
||||||
Reference in New Issue
Block a user