Final phase of the UX modernization. Four conflict-free lanes. Responsive (D4 — desktop+mobile 并重): - 7 analysis pages made usable at 375px: grid-cols-4/5 → grid-cols-2 sm:* responsive variants; raw tables wrapped in overflow-x-auto; page overflow guards - new e2e/responsive.spec.ts loops all 7 analysis routes at 375px asserting no horizontal scroll Perf harness: - playwright.config.ts gains an isolated `perf` project (testMatch /perf/), default chromium project excludes it (testIgnore) - new e2e/perf.spec.ts: CDP Network.emulateNetworkConditions (Fast 3G) + PerformanceObserver LCP on /overview kpi-row + route-transition timing; numbers reported as a relative regression signal (dev-server, not a prod SLA), not gated God-component splits (pure refactors, behavior-preserving): - MonitoringDashboard 686 → 239 lines: extracted components/monitoring/* (StatsBar, OverviewTab, CaseStatsTab, DistrictStatsTab) + useMonitoringData hook; URL-granularity source-of-truth + drilldown reconcile kept in the orchestrator (no desync regression) - AlertsDashboard 816 → 301 lines: extracted components/alerts/* (Toolbar, List, RiskPanel, MapPanel, DetailModal, …); role/privacy/grid-hide logic kept in the orchestrator — doctor-view privacy invariant (zero patient-point) still holds Gates: tsc 0 · vitest 75 · functional e2e 37/37 (incl doctor-view privacy + granularity + responsive) · build ok · perf project runs + reports Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
4.9 KiB
TypeScript
109 lines
4.9 KiB
TypeScript
import React from 'react';
|
|
import type { CellInfo } from '@/components/AlertMap';
|
|
import { HORIZON_LABELS } from './types';
|
|
import type { ExtendedAlert } from './types';
|
|
|
|
interface CellInfoPanelProps {
|
|
cellInfo: CellInfo;
|
|
onClose: () => void;
|
|
}
|
|
|
|
// Cell info panel - shown when clicking grid cell without alert
|
|
export const CellInfoPanel = React.memo(function CellInfoPanel({ cellInfo, onClose }: CellInfoPanelProps) {
|
|
return (
|
|
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-bg-card rounded-lg border border-border-light shadow-lg z-50 px-5 py-4 min-w-[320px]">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-[14px] font-semibold text-text-primary">网格详情 (100m)</span>
|
|
<button onClick={onClose} className="text-text-muted hover:text-text-primary text-[18px] leading-none">×</button>
|
|
</div>
|
|
<div className="space-y-2 text-[12px]">
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">网格</span>
|
|
<span className="font-mono text-text-primary">{cellInfo.grid_id}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">坐标</span>
|
|
<span className="font-mono text-text-primary">{cellInfo.lat.toFixed(4)}, {cellInfo.lon.toFixed(4)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">当前风险</span>
|
|
<span className={`font-bold ${cellInfo.risk >= 0.8 ? 'text-danger' : cellInfo.risk >= 0.6 ? 'text-warning' : cellInfo.risk >= 0.4 ? 'text-primary' : 'text-success'}`}>
|
|
{(cellInfo.risk * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-3 pt-1">
|
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
<div className="text-[10px] text-text-muted">1天</div>
|
|
<div className="font-bold text-[13px]">{(cellInfo.risk_1d * 100).toFixed(0)}%</div>
|
|
</div>
|
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
<div className="text-[10px] text-text-muted">3天</div>
|
|
<div className="font-bold text-[13px]">{(cellInfo.risk_3d * 100).toFixed(0)}%</div>
|
|
</div>
|
|
<div className="flex-1 text-center p-1.5 rounded bg-bg-page">
|
|
<div className="text-[10px] text-text-muted">7天</div>
|
|
<div className="font-bold text-[13px]">{(cellInfo.risk_7d * 100).toFixed(0)}%</div>
|
|
</div>
|
|
</div>
|
|
{cellInfo.nearestAlertId && (
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">最近预警距离</span>
|
|
<span className="text-text-primary">{(cellInfo.nearestAlertDist * 111).toFixed(1)} km</span>
|
|
</div>
|
|
)}
|
|
{!cellInfo.nearestAlertId && (
|
|
<div className="text-[11px] text-text-muted mt-1 pt-2 border-t border-border">
|
|
该区域无预警
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface AlertDetailModalProps {
|
|
alert: ExtendedAlert;
|
|
onClose: () => void;
|
|
}
|
|
|
|
// Alert detail modal
|
|
export const AlertDetailModal = React.memo(function AlertDetailModal({ alert, onClose }: AlertDetailModalProps) {
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={onClose}>
|
|
<div className="bg-bg-card rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
|
|
<h3 className="font-display text-[16px] font-semibold mb-3">预警详情</h3>
|
|
<div className="space-y-2 text-[13px]">
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">优先级</span>
|
|
<span className={`font-bold ${alert.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
|
|
{alert.priority}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">风险值</span>
|
|
<span className="font-bold">{Math.round(alert.risk_value * 100)}%</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">预测时效</span>
|
|
<span>{HORIZON_LABELS[alert.forecast_horizon]}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">位置</span>
|
|
<span>{alert.region}</span>
|
|
</div>
|
|
<div className="pt-2 border-t border-border">
|
|
<div className="text-text-muted mb-1">预警原因</div>
|
|
<div className="text-[12px]">{alert.reason}</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="mt-4 w-full px-4 py-2 bg-primary text-white rounded hover:bg-primary/80 transition-colors text-[13px]"
|
|
>
|
|
关闭
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|