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:
@@ -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<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 mapInstanceRef = useRef<L.Map | 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 [isLoading, setIsLoading] = useState(true);
|
||||
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(() => {
|
||||
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(
|
||||
`<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) {
|
||||
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 (
|
||||
<div className="relative">
|
||||
<div className="relative" data-case-map-mode={mode}>
|
||||
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
|
||||
<div className="text-sm text-gray-500">加载病例位置...</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
{!isLoading && !isDensity && (
|
||||
<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="ml-2 text-red-500">● 住院</span>
|
||||
<span className="ml-1 text-blue-500">● 门诊</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { 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 <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) {
|
||||
return (
|
||||
<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">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
admin
|
||||
<PerspectiveSwitcher />
|
||||
</div>
|
||||
{onLogout && (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user