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,
|
|||
|
|
};
|
|||
|
|
}
|