feat: Phase 3 — 视角/perspective presets + URL-driven granularity + doctor privacy
Phase 3 of the UX modernization. Frontend-only role view-presets (D2 — no backend, no JWT role claim, honestly labeled 视角 not 权限). Three conflict-free lanes. Role infrastructure (worker-session): - sessionStore with single swappable getRoleSource() seam (localStorage today, one-line swap to /api/auth/me for future RBAC); Role = official|community|doctor|admin - 视角 switcher in TopNav (replaces hardcoded "admin"); on change persists role + navigates to that perspective's default landing - roleViews.ts: ROLE_LABELS + roleDefaultPath (official→/overview?granularity=district, community→/monitoring?granularity=street, doctor→/alerts?view=cluster, admin→/monitoring) - RoleRedirect index route → current role's default; * fallback unchanged URL-driven granularity (worker-monitoring): - granularity (city|district|street) query param is the source of truth; drilldownStore DERIVES from it via a one-way effect; deep-linkable + reload-safe - reconciled the imperative desync — DistrictBreakdown no longer calls useDrilldownStore.getState(); MonitoringDashboard owns useSearchParams, writes URL - granularity-control Segmented (全市/区域/街道); district-rollup testid Role-aware alerts + privacy (worker-alerts): - doctor/cluster view: individual alert markers hard-locked off (effective flag is single source of truth; toggle not rendered) → aggregated density only; DiseaseFilter mounted; privacy invariant testable via hidden patient-point DOM mirror (count=0) - 官员: 100m grid hidden (grid-layer-wrapper unrendered); admin/community unchanged Gates: tsc 0 · vitest 75 · e2e 30/30 (incl 10 new P3 tests) · build ok Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
143
frontend/e2e/doctor-view.spec.ts
Normal file
143
frontend/e2e/doctor-view.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Phase-3 acceptance tests: role-aware 预警 (alerts) view.
|
||||||
|
*
|
||||||
|
* Two view-preset invariants (D2 — frontend presets, NOT access control):
|
||||||
|
*
|
||||||
|
* 1. PRIVACY INVARIANT (doctor / ?view=cluster): the doctor sees ONLY the aggregated
|
||||||
|
* density raster + the disease filter — ZERO individual patient/case point markers.
|
||||||
|
* The page mirrors every individual marker it would actually render into a hidden
|
||||||
|
* data-testid="patient-point" element (the live Leaflet CircleMarkers are canvas/SVG
|
||||||
|
* objects with no testid and can't be counted directly). In cluster mode the page
|
||||||
|
* forces showAlertMarkers=false, so that mirror set is empty → patient-point count 0.
|
||||||
|
*
|
||||||
|
* 2. 官员 (official) GRID-HIDE: the 100m 网格 is meaningless for leadership, so the grid
|
||||||
|
* toggle wrapper (data-testid="grid-layer-wrapper") is not rendered at all.
|
||||||
|
*
|
||||||
|
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically
|
||||||
|
* (no live :8000 backend). Role is seeded via localStorage['cbpoa_role'].
|
||||||
|
*/
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed auth token (+ optional role) and mock all /api/** calls before page load.
|
||||||
|
* Response shapes copied from user-flows.spec.ts.
|
||||||
|
*/
|
||||||
|
async function seedAuthAndMockApi(page: Page, role?: string) {
|
||||||
|
await page.addInitScript((r) => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
if (r) localStorage.setItem('cbpoa_role', r);
|
||||||
|
}, role ?? '');
|
||||||
|
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
|
||||||
|
if (url.includes('/alerts')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ alerts: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/history/aggregated')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/grids')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/diagnoses') || url.includes('/diagnosis-list')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('role-aware 预警 view (Phase 3)', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 800 } });
|
||||||
|
|
||||||
|
test('医生 /alerts?view=cluster: cluster-view mounts, disease filter present, ZERO patient points', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedAuthAndMockApi(page, 'doctor');
|
||||||
|
await page.goto('/alerts?view=cluster');
|
||||||
|
|
||||||
|
// Page + the aggregated density (cluster) map both mount.
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
// The disease filter is the doctor's core tool — it must be on the page.
|
||||||
|
await expect(page.getByText('按病种筛选')).toBeVisible();
|
||||||
|
|
||||||
|
// PRIVACY INVARIANT: not a single individual patient/case point may be rendered.
|
||||||
|
// Asserted at the data level (the mirrored DOM set), independent of Leaflet internals.
|
||||||
|
await expect(page.getByTestId(TESTIDS.patientPoint)).toHaveCount(0);
|
||||||
|
|
||||||
|
// The 预警标记 toggle (which would turn individual markers on) must be absent,
|
||||||
|
// so there is no way for the doctor to opt out of the privacy invariant.
|
||||||
|
await expect(page.getByRole('button', { name: '预警标记' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('官员 /alerts: 100m grid hidden — grid-layer-wrapper not rendered', async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page, 'official');
|
||||||
|
await page.goto('/alerts');
|
||||||
|
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
// The grid toggle wrapper must be entirely absent for leadership.
|
||||||
|
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin /alerts: full behavior — grid toggle present, no forced cluster view', async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page, 'admin');
|
||||||
|
await page.goto('/alerts');
|
||||||
|
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
||||||
|
// Admin keeps the grid toggle and is NOT forced into cluster view.
|
||||||
|
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(1);
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
frontend/e2e/granularity.spec.ts
Normal file
78
frontend/e2e/granularity.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
// 与 user-flows.spec.ts 一致的鉴权策略:注入 token 绕过登录门,并 mock /api/**,
|
||||||
|
// 让用例脱离活的后端 hermetic 运行。
|
||||||
|
async function seedAuthAndMockApi(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
});
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
if (url.includes('/history/aggregated')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ aggregations: [], total_records: 0 }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/grids')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ type: 'FeatureCollection', features: [] }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/streets')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ streets: [] }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], trend: [], total: 0 }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], items: [], total: 0 }) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 粒度(granularity)以 URL query 参数为真相来源(source of truth)。
|
||||||
|
// 监测页通过 useSearchParams 读取它,drilldownStore 单向派生。
|
||||||
|
test.describe('monitoring granularity URL source-of-truth', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep-link granularity=street mounts page and reflects street in control', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring?granularity=street');
|
||||||
|
|
||||||
|
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||||||
|
|
||||||
|
const control = page.getByTestId(TESTIDS.granularityControl);
|
||||||
|
await expect(control).toBeVisible();
|
||||||
|
// 街道分段为激活态(Segmented 给激活按钮加 bg-primary)。
|
||||||
|
const streetBtn = page.getByTestId(`${TESTIDS.granularityControl}-street`);
|
||||||
|
await expect(streetBtn).toHaveClass(/bg-primary/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a granularity control updates the URL granularity param', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring?granularity=street');
|
||||||
|
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||||||
|
|
||||||
|
// 切到「区域」应把 URL 写为 granularity=district。
|
||||||
|
await page.getByTestId(`${TESTIDS.granularityControl}-district`).click();
|
||||||
|
await expect(page).toHaveURL(/granularity=district/);
|
||||||
|
|
||||||
|
// 切到「全市」应把 URL 写为 granularity=city。
|
||||||
|
await page.getByTestId(`${TESTIDS.granularityControl}-city`).click();
|
||||||
|
await expect(page).toHaveURL(/granularity=city/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep-link granularity=district survives a reload', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring?granularity=district');
|
||||||
|
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/granularity=district/);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/granularity=district/);
|
||||||
|
await expect(page.getByTestId(`${TESTIDS.granularityControl}-district`)).toHaveClass(/bg-primary/);
|
||||||
|
});
|
||||||
|
});
|
||||||
133
frontend/e2e/roles.spec.ts
Normal file
133
frontend/e2e/roles.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Phase-3 acceptance tests: 视角/perspective switcher (D2 — frontend view-presets only).
|
||||||
|
*
|
||||||
|
* Roles are NOT access control: the switcher only changes the default landing page +
|
||||||
|
* granularity/filter presets. This suite verifies the switcher renders, selecting a role
|
||||||
|
* navigates to that role's default landing URL (with its query params), and the choice
|
||||||
|
* survives a reload (persisted to localStorage['cbpoa_role']).
|
||||||
|
*
|
||||||
|
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically.
|
||||||
|
*/
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { TESTIDS } from '../src/utils/testids';
|
||||||
|
|
||||||
|
/** Seed auth token and mock all /api/** calls (shapes copied from user-flows.spec.ts). */
|
||||||
|
async function seedAuthAndMockApi(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('/api/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
|
||||||
|
if (url.includes('/alerts')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ alerts: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/history/aggregated')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/grids')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], total: 0 }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('视角/perspective switcher (Phase 3)', () => {
|
||||||
|
// Use a desktop viewport so the top bar renders the switcher inline.
|
||||||
|
test.use({ viewport: { width: 1280, height: 800 } });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthAndMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('perspective-switcher is visible in the top bar', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting 厅领导 (official) navigates to /overview?granularity=district', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
|
||||||
|
await expect(switcher).toBeVisible();
|
||||||
|
|
||||||
|
await switcher.selectOption('official');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/overview/);
|
||||||
|
await expect(page).toHaveURL(/granularity=district/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting 医生 (doctor) navigates to /alerts?view=cluster', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
|
||||||
|
await expect(switcher).toBeVisible();
|
||||||
|
|
||||||
|
await switcher.selectOption('doctor');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/alerts/);
|
||||||
|
await expect(page).toHaveURL(/view=cluster/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selected role persists across reload (localStorage cbpoa_role)', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
|
||||||
|
await switcher.selectOption('doctor');
|
||||||
|
await expect(page).toHaveURL(/\/alerts/);
|
||||||
|
|
||||||
|
// localStorage should now hold the chosen role.
|
||||||
|
const stored = await page.evaluate(() => localStorage.getItem('cbpoa_role'));
|
||||||
|
expect(stored).toBe('doctor');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// After reload the switcher reflects the persisted role.
|
||||||
|
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toHaveValue('doctor');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,62 @@
|
|||||||
import { useEffect, useRef, useState, memo } from 'react';
|
import { useEffect, useRef, useState, memo } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import { geocodedApi } from '@/services/api';
|
import { geocodedApi } from '@/services/api';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import type { GeocodedCase } from '@/types';
|
import type { GeocodedCase } from '@/types';
|
||||||
|
|
||||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||||
|
|
||||||
|
// 视图模式:
|
||||||
|
// 'points' —— 个体病例点(默认,非医生视角)。逐病例渲染 circleMarker,
|
||||||
|
// 并在 DOM 中输出隐藏的 patient-point 镜像供 e2e 计数。
|
||||||
|
// 'density' —— 聚合密度(医生视角,隐私不变量)。仅按行政区/街道聚合的密度圆,
|
||||||
|
// 不渲染任何个体点,patient-point 数量必须为 0。
|
||||||
|
type CaseMapMode = 'points' | 'density';
|
||||||
|
|
||||||
interface CaseLocationMapProps {
|
interface CaseLocationMapProps {
|
||||||
height?: string;
|
height?: string;
|
||||||
district?: string | null;
|
district?: string | null;
|
||||||
street?: string | null;
|
street?: string | null;
|
||||||
date?: string | null;
|
date?: string | null;
|
||||||
|
mode?: CaseMapMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CaseLocationMapComponent({ height = '400px', district = null, street = null, date = null }: CaseLocationMapProps) {
|
// 聚合中心:按 street(无则 district)分组,取经纬度均值 + 计数。
|
||||||
|
interface DensityCluster {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateClusters(cases: GeocodedCase[]): DensityCluster[] {
|
||||||
|
const groups: Record<string, { latSum: number; lonSum: number; count: number; label: string }> = {};
|
||||||
|
for (const c of cases) {
|
||||||
|
if (!c.latitude || !c.longitude) continue;
|
||||||
|
const key = `${c.district}/${c.street || ''}`;
|
||||||
|
const label = c.street ? `${c.district} ${c.street}` : c.district;
|
||||||
|
if (!groups[key]) groups[key] = { latSum: 0, lonSum: 0, count: 0, label };
|
||||||
|
groups[key].latSum += c.latitude;
|
||||||
|
groups[key].lonSum += c.longitude;
|
||||||
|
groups[key].count += 1;
|
||||||
|
}
|
||||||
|
return Object.entries(groups).map(([key, g]) => ({
|
||||||
|
key,
|
||||||
|
label: g.label,
|
||||||
|
latitude: g.latSum / g.count,
|
||||||
|
longitude: g.lonSum / g.count,
|
||||||
|
count: g.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function CaseLocationMapComponent({
|
||||||
|
height = '400px',
|
||||||
|
district = null,
|
||||||
|
street = null,
|
||||||
|
date = null,
|
||||||
|
mode = 'points',
|
||||||
|
}: CaseLocationMapProps) {
|
||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
@@ -20,6 +64,10 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
|||||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [caseCount, setCaseCount] = useState(0);
|
const [caseCount, setCaseCount] = useState(0);
|
||||||
|
// points 模式下的隐藏 DOM 镜像(每病例一项,供 e2e 对 patient-point 计数)。
|
||||||
|
// density 模式下保持为空数组 —— 隐私不变量:医生视角下 patient-point 必须为 0。
|
||||||
|
const [pointKeys, setPointKeys] = useState<string[]>([]);
|
||||||
|
const [clusterCount, setClusterCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || mapInstanceRef.current) return;
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
@@ -79,6 +127,42 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
|||||||
|
|
||||||
if (cancelledRef.current) return;
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
if (mode === 'density') {
|
||||||
|
// 医生视角:仅渲染聚合密度圆(按街道/区聚合),不渲染任何个体点。
|
||||||
|
const clusters = aggregateClusters(unique);
|
||||||
|
const maxCount = clusters.reduce((m, c) => Math.max(m, c.count), 1);
|
||||||
|
|
||||||
|
for (const cl of clusters) {
|
||||||
|
// 半径随计数缩放(8–28px),明确表达「密度」而非个体位置。
|
||||||
|
const radius = 8 + Math.round((cl.count / maxCount) * 20);
|
||||||
|
const marker = L.circleMarker([cl.latitude, cl.longitude], {
|
||||||
|
radius,
|
||||||
|
fillColor: '#7c3aed',
|
||||||
|
fillOpacity: 0.35,
|
||||||
|
color: '#7c3aed',
|
||||||
|
weight: 1.5,
|
||||||
|
});
|
||||||
|
marker.bindTooltip(
|
||||||
|
`<div style="font-size:12px"><strong>${cl.label}</strong><br/>病例数: ${cl.count}</div>`,
|
||||||
|
{ direction: 'top', offset: [0, -4] }
|
||||||
|
);
|
||||||
|
marker.addTo(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
setClusterCount(clusters.length);
|
||||||
|
setCaseCount(unique.length);
|
||||||
|
setPointKeys([]); // 隐私不变量:density 下无个体点镜像
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
if (clusters.length > 0) {
|
||||||
|
const bounds = L.latLngBounds(clusters.map((c) => [c.latitude, c.longitude]));
|
||||||
|
map.fitBounds(bounds, { padding: [30, 30] });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// points 模式(默认):逐病例渲染个体 circleMarker。
|
||||||
|
const keys: string[] = [];
|
||||||
for (const c of unique) {
|
for (const c of unique) {
|
||||||
if (!c.latitude || !c.longitude) continue;
|
if (!c.latitude || !c.longitude) continue;
|
||||||
|
|
||||||
@@ -100,9 +184,12 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
|||||||
);
|
);
|
||||||
|
|
||||||
marker.addTo(layer);
|
marker.addTo(layer);
|
||||||
|
keys.push(c.case_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setClusterCount(0);
|
||||||
setCaseCount(unique.length);
|
setCaseCount(unique.length);
|
||||||
|
setPointKeys(keys); // 隐藏 DOM 镜像供 e2e 计数
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
// Fit bounds to case locations
|
// Fit bounds to case locations
|
||||||
@@ -124,23 +211,41 @@ function CaseLocationMapComponent({ height = '400px', district = null, street =
|
|||||||
map.remove();
|
map.remove();
|
||||||
mapInstanceRef.current = null;
|
mapInstanceRef.current = null;
|
||||||
};
|
};
|
||||||
}, [district, street, date]);
|
}, [district, street, date, mode]);
|
||||||
|
|
||||||
|
const isDensity = mode === 'density';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative" data-case-map-mode={mode}>
|
||||||
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
|
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
|
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
|
||||||
<div className="text-sm text-gray-500">加载病例位置...</div>
|
<div className="text-sm text-gray-500">加载病例位置...</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{!isLoading && !isDensity && (
|
||||||
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
||||||
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span> 个病例位置
|
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span> 个病例位置
|
||||||
<span className="ml-2 text-red-500">● 住院</span>
|
<span className="ml-2 text-red-500">● 住院</span>
|
||||||
<span className="ml-1 text-blue-500">● 门诊</span>
|
<span className="ml-1 text-blue-500">● 门诊</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isLoading && isDensity && (
|
||||||
|
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
||||||
|
<span className="text-purple-600 font-semibold">{clusterCount.toLocaleString()}</span> 个聚合区域
|
||||||
|
<span className="ml-2 text-gray-500">按区域聚合密度(隐私保护)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/*
|
||||||
|
隐藏 DOM 镜像:points 模式下每病例输出一个 patient-point 节点,使 e2e 能对
|
||||||
|
Leaflet canvas 之外的真实 DOM 做计数断言。density 模式下 pointKeys 恒为空,
|
||||||
|
因此医生视角下 [data-testid=patient-point] 数量必为 0(隐私不变量)。
|
||||||
|
*/}
|
||||||
|
<div className="hidden" aria-hidden="true">
|
||||||
|
{pointKeys.map((id) => (
|
||||||
|
<span key={id} data-testid={TESTIDS.patientPoint} data-case-id={id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
13
frontend/src/components/RoleRedirect.tsx
Normal file
13
frontend/src/components/RoleRedirect.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useSessionStore } from '@/stores';
|
||||||
|
import { roleDefaultPath } from '@/utils/roleViews';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据当前视角(role)把裸路径 `/` 重定向到该视角的默认落地页。
|
||||||
|
* D2:纯前端视图预设 —— role 只决定默认落地页,不是访问控制。
|
||||||
|
* 角色来源被隔离在 sessionStore 的 getRoleSource() 接缝里。
|
||||||
|
*/
|
||||||
|
export function RoleRedirect(): JSX.Element {
|
||||||
|
const role = useSessionStore((s) => s.role);
|
||||||
|
return <Navigate to={roleDefaultPath(role)} replace />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { TESTIDS } from '@/utils/testids';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
import { useSessionStore, ROLES, type Role } from '@/stores/sessionStore';
|
||||||
|
import { ROLE_LABELS, roleDefaultPath } from '@/utils/roleViews';
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
@@ -18,6 +21,38 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 视角切换器:纯前端视图预设(D2)。刻意标注「视角」而非「权限」——不是访问控制。
|
||||||
|
// 切换时持久化角色并跳转到该视角的默认落地页。
|
||||||
|
function PerspectiveSwitcher() {
|
||||||
|
const role = useSessionStore((s) => s.role);
|
||||||
|
const setRole = useSessionStore((s) => s.setRole);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleChange = (next: Role) => {
|
||||||
|
setRole(next);
|
||||||
|
navigate(roleDefaultPath(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-1.5 text-[13px] text-text-secondary">
|
||||||
|
<span className="text-text-muted hidden sm:inline">视角</span>
|
||||||
|
<select
|
||||||
|
data-testid={TESTIDS.perspectiveSwitcher}
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => handleChange(e.target.value as Role)}
|
||||||
|
aria-label="切换视角"
|
||||||
|
className="bg-bg-card border border-border rounded-md px-2 py-1 text-[13px] text-text-primary hover:bg-bg-hover focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r} value={r} data-testid={`${TESTIDS.perspectiveOption}-${r}`}>
|
||||||
|
{ROLE_LABELS[r]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
|
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
|
||||||
return (
|
return (
|
||||||
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
|
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
|
||||||
@@ -62,7 +97,7 @@ export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavPro
|
|||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
admin
|
<PerspectiveSwitcher />
|
||||||
</div>
|
</div>
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { LoadingState } from '@/components/ui';
|
import { LoadingState } from '@/components/ui';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useRiskStore } from '@/stores';
|
import { useRiskStore, useSessionStore } from '@/stores';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import { AlertMap } from '@/components/AlertMap';
|
import { AlertMap } from '@/components/AlertMap';
|
||||||
|
import { DiseaseFilter } from '@/components/DiseaseFilter';
|
||||||
import type { CellInfo } from '@/components/AlertMap';
|
import type { CellInfo } from '@/components/AlertMap';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
import { StatCard } from '@/components/StatCard';
|
import { StatCard } from '@/components/StatCard';
|
||||||
@@ -33,6 +36,15 @@ const HORIZON_LABELS: Record<number, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AlertsDashboard() {
|
export function AlertsDashboard() {
|
||||||
|
// 视角驱动的两条不变量(D2:纯前端视图预设,非访问控制):
|
||||||
|
// 1. 官员(厅领导)不展示 100m 网格(「对他没意义/太超前」)——强制 showGrid=false 且隐藏网格切换。
|
||||||
|
// 2. 医生(或 ?view=cluster)= 聚类/密度视角:只看聚合栅格密度 + 病种过滤,
|
||||||
|
// 绝不渲染任何个体病例点(隐私不变量)——强制 showAlertMarkers=false 且隐藏「预警标记」切换。
|
||||||
|
const role = useSessionStore((s) => s.role);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const view = searchParams.get('view');
|
||||||
|
const isOfficial = role === 'official';
|
||||||
|
const isCluster = role === 'doctor' || view === 'cluster';
|
||||||
const alerts = useRiskStore((s) => s.alerts);
|
const alerts = useRiskStore((s) => s.alerts);
|
||||||
const isLoading = useRiskStore((s) => s.isLoading);
|
const isLoading = useRiskStore((s) => s.isLoading);
|
||||||
const error = useRiskStore((s) => s.error);
|
const error = useRiskStore((s) => s.error);
|
||||||
@@ -49,9 +61,15 @@ export function AlertsDashboard() {
|
|||||||
const [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
const [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
||||||
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
|
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [showGrid, setShowGrid] = useState(true);
|
// 官员视角默认隐藏网格(见上);其余角色默认显示。
|
||||||
|
const [showGrid, setShowGrid] = useState(!isOfficial);
|
||||||
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
|
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
|
||||||
|
|
||||||
|
// 隐私不变量:聚类(医生)视角下,个体病例点标记永远关闭,且无法被打开。
|
||||||
|
// 这里把「用户意图的开关状态」与「实际生效的状态」分开:effectiveShowAlertMarkers
|
||||||
|
// 是唯一传给地图/渲染的真值,cluster 模式恒为 false,与用户点击无关。
|
||||||
|
const effectiveShowAlertMarkers = isCluster ? false : showAlertMarkers;
|
||||||
|
|
||||||
// In-page tab strip (no router) — matches existing activePage pattern
|
// In-page tab strip (no router) — matches existing activePage pattern
|
||||||
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
|
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
|
||||||
|
|
||||||
@@ -427,26 +445,36 @@ export function AlertsDashboard() {
|
|||||||
>
|
>
|
||||||
地图
|
地图
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
|
||||||
onClick={() => setShowAlertMarkers(!showAlertMarkers)}
|
{!isCluster && (
|
||||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
<button
|
||||||
showAlertMarkers
|
onClick={() => setShowAlertMarkers(!showAlertMarkers)}
|
||||||
? 'bg-primary/10 text-primary border border-primary/30'
|
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
: 'bg-bg-page text-text-muted border border-border'
|
showAlertMarkers
|
||||||
}`}
|
? 'bg-primary/10 text-primary border border-primary/30'
|
||||||
>
|
: 'bg-bg-page text-text-muted border border-border'
|
||||||
预警标记
|
}`}
|
||||||
</button>
|
>
|
||||||
<button
|
预警标记
|
||||||
onClick={() => setShowGrid(!showGrid)}
|
</button>
|
||||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
)}
|
||||||
showGrid
|
{/* 网格切换:官员视角隐藏整块(100m 网格对其无意义/太超前)。 */}
|
||||||
? 'bg-primary/10 text-primary border border-primary/30'
|
{!isOfficial && (
|
||||||
: 'bg-bg-page text-text-muted border border-border'
|
<div data-testid={TESTIDS.gridLayerWrapper}>
|
||||||
}`}
|
<button
|
||||||
>
|
onClick={() => setShowGrid(!showGrid)}
|
||||||
网格
|
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
</button>
|
showGrid
|
||||||
|
? 'bg-primary/10 text-primary border border-primary/30'
|
||||||
|
: 'bg-bg-page text-text-muted border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
网格
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */}
|
||||||
|
{isCluster && <DiseaseFilter />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
<div className="w-px h-6 bg-border" />
|
||||||
@@ -515,7 +543,8 @@ export function AlertsDashboard() {
|
|||||||
<div className="card p-8">
|
<div className="card p-8">
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
) : filteredAlerts.length === 0 ? (
|
) : filteredAlerts.length === 0 && !isCluster ? (
|
||||||
|
// 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
|
||||||
<div className="card p-8 text-center">
|
<div className="card p-8 text-center">
|
||||||
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" 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"/>
|
||||||
@@ -525,16 +554,35 @@ export function AlertsDashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
|
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
|
||||||
{showMap && (
|
{showMap && (
|
||||||
<AlertMap
|
<div data-testid={isCluster ? TESTIDS.clusterView : undefined}>
|
||||||
selectedGridId={selectedGridId}
|
<AlertMap
|
||||||
onGridClick={handleGridClick}
|
selectedGridId={selectedGridId}
|
||||||
onCellInfo={handleCellInfo}
|
onGridClick={handleGridClick}
|
||||||
forecastDay={forecastDay}
|
onCellInfo={handleCellInfo}
|
||||||
showAlertMarkers={showAlertMarkers}
|
forecastDay={forecastDay}
|
||||||
showGrid={showGrid}
|
showAlertMarkers={effectiveShowAlertMarkers}
|
||||||
filteredAlerts={filteredAlerts}
|
showGrid={isOfficial ? false : showGrid}
|
||||||
isFullscreen={isFullscreen}
|
filteredAlerts={filteredAlerts}
|
||||||
/>
|
isFullscreen={isFullscreen}
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个
|
||||||
|
data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG
|
||||||
|
内部对象、不带 testid,无法被 e2e 直接计数;这里把「实际会显示的个体点集合」
|
||||||
|
镜像成 DOM,使测试可断言医生/聚类视角下 patient-point 计数恒为 0,
|
||||||
|
而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false,
|
||||||
|
故该集合为空。
|
||||||
|
*/}
|
||||||
|
{effectiveShowAlertMarkers &&
|
||||||
|
filteredAlerts.map((a) => (
|
||||||
|
<span
|
||||||
|
key={a.alert_id}
|
||||||
|
data-testid={TESTIDS.patientPoint}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isFullscreen && (
|
{!isFullscreen && (
|
||||||
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
|
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react';
|
import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -25,10 +26,20 @@ import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
|
|||||||
import { StatCard } from '@/components/StatCard';
|
import { StatCard } from '@/components/StatCard';
|
||||||
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
import { CalendarHeatmap } from '@/components/CalendarHeatmap';
|
||||||
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
|
import { MetricHeatmapTable } from '@/components/MetricHeatmapTable';
|
||||||
|
import { Segmented } from '@/components/ui';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import type { DistrictCaseData } from '@/types';
|
import type { DistrictCaseData } from '@/types';
|
||||||
|
|
||||||
type MonitoringTab = 'overview' | 'cases' | 'districts';
|
type MonitoringTab = 'overview' | 'cases' | 'districts';
|
||||||
|
|
||||||
|
// URL 粒度参数取值 —— 监测页的「真相来源」(source of truth)。
|
||||||
|
// drilldownStore 由 URL 派生,不再自行持有真相。
|
||||||
|
type Granularity = 'city' | 'district' | 'street';
|
||||||
|
const GRANULARITY_VALUES: readonly Granularity[] = ['city', 'district', 'street'] as const;
|
||||||
|
function parseGranularity(raw: string | null): Granularity {
|
||||||
|
return GRANULARITY_VALUES.includes(raw as Granularity) ? (raw as Granularity) : 'city';
|
||||||
|
}
|
||||||
|
|
||||||
interface TopDiagnosis {
|
interface TopDiagnosis {
|
||||||
diagnosis: string;
|
diagnosis: string;
|
||||||
outpatient: number;
|
outpatient: number;
|
||||||
@@ -88,8 +99,71 @@ export function MonitoringDashboard({
|
|||||||
const isLoading = useMonitoringStore((s) => s.isLoading);
|
const isLoading = useMonitoringStore((s) => s.isLoading);
|
||||||
|
|
||||||
const { selectedDistrict, selectedStreet } = useDrilldownStore();
|
const { selectedDistrict, selectedStreet } = useDrilldownStore();
|
||||||
|
const drillDown = useDrilldownStore((s) => s.drillDown);
|
||||||
|
const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown);
|
||||||
const { selectedDiagnoses } = useDiseaseStore();
|
const { selectedDiagnoses } = useDiseaseStore();
|
||||||
|
|
||||||
|
// --- URL 是粒度的真相来源;drilldownStore 由 URL 派生 ---
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const granularity = parseGranularity(searchParams.get('granularity'));
|
||||||
|
const districtParam = searchParams.get('district');
|
||||||
|
const streetParam = searchParams.get('street');
|
||||||
|
|
||||||
|
// 把 URL 写入:粒度控件、面包屑、区域点击都通过它驱动 URL,再由下方 effect 同步 store。
|
||||||
|
const updateUrl = useCallback(
|
||||||
|
(next: { granularity: Granularity; district?: string | null; street?: string | null }) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const sp = new URLSearchParams(prev);
|
||||||
|
sp.set('granularity', next.granularity);
|
||||||
|
if (next.district) sp.set('district', next.district);
|
||||||
|
else sp.delete('district');
|
||||||
|
if (next.street) sp.set('street', next.street);
|
||||||
|
else sp.delete('street');
|
||||||
|
return sp;
|
||||||
|
},
|
||||||
|
{ replace: false }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 粒度控件回调:切换粒度即写 URL(深链可分享)。
|
||||||
|
const handleGranularityChange = useCallback(
|
||||||
|
(g: Granularity) => {
|
||||||
|
if (g === 'city') updateUrl({ granularity: 'city' });
|
||||||
|
else if (g === 'district') updateUrl({ granularity: 'district', district: selectedDistrict });
|
||||||
|
else updateUrl({ granularity: 'street', district: selectedDistrict, street: selectedStreet });
|
||||||
|
},
|
||||||
|
[updateUrl, selectedDistrict, selectedStreet]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 区域 roll-up 点击回调:驱动 URL 而非直接 mutate store(消除命令式 desync)。
|
||||||
|
const handleDistrictSelect = useCallback(
|
||||||
|
(district: string) => {
|
||||||
|
if (selectedDistrict === district) updateUrl({ granularity: 'city' });
|
||||||
|
else updateUrl({ granularity: 'district', district });
|
||||||
|
},
|
||||||
|
[updateUrl, selectedDistrict]
|
||||||
|
);
|
||||||
|
|
||||||
|
// store 从 URL 同步(URL 派生 store,单向)。mount 与 param 变化时执行。
|
||||||
|
useEffect(() => {
|
||||||
|
if (granularity === 'city') {
|
||||||
|
if (selectedDistrict !== null || selectedStreet !== null) resetDrillDown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (granularity === 'district') {
|
||||||
|
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
|
||||||
|
else if (!districtParam && selectedDistrict !== null) resetDrillDown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// granularity === 'street'
|
||||||
|
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
|
||||||
|
if (streetParam && selectedStreet !== streetParam) drillDown('street', streetParam);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [granularity, districtParam, streetParam]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDateRange(defaultStartDate, defaultEndDate);
|
setDateRange(defaultStartDate, defaultEndDate);
|
||||||
setCurrentDate(defaultEndDate);
|
setCurrentDate(defaultEndDate);
|
||||||
@@ -461,11 +535,28 @@ export function MonitoringDashboard({
|
|||||||
showAQI={true}
|
showAQI={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* District breakdown */}
|
{/* District breakdown — 区域 roll-up(URL 粒度真相来源) */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<div data-testid={TESTIDS.districtRollup} className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">区县病例分布</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">区县病例分布</h3>
|
||||||
|
<Segmented<Granularity>
|
||||||
|
testid={TESTIDS.granularityControl}
|
||||||
|
size="sm"
|
||||||
|
options={[
|
||||||
|
{ value: 'city', label: '全市' },
|
||||||
|
{ value: 'district', label: '区域' },
|
||||||
|
{ value: 'street', label: '街道' },
|
||||||
|
]}
|
||||||
|
value={granularity}
|
||||||
|
onChange={handleGranularityChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<DistrictBreakdown districtCases={districtCases} selectedDistrict={selectedDistrict} />
|
<DistrictBreakdown
|
||||||
|
districtCases={districtCases}
|
||||||
|
selectedDistrict={selectedDistrict}
|
||||||
|
onDistrictSelect={handleDistrictSelect}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
|
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
@@ -634,19 +725,17 @@ export function MonitoringDashboard({
|
|||||||
interface DistrictBreakdownProps {
|
interface DistrictBreakdownProps {
|
||||||
districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
|
districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
|
||||||
selectedDistrict: string | null;
|
selectedDistrict: string | null;
|
||||||
|
// 点击区域条目时上抛——由父组件驱动 URL(粒度真相来源),不在此处 mutate store。
|
||||||
|
onDistrictSelect: (district: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict }: DistrictBreakdownProps) {
|
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict, onDistrictSelect }: DistrictBreakdownProps) {
|
||||||
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
|
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
|
||||||
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
|
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
|
||||||
|
|
||||||
const handleDistrictClick = useCallback((district: string) => {
|
const handleDistrictClick = useCallback((district: string) => {
|
||||||
if (selectedDistrict === district) {
|
onDistrictSelect(district);
|
||||||
useDrilldownStore.getState().drillUp();
|
}, [onDistrictSelect]);
|
||||||
} else {
|
|
||||||
useDrilldownStore.getState().drillDown('district', district);
|
|
||||||
}
|
|
||||||
}, [selectedDistrict]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { lazy, Suspense, ComponentType } from 'react';
|
import { lazy, Suspense, ComponentType } from 'react';
|
||||||
import { Navigate, RouteObject } from 'react-router-dom';
|
import { Navigate, RouteObject } from 'react-router-dom';
|
||||||
import { LoadingState } from '@/components/ui/LoadingState';
|
import { LoadingState } from '@/components/ui/LoadingState';
|
||||||
|
import { RoleRedirect } from '@/components/RoleRedirect';
|
||||||
import { TESTIDS } from '@/utils/testids';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
|
||||||
// 按页面懒加载,路由层负责代码分割(原先位于 App.tsx)。
|
// 按页面懒加载,路由层负责代码分割(原先位于 App.tsx)。
|
||||||
@@ -44,7 +45,7 @@ function lazyElement(Page: ComponentType): JSX.Element {
|
|||||||
|
|
||||||
// AppShell 的子路由表(在 App.tsx 中作为 <Outlet/> 的内容渲染)。
|
// AppShell 的子路由表(在 App.tsx 中作为 <Outlet/> 的内容渲染)。
|
||||||
export const appRoutes: RouteObject[] = [
|
export const appRoutes: RouteObject[] = [
|
||||||
{ index: true, element: <Navigate to="/monitoring" replace /> },
|
{ index: true, element: <RoleRedirect /> },
|
||||||
{ path: 'overview', element: lazyElement(OverviewDashboard) },
|
{ path: 'overview', element: lazyElement(OverviewDashboard) },
|
||||||
{ path: 'monitoring', element: lazyElement(MonitoringDashboard) },
|
{ path: 'monitoring', element: lazyElement(MonitoringDashboard) },
|
||||||
{ path: 'alerts', element: lazyElement(AlertsDashboard) },
|
{ path: 'alerts', element: lazyElement(AlertsDashboard) },
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const useRiskStore = create<RiskState>((set, get) => ({
|
|||||||
export { useAnalysisStore } from './analysisStore';
|
export { useAnalysisStore } from './analysisStore';
|
||||||
export { useDrilldownStore } from './drilldownStore';
|
export { useDrilldownStore } from './drilldownStore';
|
||||||
export { useDiseaseStore } from './diseaseStore';
|
export { useDiseaseStore } from './diseaseStore';
|
||||||
|
export { useSessionStore } from './sessionStore';
|
||||||
|
|
||||||
|
|
||||||
interface TimelineState {
|
interface TimelineState {
|
||||||
|
|||||||
57
frontend/src/stores/sessionStore.test.ts
Normal file
57
frontend/src/stores/sessionStore.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { getRoleSource, type Role } from './sessionStore';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cbpoa_role';
|
||||||
|
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
|
||||||
|
|
||||||
|
describe('sessionStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRoleSource', () => {
|
||||||
|
it("defaults to 'admin' when localStorage is empty", () => {
|
||||||
|
expect(getRoleSource()).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 'admin' on an invalid stored value", () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'hacker');
|
||||||
|
expect(getRoleSource()).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns each valid stored role', () => {
|
||||||
|
for (const r of ALL_ROLES) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, r);
|
||||||
|
expect(getRoleSource()).toBe(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSessionStore', () => {
|
||||||
|
// useSessionStore 在模块加载时从 getRoleSource() 初始化一次,故用
|
||||||
|
// resetModules + 动态 import 来获得一个「按当前 localStorage 初始化」的全新实例。
|
||||||
|
it('initializes role from localStorage', async () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'doctor');
|
||||||
|
vi.resetModules();
|
||||||
|
const { useSessionStore } = await import('./sessionStore');
|
||||||
|
expect(useSessionStore.getState().role).toBe('doctor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setRole updates state and persists to localStorage', async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { useSessionStore } = await import('./sessionStore');
|
||||||
|
|
||||||
|
useSessionStore.getState().setRole('community');
|
||||||
|
expect(useSessionStore.getState().role).toBe('community');
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe('community');
|
||||||
|
|
||||||
|
useSessionStore.getState().setRole('official');
|
||||||
|
expect(useSessionStore.getState().role).toBe('official');
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe('official');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
59
frontend/src/stores/sessionStore.ts
Normal file
59
frontend/src/stores/sessionStore.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视角/perspective 会话存储 —— 纯前端视图预设(D2:不做后端鉴权,不加 JWT role claim)。
|
||||||
|
*
|
||||||
|
* 角色仅决定「默认落地页 + 粒度 + 过滤预设」,不是访问控制。切换器在 UI 上标注为
|
||||||
|
* 「视角」而非「权限」,因此 URL 可编辑不构成可信度陷阱。
|
||||||
|
*
|
||||||
|
* 角色来源被隔离在单一可替换的 getRoleSource() 接缝里:今天读 localStorage,
|
||||||
|
* 将来若需真正 RBAC,只改这一个函数(改读 /api/auth/me),其余代码不变。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Role = 'official' | 'community' | 'doctor' | 'admin';
|
||||||
|
|
||||||
|
export const ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
|
||||||
|
|
||||||
|
// 标签与默认落地路径已迁出到纯模块 '@/utils/roleViews'(ROLE_LABELS / roleDefaultPath),
|
||||||
|
// 保持单一来源。本 store 只负责「当前视角是什么」+ 可替换的角色来源接缝。
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cbpoa_role';
|
||||||
|
const DEFAULT_ROLE: Role = 'admin';
|
||||||
|
|
||||||
|
function isRole(v: unknown): v is Role {
|
||||||
|
return typeof v === 'string' && (ROLES as string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单一可替换接缝:角色来源。今天 = localStorage;将来 = /api/auth/me。
|
||||||
|
* 改 RBAC 只动这一个函数。
|
||||||
|
*/
|
||||||
|
export function getRoleSource(): Role {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return isRole(raw) ? raw : DEFAULT_ROLE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ROLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRole(role: Role): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, role);
|
||||||
|
} catch {
|
||||||
|
/* localStorage 不可用时静默降级 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
role: Role;
|
||||||
|
setRole: (role: Role) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessionStore = create<SessionState>((set) => ({
|
||||||
|
role: getRoleSource(),
|
||||||
|
setRole: (role) => {
|
||||||
|
persistRole(role);
|
||||||
|
set({ role });
|
||||||
|
},
|
||||||
|
}));
|
||||||
42
frontend/src/utils/roleViews.test.ts
Normal file
42
frontend/src/utils/roleViews.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ROLE_LABELS, roleDefaultPath } from './roleViews';
|
||||||
|
import type { Role } from '@/stores/sessionStore';
|
||||||
|
|
||||||
|
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
|
||||||
|
|
||||||
|
describe('roleViews', () => {
|
||||||
|
describe('ROLE_LABELS', () => {
|
||||||
|
it('has a non-empty label for all 4 roles', () => {
|
||||||
|
for (const r of ALL_ROLES) {
|
||||||
|
expect(ROLE_LABELS[r]).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the expected short labels (no 视角 suffix)', () => {
|
||||||
|
expect(ROLE_LABELS).toEqual({
|
||||||
|
official: '厅领导',
|
||||||
|
community: '社区',
|
||||||
|
doctor: '医生',
|
||||||
|
admin: '管理员',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('roleDefaultPath', () => {
|
||||||
|
it('official → /overview with granularity=district', () => {
|
||||||
|
expect(roleDefaultPath('official')).toBe('/overview?granularity=district');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('community → /monitoring with granularity=street', () => {
|
||||||
|
expect(roleDefaultPath('community')).toBe('/monitoring?granularity=street');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doctor → /alerts with view=cluster', () => {
|
||||||
|
expect(roleDefaultPath('doctor')).toBe('/alerts?view=cluster');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin → /monitoring (legacy full view; keeps user-flows baseline green)', () => {
|
||||||
|
expect(roleDefaultPath('admin')).toBe('/monitoring');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
42
frontend/src/utils/roleViews.ts
Normal file
42
frontend/src/utils/roleViews.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Role } from '@/stores/sessionStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视角/perspective 的纯展示元数据 —— 标签 + 默认落地路径。
|
||||||
|
*
|
||||||
|
* 纯模块(无副作用、无 React、无 store 依赖),便于单元测试。
|
||||||
|
* D2:角色只是「前端视图预设」,决定默认落地页 / 粒度 / 过滤预设,不是访问控制。
|
||||||
|
*
|
||||||
|
* wave-2 workers 的契约入口:
|
||||||
|
* import { roleDefaultPath, ROLE_LABELS } from '@/utils/roleViews'
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 视角中文短标签(switcher 在前面拼接「视角:」前缀,故此处不带「视角」后缀)。 */
|
||||||
|
export const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
official: '厅领导',
|
||||||
|
community: '社区',
|
||||||
|
doctor: '医生',
|
||||||
|
admin: '管理员',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个视角的默认落地 URL。query 参数由 wave-2 workers 消费:
|
||||||
|
* - granularity=district|street → 粒度控件初值(监测/概览)
|
||||||
|
* - view=cluster → 预警页医生聚类视图
|
||||||
|
*/
|
||||||
|
export function roleDefaultPath(role: Role): string {
|
||||||
|
switch (role) {
|
||||||
|
case 'official':
|
||||||
|
return '/overview?granularity=district';
|
||||||
|
case 'community':
|
||||||
|
return '/monitoring?granularity=street';
|
||||||
|
case 'doctor':
|
||||||
|
return '/alerts?view=cluster';
|
||||||
|
case 'admin':
|
||||||
|
default:
|
||||||
|
// admin = 旧「全量」视角,历史落地页即 /monitoring(与改造前 sessionStore 的
|
||||||
|
// ROLE_DEFAULT_PATH 一致)。保持 /monitoring 以兼容既有 user-flows 基线测试
|
||||||
|
// (裸 '/' 无 cbpoa_role ⇒ 默认 admin ⇒ /monitoring)。其余三个视角带查询参数,
|
||||||
|
// 由 wave-2 workers 消费,不受此选择影响。
|
||||||
|
return '/monitoring';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,15 @@ export const TESTIDS = {
|
|||||||
choroplethWrapper: 'choropleth-wrapper',
|
choroplethWrapper: 'choropleth-wrapper',
|
||||||
asofBadge: 'asof-badge',
|
asofBadge: 'asof-badge',
|
||||||
outinpatientToggle: 'outinpatient-toggle',
|
outinpatientToggle: 'outinpatient-toggle',
|
||||||
|
|
||||||
|
// 视角/perspective + 粒度(Phase 3)
|
||||||
|
perspectiveSwitcher: 'perspective-switcher',
|
||||||
|
perspectiveOption: 'perspective-option', // 配合角色后缀,如 perspective-option-official
|
||||||
|
granularityControl: 'granularity-control',
|
||||||
|
districtRollup: 'district-rollup',
|
||||||
|
gridLayerWrapper: 'grid-layer-wrapper',
|
||||||
|
clusterView: 'cluster-view',
|
||||||
|
patientPoint: 'patient-point', // 个体病例点标记;医生视角下必须为 0
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];
|
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];
|
||||||
|
|||||||
Reference in New Issue
Block a user