feat/ux-modernization #1
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,6 +14,8 @@ cache/
|
||||
logs/
|
||||
mlruns/
|
||||
.playwright-mcp/
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
*Zone.Identifier
|
||||
# Transcription processing intermediates
|
||||
Outputs/transcript/chunks/
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
34
frontend/pnpm-lock.yaml
generated
34
frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="text-text-secondary text-[13px]">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
// 已登录:AppShell 提供布局骨架,子路由表渲染到其 <Outlet/>。
|
||||
function AuthedApp({ onLogout }: { onLogout: () => void }) {
|
||||
const element = useRoutes([
|
||||
{
|
||||
element: <AppShell onLogout={onLogout} />,
|
||||
children: appRoutes,
|
||||
},
|
||||
]);
|
||||
return element;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [activePage, setActivePage] = useState('monitoring');
|
||||
const [token, setToken] = useState<string | null>(() => 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 (
|
||||
<ErrorBoundary>
|
||||
<Login onLogin={handleLogin} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="min-h-screen bg-bg-page flex flex-col">
|
||||
<TopNav onLogout={handleLogout} />
|
||||
|
||||
<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>
|
||||
<BrowserRouter>
|
||||
{token ? (
|
||||
<AuthedApp onLogout={handleLogout} />
|
||||
) : (
|
||||
// 鉴权门:无 token 时所有路由都进入登录页。
|
||||
<Routes>
|
||||
<Route path="*" element={<Login onLogin={handleLogin} />} />
|
||||
</Routes>
|
||||
)}
|
||||
</BrowserRouter>
|
||||
</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 { 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) {
|
||||
<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">
|
||||
{isLoading ? (
|
||||
<span className="text-text-muted">数据加载中...</span>
|
||||
<Skeleton className="h-3 w-20 inline-block align-middle" />
|
||||
) : error ? (
|
||||
<span className="text-danger">加载失败: {error}</span>
|
||||
) : (
|
||||
|
||||
@@ -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: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
|
||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" />
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'monitoring', label: '监测面板' },
|
||||
],
|
||||
items: [{ to: '/monitoring', label: '监测面板', testid: TESTIDS.navMonitoring }],
|
||||
},
|
||||
{
|
||||
id: 'alert',
|
||||
label: '预警',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'alerts', label: '预警地图' },
|
||||
],
|
||||
items: [{ to: '/alerts', label: '预警地图', testid: TESTIDS.navAlerts }],
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: '分析',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z" />
|
||||
</svg>
|
||||
),
|
||||
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<string | null>('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<string | null>(() => 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 (
|
||||
<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) => (
|
||||
<div key={module.id} className="mb-4">
|
||||
<button
|
||||
@@ -81,9 +83,7 @@ export function SideNav({
|
||||
: 'text-text-primary hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 h-4 flex items-center justify-center">
|
||||
{module.icon}
|
||||
</span>
|
||||
<span className="w-4 h-4 flex items-center justify-center">{module.icon}</span>
|
||||
<span>{module.label}</span>
|
||||
{module.id === 'alert' && alertCount > 0 && (
|
||||
<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 && (
|
||||
<div className="mt-1 pl-7">
|
||||
{module.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(module.id, item.id)}
|
||||
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
|
||||
activePage === item.id
|
||||
? 'bg-bg-active text-primary'
|
||||
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
data-testid={item.testid}
|
||||
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'
|
||||
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
|
||||
}
|
||||
|
||||
export function TopNav({ onLogout }: TopNavProps) {
|
||||
|
||||
export function TopNav({ onLogout, onToggleMenu }: TopNavProps) {
|
||||
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="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">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z"/>
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-display font-semibold text-[15px] text-text-primary">
|
||||
@@ -28,19 +44,19 @@ export function TopNav({ onLogout }: TopNavProps) {
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<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 />
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
admin
|
||||
</div>
|
||||
|
||||
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 { LoadingState } from '@/components/ui';
|
||||
import React from 'react';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { AlertMap } from '@/components/AlertMap';
|
||||
@@ -241,7 +242,7 @@ export function AlertsDashboard() {
|
||||
}, [filteredAlerts]);
|
||||
|
||||
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 && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
@@ -511,8 +512,8 @@ export function AlertsDashboard() {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card p-8 text-center">
|
||||
<div className="text-text-secondary text-[13px]">加载中...</div>
|
||||
<div className="card p-8">
|
||||
<LoadingState />
|
||||
</div>
|
||||
) : filteredAlerts.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
@@ -570,7 +571,7 @@ export function AlertsDashboard() {
|
||||
|
||||
{/* Risk trend chart (real data from /api/analysis/trend) */}
|
||||
{trendLoading ? (
|
||||
<div className="card p-8 text-center text-text-secondary text-[13px]">趋势加载中...</div>
|
||||
<div className="card p-8"><LoadingState /></div>
|
||||
) : trendError ? (
|
||||
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
|
||||
) : trendData.length === 0 ? (
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { Users, Activity } from 'lucide-react';
|
||||
import { caseApi } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { LoadingState } from '@/components/ui';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import type { DemographicsResponse, AgeBin, AgeDiagnosisMatrixItem } from '@/types';
|
||||
|
||||
// --- Chart 3 helpers ---
|
||||
@@ -108,9 +110,11 @@ export function DemographicAnalysis() {
|
||||
}, []);
|
||||
|
||||
// --- Derived data ---
|
||||
// 防御性归一化:后端返回意外形状(缺字段/类型不符)时降级为空,避免 .map 抛错
|
||||
// 冒泡到根 ErrorBoundary 把整页白屏(与 DiseaseAnalysis/EnvironmentalHealth 的处理一致)。
|
||||
const ageData: AgeBin[] = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.age_distribution.map((d) => ({
|
||||
const bins = Array.isArray(data?.age_distribution) ? data!.age_distribution : [];
|
||||
return bins.map((d) => ({
|
||||
age_bin: d.age_bin,
|
||||
outpatient: d.outpatient,
|
||||
inpatient: d.inpatient,
|
||||
@@ -119,8 +123,8 @@ export function DemographicAnalysis() {
|
||||
|
||||
const genderData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const male = data.gender_split.male.inpatient;
|
||||
const female = data.gender_split.female.inpatient;
|
||||
const male = data.gender_split?.male?.inpatient ?? 0;
|
||||
const female = data.gender_split?.female?.inpatient ?? 0;
|
||||
return [
|
||||
{ name: '男性', value: male, color: '#3B82F6' },
|
||||
{ name: '女性', value: female, color: '#EC4899' },
|
||||
@@ -132,8 +136,8 @@ export function DemographicAnalysis() {
|
||||
}, [genderData]);
|
||||
|
||||
const heatmapData = useMemo(() => {
|
||||
if (!data) return { diagnoses: [], matrix: [], totals: [] };
|
||||
return buildHeatmapMatrix(data.age_diagnosis_matrix);
|
||||
const matrix = Array.isArray(data?.age_diagnosis_matrix) ? data!.age_diagnosis_matrix : [];
|
||||
return buildHeatmapMatrix(matrix);
|
||||
}, [data]);
|
||||
|
||||
const heatmapMax = useMemo(() => {
|
||||
@@ -148,11 +152,7 @@ export function DemographicAnalysis() {
|
||||
|
||||
// --- Loading state ---
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <LoadingState testid={TESTIDS.pageLoading} />;
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
@@ -162,7 +162,7 @@ export function DemographicAnalysis() {
|
||||
heatmapData.matrix.length === 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
<div data-testid="page-demographics" className="flex flex-col h-full overflow-auto">
|
||||
{error && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
|
||||
@@ -442,7 +442,7 @@ export function DiseaseAnalysis() {
|
||||
const avgOIRatio = totalIn > 0 ? totalOut / totalIn : 0;
|
||||
|
||||
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 && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { LoadingState } from '@/components/ui';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -127,7 +128,7 @@ export function DistrictComparison() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="page-district">
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
@@ -184,8 +185,8 @@ export function DistrictComparison() {
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
||||
<span className="text-text-secondary">数据加载中...</span>
|
||||
<div className="mb-4 bg-bg-card rounded-lg border border-border">
|
||||
<LoadingState />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -385,7 +386,7 @@ export function DistrictComparison() {
|
||||
门诊/住院比 (O/I Ratio) 区域排名
|
||||
</div>
|
||||
{caseDataLoading && (
|
||||
<div className="text-center py-8 text-text-secondary">病例数据加载中...</div>
|
||||
<LoadingState />
|
||||
)}
|
||||
{!caseDataLoading && oiRatioData.data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
|
||||
@@ -276,7 +276,7 @@ export function EnvironmentalHealth() {
|
||||
}
|
||||
|
||||
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 */}
|
||||
{errors.length > 0 && (
|
||||
<div className="px-6 pt-4">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { LoadingState } from '@/components/ui';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { ChatBot } from '@/components/ChatBot';
|
||||
@@ -186,7 +187,7 @@ export function Insights() {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="page-insights">
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
@@ -205,8 +206,8 @@ export function Insights() {
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
||||
<span className="text-text-secondary">数据加载中...</span>
|
||||
<div className="mb-4 bg-bg-card rounded-lg border border-border">
|
||||
<LoadingState />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -307,8 +308,8 @@ export function Insights() {
|
||||
</p>
|
||||
|
||||
{anomalyLoading && (
|
||||
<div className="card p-8 text-center">
|
||||
<span className="text-text-secondary">异常检测数据加载中...</span>
|
||||
<div className="card p-8">
|
||||
<LoadingState />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '@/services/api';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: (token: string) => void;
|
||||
}
|
||||
|
||||
export function Login({ onLogin }: LoginProps) {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -20,6 +23,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
const token = res.data.access_token;
|
||||
localStorage.setItem('cbpoa_token', token);
|
||||
onLogin(token);
|
||||
navigate('/monitoring');
|
||||
} catch {
|
||||
setError('用户名或密码错误');
|
||||
} finally {
|
||||
@@ -28,7 +32,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
};
|
||||
|
||||
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
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
|
||||
@@ -68,6 +72,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
|
||||
@@ -342,7 +342,7 @@ export function MonitoringDashboard({
|
||||
}, [setPlaying]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div data-testid="page-monitoring" className="flex flex-col h-full">
|
||||
{error && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
|
||||
@@ -239,7 +239,7 @@ export function OverviewDashboard() {
|
||||
}
|
||||
|
||||
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 */}
|
||||
{errors.length > 0 && (
|
||||
<div className="px-6 pt-4">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { LoadingState } from '@/components/ui';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { FileText, Download, Activity, TrendingUp, TrendingDown, AlertTriangle, ChevronLeft, RefreshCw } from 'lucide-react';
|
||||
import { useReportsStore } from '@/stores/reportsStore';
|
||||
@@ -94,7 +95,7 @@ function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-sm text-gray-500">加载中...</div>
|
||||
<LoadingState />
|
||||
) : reports.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
@@ -329,7 +330,7 @@ export function ReportsCenter() {
|
||||
}, [fetchReportsList]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="page-reports">
|
||||
{error && view === 'list' && (
|
||||
<ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(); }} onDismiss={clearError} />
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoadingState } from '@/components/ui';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -148,7 +149,7 @@ export function TrendAnalysis() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="page-trend">
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
@@ -211,8 +212,8 @@ export function TrendAnalysis() {
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
||||
<span className="text-text-secondary">数据加载中...</span>
|
||||
<div className="mb-4 bg-bg-card rounded-lg border border-border">
|
||||
<LoadingState />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -350,7 +351,7 @@ export function TrendAnalysis() {
|
||||
多年度病例对比
|
||||
</div>
|
||||
{multiYearLoading ? (
|
||||
<div className="text-center py-8 text-text-secondary text-sm">数据加载中...</div>
|
||||
<LoadingState />
|
||||
) : Object.keys(multiYearData).length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<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