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>
164 lines
5.8 KiB
TypeScript
164 lines
5.8 KiB
TypeScript
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||
import { useRiskStore } from '@/stores';
|
||
import { analysisApi } from '@/services/api';
|
||
import type { ExtendedAlert, RiskStats } from './types';
|
||
|
||
interface UseAlertsDataParams {
|
||
selectedHorizon: number | 'all';
|
||
selectedPriority: 'all' | 'P1' | 'P2';
|
||
sortBy: 'risk' | 'time';
|
||
debouncedRiskRange: [number, number];
|
||
activeTab: 'list' | 'stats';
|
||
}
|
||
|
||
interface TrendPoint { date: string; cases: number; risk: number }
|
||
|
||
// 预警仪表盘的数据层:派生 extendedAlerts/filteredAlerts/riskStats、按需拉取风险趋势、
|
||
// 以及 CSV/JSON 导出辅助。角色/隐私计算保留在 orchestrator,不在此处。
|
||
export function useAlertsData({
|
||
selectedHorizon,
|
||
selectedPriority,
|
||
sortBy,
|
||
debouncedRiskRange,
|
||
activeTab,
|
||
}: UseAlertsDataParams) {
|
||
const alerts = useRiskStore((s) => s.alerts);
|
||
|
||
// Risk-trend data for the 风险统计 tab, fetched on demand
|
||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||
const [trendLoading, setTrendLoading] = useState(false);
|
||
const [trendError, setTrendError] = useState<string | null>(null);
|
||
const [trendLoaded, setTrendLoaded] = useState(false);
|
||
|
||
// Fetch real risk-trend data when the 风险统计 tab is first opened
|
||
useEffect(() => {
|
||
if (activeTab !== 'stats' || trendLoaded) return;
|
||
let cancelled = false;
|
||
setTrendLoading(true);
|
||
setTrendError(null);
|
||
analysisApi
|
||
.getTrend(14)
|
||
.then((res: { dates?: string[]; values?: number[] }) => {
|
||
if (cancelled) return;
|
||
const dates = res?.dates ?? [];
|
||
const values = res?.values ?? [];
|
||
setTrendData(dates.map((date, i) => ({ date, cases: 0, risk: values[i] ?? 0 })));
|
||
setTrendLoaded(true);
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (cancelled) return;
|
||
setTrendError(err instanceof Error ? err.message : '加载风险趋势失败');
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setTrendLoading(false);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [activeTab, trendLoaded]);
|
||
|
||
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
|
||
const now = Date.now();
|
||
return (alerts || []).map((alert) => {
|
||
const forecastDate = new Date(alert.forecast_time);
|
||
const diffDays = Math.ceil((forecastDate.getTime() - now) / (1000 * 60 * 60 * 24));
|
||
const horizon = diffDays <= 1 ? 1 : diffDays <= 3 ? 3 : 7;
|
||
|
||
return {
|
||
...alert,
|
||
latitude: alert.latitude || 0,
|
||
longitude: alert.longitude || 0,
|
||
forecast_horizon: horizon,
|
||
};
|
||
});
|
||
}, [alerts]);
|
||
|
||
const filteredAlerts = useMemo(() => {
|
||
return extendedAlerts
|
||
.filter((alert) => {
|
||
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
|
||
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
|
||
const riskMatch = alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
|
||
return horizonMatch && priorityMatch && riskMatch;
|
||
})
|
||
.sort((a, b) => {
|
||
if (sortBy === 'risk') {
|
||
return b.risk_value - a.risk_value;
|
||
}
|
||
return new Date(b.forecast_time).getTime() - new Date(a.forecast_time).getTime();
|
||
});
|
||
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
|
||
|
||
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
|
||
const riskStats: RiskStats = useMemo(() => {
|
||
// p1/p2 reflect the full (unfiltered) alert set
|
||
let p1 = 0;
|
||
let p2 = 0;
|
||
for (const a of extendedAlerts) {
|
||
if (a.priority === 'P1') p1++;
|
||
else if (a.priority === 'P2') p2++;
|
||
}
|
||
|
||
// Single pass over filteredAlerts: counters + sum + district map
|
||
let high = 0;
|
||
let mediumHigh = 0;
|
||
let medium = 0;
|
||
let sum = 0;
|
||
const byDistrict: Record<string, number> = {};
|
||
for (const a of filteredAlerts) {
|
||
const v = a.risk_value;
|
||
if (v >= 0.8) high++;
|
||
else if (v >= 0.6) mediumHigh++;
|
||
else if (v >= 0.4) medium++;
|
||
sum += v;
|
||
const d = a.region || '未知';
|
||
byDistrict[d] = (byDistrict[d] || 0) + 1;
|
||
}
|
||
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
|
||
|
||
const topDistricts = Object.entries(byDistrict)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5);
|
||
|
||
return { p1, p2, high, mediumHigh, medium, avgRisk, topDistricts };
|
||
}, [extendedAlerts, filteredAlerts]);
|
||
|
||
// Export utilities
|
||
const exportToCsv = useCallback(() => {
|
||
const headers = ['alert_id', 'grid_id', 'region', 'street', 'latitude', 'longitude', 'risk_value', 'priority', 'forecast_horizon', 'reason', 'timestamp'];
|
||
const rows = filteredAlerts.map(a => [
|
||
a.alert_id, a.grid_id, a.region, a.street,
|
||
a.latitude, a.longitude, a.risk_value, a.priority,
|
||
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
|
||
]);
|
||
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `alerts_${new Date().toISOString().split('T')[0]}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}, [filteredAlerts]);
|
||
|
||
const exportToJson = useCallback(() => {
|
||
const json = JSON.stringify(filteredAlerts, null, 2);
|
||
const blob = new Blob([json], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `alerts_${new Date().toISOString().split('T')[0]}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}, [filteredAlerts]);
|
||
|
||
return {
|
||
extendedAlerts,
|
||
filteredAlerts,
|
||
riskStats,
|
||
trendData,
|
||
trendLoading,
|
||
trendError,
|
||
exportToCsv,
|
||
exportToJson,
|
||
};
|
||
}
|