Files
CA/frontend/src/components/TopNav.tsx

82 lines
2.9 KiB
TypeScript
Raw Normal View History

import { useState, useEffect } from 'react';
import { TESTIDS } from '@/utils/testids';
interface TopNavProps {
onLogout?: () => void;
onToggleMenu?: () => void;
isMenuOpen?: boolean;
}
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
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>
2026-06-21 20:46:38 +08:00
return (
<span className="font-mono tabular-nums">
{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</span>
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>
2026-06-21 20:46:38 +08:00
);
}
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
return (
<nav className="h-[54px] shrink-0 bg-bg-card/95 border-b border-border flex items-center px-4 sm:px-5 z-50 backdrop-blur-md">
{onToggleMenu && (
<button
type="button"
onClick={onToggleMenu}
aria-label="打开菜单"
aria-expanded={isMenuOpen}
aria-controls="app-drawer"
data-testid={TESTIDS.hamburger}
className="lg:hidden mr-2.5 -ml-0.5 w-9 h-9 flex items-center justify-center rounded-lg text-text-secondary hover:bg-bg-hover transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-mist flex items-center justify-center shadow-soft shrink-0"
aria-hidden
>
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<path d="M12 3c-1.2 2.4-3.5 4-6 4 .6 3.4 2.8 6.2 6 7.5 3.2-1.3 5.4-4.1 6-7.5-2.5 0-4.8-1.6-6-4zm0 14.5c-2.2-.9-4-2.5-5.2-4.5C5.5 15.2 4 18 4 21h16c0-3-1.5-5.8-2.8-8-1.2 2-3 3.6-5.2 4.5z" />
</svg>
</div>
<div className="min-w-0 leading-tight">
<span className="brand-mark text-[16px] block truncate">CBPOA</span>
<span className="text-[11px] text-text-muted hidden sm:block truncate">
</span>
</div>
</div>
<div className="w-px h-6 bg-border ml-4 mr-4 hidden md:block" />
<span className="text-[12px] text-text-secondary hidden md:inline truncate">
· ·
</span>
<div className="ml-auto flex items-center gap-4">
<span className="text-[12px] text-text-muted hidden sm:inline">
<Clock />
</span>
{onLogout && (
<button
type="button"
onClick={onLogout}
className="text-[12px] text-text-muted hover:text-danger transition-colors px-2 py-1 rounded-md hover:bg-danger-light/60"
>
退
</button>
)}
</div>
</nav>
);
}