Files
CA/frontend/src/components/alerts/useAlertsData.ts

164 lines
5.8 KiB
TypeScript
Raw Normal View History

feat: Phase 4 — responsive analysis pages + perf harness + god-component splits 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>
2026-06-21 21:00:15 +08:00
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,
};
}