From 8ad7e086bb79b23ee6a14c49b3e4bafa6d1396c3 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Sun, 21 Jun 2026 20:46:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20=E8=A7=86?= =?UTF-8?q?=E8=A7=92/perspective=20presets=20+=20URL-driven=20granularity?= =?UTF-8?q?=20+=20doctor=20privacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/e2e/doctor-view.spec.ts | 143 ++++++++++++++++++++ frontend/e2e/granularity.spec.ts | 78 +++++++++++ frontend/e2e/roles.spec.ts | 133 ++++++++++++++++++ frontend/src/components/CaseLocationMap.tsx | 113 +++++++++++++++- frontend/src/components/RoleRedirect.tsx | 13 ++ frontend/src/components/TopNav.tsx | 37 ++++- frontend/src/pages/AlertsDashboard.tsx | 114 +++++++++++----- frontend/src/pages/MonitoringDashboard.tsx | 111 +++++++++++++-- frontend/src/routes.tsx | 3 +- frontend/src/stores/index.ts | 1 + frontend/src/stores/sessionStore.test.ts | 57 ++++++++ frontend/src/stores/sessionStore.ts | 59 ++++++++ frontend/src/utils/roleViews.test.ts | 42 ++++++ frontend/src/utils/roleViews.ts | 42 ++++++ frontend/src/utils/testids.ts | 9 ++ 15 files changed, 905 insertions(+), 50 deletions(-) create mode 100644 frontend/e2e/doctor-view.spec.ts create mode 100644 frontend/e2e/granularity.spec.ts create mode 100644 frontend/e2e/roles.spec.ts create mode 100644 frontend/src/components/RoleRedirect.tsx create mode 100644 frontend/src/stores/sessionStore.test.ts create mode 100644 frontend/src/stores/sessionStore.ts create mode 100644 frontend/src/utils/roleViews.test.ts create mode 100644 frontend/src/utils/roleViews.ts diff --git a/frontend/e2e/doctor-view.spec.ts b/frontend/e2e/doctor-view.spec.ts new file mode 100644 index 0000000..ef1e3ec --- /dev/null +++ b/frontend/e2e/doctor-view.spec.ts @@ -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); + }); +}); diff --git a/frontend/e2e/granularity.spec.ts b/frontend/e2e/granularity.spec.ts new file mode 100644 index 0000000..933e266 --- /dev/null +++ b/frontend/e2e/granularity.spec.ts @@ -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/); + }); +}); diff --git a/frontend/e2e/roles.spec.ts b/frontend/e2e/roles.spec.ts new file mode 100644 index 0000000..fd34003 --- /dev/null +++ b/frontend/e2e/roles.spec.ts @@ -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'); + }); +}); diff --git a/frontend/src/components/CaseLocationMap.tsx b/frontend/src/components/CaseLocationMap.tsx index 4bf7d70..648460a 100644 --- a/frontend/src/components/CaseLocationMap.tsx +++ b/frontend/src/components/CaseLocationMap.tsx @@ -1,18 +1,62 @@ import { useEffect, useRef, useState, memo } from 'react'; import L from 'leaflet'; import { geocodedApi } from '@/services/api'; +import { TESTIDS } from '@/utils/testids'; import type { GeocodedCase } from '@/types'; 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 { height?: string; district?: string | null; street?: 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 = {}; + 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(null); const mapInstanceRef = useRef(null); const layerRef = useRef(null); @@ -20,6 +64,10 @@ function CaseLocationMapComponent({ height = '400px', district = null, street = const resizeObserverRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [caseCount, setCaseCount] = useState(0); + // points 模式下的隐藏 DOM 镜像(每病例一项,供 e2e 对 patient-point 计数)。 + // density 模式下保持为空数组 —— 隐私不变量:医生视角下 patient-point 必须为 0。 + const [pointKeys, setPointKeys] = useState([]); + const [clusterCount, setClusterCount] = useState(0); useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return; @@ -79,6 +127,42 @@ function CaseLocationMapComponent({ height = '400px', district = null, street = 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( + `
${cl.label}
病例数: ${cl.count}
`, + { 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) { if (!c.latitude || !c.longitude) continue; @@ -100,9 +184,12 @@ function CaseLocationMapComponent({ height = '400px', district = null, street = ); marker.addTo(layer); + keys.push(c.case_id); } + setClusterCount(0); setCaseCount(unique.length); + setPointKeys(keys); // 隐藏 DOM 镜像供 e2e 计数 setIsLoading(false); // Fit bounds to case locations @@ -124,23 +211,41 @@ function CaseLocationMapComponent({ height = '400px', district = null, street = map.remove(); mapInstanceRef.current = null; }; - }, [district, street, date]); + }, [district, street, date, mode]); + + const isDensity = mode === 'density'; return ( -
+
{isLoading && (
加载病例位置...
)} - {!isLoading && ( + {!isLoading && !isDensity && (
{caseCount.toLocaleString()} 个病例位置 ● 住院 ● 门诊
)} + {!isLoading && isDensity && ( +
+ {clusterCount.toLocaleString()} 个聚合区域 + 按区域聚合密度(隐私保护) +
+ )} + {/* + 隐藏 DOM 镜像:points 模式下每病例输出一个 patient-point 节点,使 e2e 能对 + Leaflet canvas 之外的真实 DOM 做计数断言。density 模式下 pointKeys 恒为空, + 因此医生视角下 [data-testid=patient-point] 数量必为 0(隐私不变量)。 + */} +
); } diff --git a/frontend/src/components/RoleRedirect.tsx b/frontend/src/components/RoleRedirect.tsx new file mode 100644 index 0000000..e13c02f --- /dev/null +++ b/frontend/src/components/RoleRedirect.tsx @@ -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 ; +} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 452ede7..a04ab48 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -1,5 +1,8 @@ import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { TESTIDS } from '@/utils/testids'; +import { useSessionStore, ROLES, type Role } from '@/stores/sessionStore'; +import { ROLE_LABELS, roleDefaultPath } from '@/utils/roleViews'; interface TopNavProps { onLogout?: () => void; @@ -18,6 +21,38 @@ function Clock() { return {time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}; } +// 视角切换器:纯前端视图预设(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 ( + + ); +} + export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) { return (
{onLogout && ( - - + {/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */} + {!isCluster && ( + + )} + {/* 网格切换:官员视角隐藏整块(100m 网格对其无意义/太超前)。 */} + {!isOfficial && ( +
+ +
+ )} + {/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */} + {isCluster && }
@@ -515,7 +543,8 @@ export function AlertsDashboard() {
- ) : filteredAlerts.length === 0 ? ( + ) : filteredAlerts.length === 0 && !isCluster ? ( + // 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
@@ -525,16 +554,35 @@ export function AlertsDashboard() { ) : (
{showMap && ( - +
+ + {/* + 隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个 + data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG + 内部对象、不带 testid,无法被 e2e 直接计数;这里把「实际会显示的个体点集合」 + 镜像成 DOM,使测试可断言医生/聚类视角下 patient-point 计数恒为 0, + 而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false, + 故该集合为空。 + */} + {effectiveShowAlertMarkers && + filteredAlerts.map((a) => ( + + ))} +
)} {!isFullscreen && (
diff --git a/frontend/src/pages/MonitoringDashboard.tsx b/frontend/src/pages/MonitoringDashboard.tsx index b3d9b29..662c78b 100644 --- a/frontend/src/pages/MonitoringDashboard.tsx +++ b/frontend/src/pages/MonitoringDashboard.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { LineChart, Line, @@ -25,10 +26,20 @@ import { AdminBreadcrumb } from '@/components/AdminBreadcrumb'; import { StatCard } from '@/components/StatCard'; import { CalendarHeatmap } from '@/components/CalendarHeatmap'; import { MetricHeatmapTable } from '@/components/MetricHeatmapTable'; +import { Segmented } from '@/components/ui'; +import { TESTIDS } from '@/utils/testids'; import type { DistrictCaseData } from '@/types'; 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 { diagnosis: string; outpatient: number; @@ -88,8 +99,71 @@ export function MonitoringDashboard({ const isLoading = useMonitoringStore((s) => s.isLoading); const { selectedDistrict, selectedStreet } = useDrilldownStore(); + const drillDown = useDrilldownStore((s) => s.drillDown); + const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown); 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(() => { setDateRange(defaultStartDate, defaultEndDate); setCurrentDate(defaultEndDate); @@ -461,11 +535,28 @@ export function MonitoringDashboard({ showAQI={true} /> - {/* District breakdown */} -
-

区县病例分布

+ {/* District breakdown — 区域 roll-up(URL 粒度真相来源) */} +
+
+

区县病例分布

+ + testid={TESTIDS.granularityControl} + size="sm" + options={[ + { value: 'city', label: '全市' }, + { value: 'district', label: '区域' }, + { value: 'street', label: '街道' }, + ]} + value={granularity} + onChange={handleGranularityChange} + /> +
- +
@@ -634,19 +725,17 @@ export function MonitoringDashboard({ interface DistrictBreakdownProps { districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>; 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 maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]); const handleDistrictClick = useCallback((district: string) => { - if (selectedDistrict === district) { - useDrilldownStore.getState().drillUp(); - } else { - useDrilldownStore.getState().drillDown('district', district); - } - }, [selectedDistrict]); + onDistrictSelect(district); + }, [onDistrictSelect]); return ( <> diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 7a4e881..939dbfb 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, ComponentType } from 'react'; import { Navigate, RouteObject } from 'react-router-dom'; import { LoadingState } from '@/components/ui/LoadingState'; +import { RoleRedirect } from '@/components/RoleRedirect'; import { TESTIDS } from '@/utils/testids'; // 按页面懒加载,路由层负责代码分割(原先位于 App.tsx)。 @@ -44,7 +45,7 @@ function lazyElement(Page: ComponentType): JSX.Element { // AppShell 的子路由表(在 App.tsx 中作为 的内容渲染)。 export const appRoutes: RouteObject[] = [ - { index: true, element: }, + { index: true, element: }, { path: 'overview', element: lazyElement(OverviewDashboard) }, { path: 'monitoring', element: lazyElement(MonitoringDashboard) }, { path: 'alerts', element: lazyElement(AlertsDashboard) }, diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 36e066c..5412bb2 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -102,6 +102,7 @@ export const useRiskStore = create((set, get) => ({ export { useAnalysisStore } from './analysisStore'; export { useDrilldownStore } from './drilldownStore'; export { useDiseaseStore } from './diseaseStore'; +export { useSessionStore } from './sessionStore'; interface TimelineState { diff --git a/frontend/src/stores/sessionStore.test.ts b/frontend/src/stores/sessionStore.test.ts new file mode 100644 index 0000000..75f03e2 --- /dev/null +++ b/frontend/src/stores/sessionStore.test.ts @@ -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'); + }); + }); +}); diff --git a/frontend/src/stores/sessionStore.ts b/frontend/src/stores/sessionStore.ts new file mode 100644 index 0000000..fe1511e --- /dev/null +++ b/frontend/src/stores/sessionStore.ts @@ -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((set) => ({ + role: getRoleSource(), + setRole: (role) => { + persistRole(role); + set({ role }); + }, +})); diff --git a/frontend/src/utils/roleViews.test.ts b/frontend/src/utils/roleViews.test.ts new file mode 100644 index 0000000..f9e36fd --- /dev/null +++ b/frontend/src/utils/roleViews.test.ts @@ -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'); + }); + }); +}); diff --git a/frontend/src/utils/roleViews.ts b/frontend/src/utils/roleViews.ts new file mode 100644 index 0000000..f60c9c2 --- /dev/null +++ b/frontend/src/utils/roleViews.ts @@ -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 = { + 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'; + } +} diff --git a/frontend/src/utils/testids.ts b/frontend/src/utils/testids.ts index df60152..9f67f5c 100644 --- a/frontend/src/utils/testids.ts +++ b/frontend/src/utils/testids.ts @@ -40,6 +40,15 @@ export const TESTIDS = { choroplethWrapper: 'choropleth-wrapper', asofBadge: 'asof-badge', 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; export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];