feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality data with children's respiratory disease incidence across Wuhan. Approach: FastAPI backend serving PostGIS spatial queries, React frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline for multi-day (1d/3d/7d) risk prediction. Changes: - backend/ — FastAPI API with auth (JWT), alerts, risk analysis, geocoded case data, grid statistics, and report endpoints - frontend/ — React dashboard with interactive risk maps, alert monitoring, district comparison charts, and timeline player - models/ — SpatialTemporalGCN model with trained weights and ONNX export for inference - scripts/ — ETL pipeline for weather + medical data, grid generation, feature engineering, training, and daily inference - deploy/ — Docker Compose configs for backend, frontend, and MLflow - docs/ — API docs, deployment guide, user guide, and code review Impact: Enables spatial risk visualization, alert monitoring, and ML-driven health risk forecasting for environmental health teams.
This commit is contained in:
628
frontend/src/pages/AlertsDashboard.tsx
Normal file
628
frontend/src/pages/AlertsDashboard.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { useLodGrid } from '@/hooks/useLodGrid';
|
||||
import { AlertMap } from '@/components/AlertMap';
|
||||
import type { CellInfo } from '@/components/AlertMap';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
|
||||
interface ExtendedAlert {
|
||||
alert_id: string;
|
||||
grid_id: string;
|
||||
region: string;
|
||||
street: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
risk_value: number;
|
||||
risk_level: 'high' | 'medium_high' | 'medium' | 'medium_low' | 'low';
|
||||
priority: 'P1' | 'P2';
|
||||
forecast_horizon: number;
|
||||
forecast_time: string;
|
||||
reason: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const HORIZON_LABELS: Record<number, string> = {
|
||||
1: '1 天后',
|
||||
3: '3 天后',
|
||||
7: '7 天后',
|
||||
};
|
||||
|
||||
export function AlertsDashboard() {
|
||||
const { alerts, isLoading, error, clearError, fetchRiskMap, fetchAlerts } = useRiskStore();
|
||||
const [selectedHorizon, setSelectedHorizon] = useState<number | 'all'>('all');
|
||||
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
|
||||
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
|
||||
const [showMap, setShowMap] = useState(true);
|
||||
const [showAlertMarkers, setShowAlertMarkers] = useState(true);
|
||||
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
|
||||
const [riskRange, setRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
||||
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
|
||||
|
||||
// LOD grid data for cell info lookup (1d/3d/7d risk values)
|
||||
const { grids: lodGrids } = useLodGrid(10, forecastDay);
|
||||
|
||||
// Fetch grids (for map) and alerts (for side panel) on mount
|
||||
useEffect(() => {
|
||||
fetchRiskMap();
|
||||
fetchAlerts();
|
||||
}, [fetchRiskMap, fetchAlerts]);
|
||||
|
||||
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
|
||||
return alerts.map((alert) => {
|
||||
const forecastDate = new Date(alert.forecast_time);
|
||||
const now = new Date();
|
||||
const diffDays = Math.ceil((forecastDate.getTime() - now.getTime()) / (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 >= riskRange[0] && alert.risk_value <= riskRange[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, riskRange]);
|
||||
|
||||
const p1Count = extendedAlerts.filter((a) => a.priority === 'P1').length;
|
||||
const p2Count = extendedAlerts.filter((a) => a.priority === 'P2').length;
|
||||
|
||||
// Risk distribution stats
|
||||
const riskStats = useMemo(() => {
|
||||
const high = filteredAlerts.filter(a => a.risk_value >= 0.8).length;
|
||||
const mediumHigh = filteredAlerts.filter(a => a.risk_value >= 0.6 && a.risk_value < 0.8).length;
|
||||
const medium = filteredAlerts.filter(a => a.risk_value >= 0.4 && a.risk_value < 0.6).length;
|
||||
const avgRisk = filteredAlerts.length > 0
|
||||
? filteredAlerts.reduce((s, a) => s + a.risk_value, 0) / filteredAlerts.length
|
||||
: 0;
|
||||
|
||||
const byDistrict: Record<string, number> = {};
|
||||
for (const a of filteredAlerts) {
|
||||
const d = a.region || '未知';
|
||||
byDistrict[d] = (byDistrict[d] || 0) + 1;
|
||||
}
|
||||
const topDistricts = Object.entries(byDistrict)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5);
|
||||
|
||||
return { high, mediumHigh, medium, avgRisk, topDistricts };
|
||||
}, [filteredAlerts]);
|
||||
|
||||
const selectedAlertData = useMemo(() => {
|
||||
return filteredAlerts.find(a => a.alert_id === selectedAlert);
|
||||
}, [filteredAlerts, selectedAlert]);
|
||||
|
||||
const selectedGridId = useMemo(() => {
|
||||
if (!selectedAlert) return null;
|
||||
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
|
||||
return alert?.grid_id ?? null;
|
||||
}, [filteredAlerts, selectedAlert]);
|
||||
|
||||
const handleGridClick = useCallback((gridId: string) => {
|
||||
const alertForGrid = filteredAlerts.find(a => a.grid_id === gridId);
|
||||
if (alertForGrid) {
|
||||
setSelectedAlert(alertForGrid.alert_id);
|
||||
}
|
||||
}, [filteredAlerts]);
|
||||
|
||||
const handleAlertCardClick = useCallback((id: string) => {
|
||||
setSelectedAlert(id);
|
||||
}, []);
|
||||
|
||||
const clearSelectedAlert = useCallback(() => {
|
||||
setSelectedAlert(null);
|
||||
}, []);
|
||||
|
||||
const handleCellInfo = useCallback((info: CellInfo) => {
|
||||
setCellInfo(info);
|
||||
setSelectedAlert(null); // Close alert modal if open
|
||||
}, []);
|
||||
|
||||
const clearCellInfo = useCallback(() => {
|
||||
setCellInfo(null);
|
||||
}, []);
|
||||
|
||||
// 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(['\uFEFF' + 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 (
|
||||
<div className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={() => { clearError(); fetchRiskMap(); fetchAlerts(); }}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1">风险预警</h1>
|
||||
<p className="text-[12px] text-text-muted">
|
||||
100m网格风险预测 · 多时间尺度预警 · 病例-气象关联分析
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px]">
|
||||
<span className="text-text-muted">共 <span className="font-semibold text-text-primary">{filteredAlerts.length}</span> 条预警</span>
|
||||
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {p1Count}</span>
|
||||
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {p2Count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar Row 1: Forecast + Fullscreen + Export */}
|
||||
<div className="card p-3 mb-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-text-muted">网格预测:</span>
|
||||
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
|
||||
{([1, 3, 7] as const).map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
onClick={() => setForecastDay(day)}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||
forecastDay === day
|
||||
? 'bg-bg-card text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{day}天
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
isFullscreen
|
||||
? 'bg-bg-card text-primary border border-primary'
|
||||
: 'bg-bg-page text-text-secondary border border-border'
|
||||
}`}
|
||||
>
|
||||
{isFullscreen ? '退出全屏' : '全屏'}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
<button
|
||||
onClick={exportToCsv}
|
||||
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
|
||||
>
|
||||
导出CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={exportToJson}
|
||||
className="px-3 py-1.5 text-[12px] font-medium rounded bg-bg-page text-text-secondary border border-border hover:border-primary transition-colors"
|
||||
>
|
||||
导出JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar Row 2: Filters */}
|
||||
<div className="card p-3 mb-4">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-text-muted">预测时效:</span>
|
||||
<div className="flex gap-1">
|
||||
{(['all', 1, 3, 7] as const).map((horizon) => (
|
||||
<button
|
||||
key={horizon}
|
||||
onClick={() => setSelectedHorizon(horizon)}
|
||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
selectedHorizon === horizon
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||
}`}
|
||||
>
|
||||
{horizon === 'all' ? '全部' : HORIZON_LABELS[horizon]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-text-muted">优先级:</span>
|
||||
<div className="flex gap-1">
|
||||
{(['all', 'P1', 'P2'] as const).map((priority) => (
|
||||
<button
|
||||
key={priority}
|
||||
onClick={() => setSelectedPriority(priority)}
|
||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
selectedPriority === priority
|
||||
? priority === 'P1'
|
||||
? 'bg-danger text-white'
|
||||
: priority === 'P2'
|
||||
? 'bg-warning text-white'
|
||||
: 'bg-primary text-white'
|
||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||
}`}
|
||||
>
|
||||
{priority === 'all' ? '全部' : priority}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-text-muted">风险值:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={riskRange[0]}
|
||||
onChange={(e) => setRiskRange([parseFloat(e.target.value) || 0, riskRange[1]])}
|
||||
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<span className="text-[12px] text-text-muted">-</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={riskRange[1]}
|
||||
onChange={(e) => setRiskRange([riskRange[0], parseFloat(e.target.value) || 1])}
|
||||
className="w-16 px-2 py-1.5 text-[12px] border border-border rounded bg-bg-page text-text-primary focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowMap(!showMap)}
|
||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
showMap
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'bg-bg-page text-text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
地图
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAlertMarkers(!showAlertMarkers)}
|
||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
showAlertMarkers
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'bg-bg-page text-text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
预警标记
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGrid(!showGrid)}
|
||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
showGrid
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'bg-bg-page text-text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
网格
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-text-muted">排序:</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setSortBy('risk')}
|
||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
sortBy === 'risk'
|
||||
? 'bg-bg-card text-primary border border-primary'
|
||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||
}`}
|
||||
>
|
||||
风险值
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSortBy('time')}
|
||||
className={`px-3 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
sortBy === 'time'
|
||||
? 'bg-bg-card text-primary border border-primary'
|
||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||
}`}
|
||||
>
|
||||
时间
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk distribution summary */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="card p-3">
|
||||
<div className="text-[11px] text-text-muted mb-1">高风险 (≥0.8)</div>
|
||||
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
|
||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-danger rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.high / filteredAlerts.length) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-3">
|
||||
<div className="text-[11px] text-text-muted mb-1">中高风险 (0.6-0.8)</div>
|
||||
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
|
||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-warning rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.mediumHigh / filteredAlerts.length) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-3">
|
||||
<div className="text-[11px] text-text-muted mb-1">中风险 (0.4-0.6)</div>
|
||||
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
|
||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${filteredAlerts.length > 0 ? (riskStats.medium / filteredAlerts.length) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-3">
|
||||
<div className="text-[11px] text-text-muted mb-1">平均风险</div>
|
||||
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
|
||||
<div className="mt-1.5 text-[10px] text-text-muted">
|
||||
高风险区域: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card p-8 text-center">
|
||||
<div className="text-text-secondary text-[13px]">加载中...</div>
|
||||
</div>
|
||||
) : filteredAlerts.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||
</svg>
|
||||
<div className="text-text-muted text-[13px]">暂无符合条件的预警</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
|
||||
{showMap && (
|
||||
<AlertMap
|
||||
selectedGridId={selectedGridId}
|
||||
onGridClick={handleGridClick}
|
||||
onCellInfo={handleCellInfo}
|
||||
forecastDay={forecastDay}
|
||||
showAlertMarkers={showAlertMarkers}
|
||||
showGrid={showGrid}
|
||||
filteredAlerts={filteredAlerts}
|
||||
riskRange={riskRange}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
)}
|
||||
{!isFullscreen && (
|
||||
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
|
||||
{filteredAlerts.slice(0, 50).map((alert) => (
|
||||
<AlertCard
|
||||
key={alert.alert_id}
|
||||
alert={alert}
|
||||
isSelected={selectedAlert === alert.alert_id}
|
||||
onClick={() => handleAlertCardClick(alert.alert_id)}
|
||||
/>
|
||||
))}
|
||||
{filteredAlerts.length > 50 && (
|
||||
<div className="text-center text-text-muted text-[12px] py-2">
|
||||
还有 {filteredAlerts.length - 50} 条预警未显示
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cell info panel - shown when clicking grid cell without alert */}
|
||||
{cellInfo && !selectedAlertData && (() => {
|
||||
// Find nearest LOD grid cell for multi-day risk display
|
||||
// grids are [lat, lon, risk_1d, risk_3d, risk_7d]
|
||||
let nearest: { lat: number; lon: number; risk_1d: number; risk_3d: number; risk_7d: number } | null = null;
|
||||
let minDist = Infinity;
|
||||
for (const g of lodGrids) {
|
||||
const d = Math.sqrt((g[0] - cellInfo.lat) ** 2 + (g[1] - cellInfo.lon) ** 2);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
nearest = { lat: g[0], lon: g[1], risk_1d: g[2] ?? 0, risk_3d: g[3] ?? 0, risk_7d: g[4] ?? 0 };
|
||||
}
|
||||
}
|
||||
|
||||
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">网格详情</span>
|
||||
<button onClick={clearCellInfo} 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.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>
|
||||
{nearest && (
|
||||
<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]">{(nearest.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]">{(nearest.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]">{(nearest.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>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Alert detail modal */}
|
||||
{selectedAlertData && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={clearSelectedAlert}>
|
||||
<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 ${selectedAlertData.priority === 'P1' ? 'text-danger' : 'text-warning'}`}>
|
||||
{selectedAlertData.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">风险值</span>
|
||||
<span className="font-bold">{Math.round(selectedAlertData.risk_value * 100)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">预测时效</span>
|
||||
<span>{HORIZON_LABELS[selectedAlertData.forecast_horizon]}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">位置</span>
|
||||
<span>{selectedAlertData.region}</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="text-text-muted mb-1">预警原因</div>
|
||||
<div className="text-[12px]">{selectedAlertData.reason}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearSelectedAlert}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: ExtendedAlert;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function AlertCard({ alert, isSelected, onClick }: AlertCardProps) {
|
||||
const isP1 = alert.priority === 'P1';
|
||||
const riskPercent = Math.round(alert.risk_value * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`card overflow-hidden transition-colors cursor-pointer ${
|
||||
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
|
||||
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
||||
{alert.priority}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">
|
||||
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
||||
{riskPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="mb-3">
|
||||
<div className="text-[13px] font-semibold mb-1">
|
||||
{alert.region} - {alert.street}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted">
|
||||
网格:{alert.grid_id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
|
||||
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
|
||||
}`}>
|
||||
{alert.reason}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-text-muted">
|
||||
<span>预测时间:{alert.forecast_time}</span>
|
||||
<span>生成:{alert.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
frontend/src/pages/DistrictComparison.tsx
Normal file
228
frontend/src/pages/DistrictComparison.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { BarChart3, MapPin, Users, Shield } from 'lucide-react';
|
||||
|
||||
const COLORS = ['#DC2626', '#D97706', '#2563EB', '#059669', '#7C3AED', '#0891B2', '#EA580C', '#84CC16'];
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
high: '#DC2626',
|
||||
medium: '#D97706',
|
||||
low: '#059669',
|
||||
};
|
||||
|
||||
export function DistrictComparison() {
|
||||
const { districtData, isLoading, error, clearError, fetchDistricts } = useAnalysisStore();
|
||||
const [metric, setMetric] = useState<'avg_aqi' | 'avg_risk' | 'high_risk_count'>('avg_aqi');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDistricts();
|
||||
}, []);
|
||||
|
||||
const metricConfig = {
|
||||
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },
|
||||
avg_risk: { label: '平均风险', color: '#DC2626', unit: '' },
|
||||
high_risk_count: { label: '高风险数', color: '#D97706', unit: '个' },
|
||||
};
|
||||
|
||||
const sortedData = [...districtData].sort((a, b) => {
|
||||
const aVal = a[metric] as number;
|
||||
const bVal = b[metric] as number;
|
||||
return bVal - aVal;
|
||||
});
|
||||
|
||||
const getRiskLevel = (risk: number) => {
|
||||
if (risk >= 0.7) return 'high';
|
||||
if (risk >= 0.4) return 'medium';
|
||||
return 'low';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={() => { clearError(); fetchDistricts(); }}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-5">
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-primary" />
|
||||
区域对比
|
||||
</h1>
|
||||
<p className="text-[12px] text-text-muted">
|
||||
各行政区空气质量与风险指标对比分析
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-[13px] text-text-secondary">对比指标:</span>
|
||||
<div className="flex gap-1 bg-bg-page p-0.5 rounded">
|
||||
{(Object.keys(metricConfig) as Array<keyof typeof metricConfig>).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setMetric(key)}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||
metric === key
|
||||
? 'bg-bg-card text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{metricConfig[key].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
||||
<span className="text-text-secondary">数据加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
{metricConfig[metric].label} 区域排名
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={380}>
|
||||
<BarChart
|
||||
data={sortedData}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="district"
|
||||
tick={{ fontSize: 12, fill: '#1E293B', fontWeight: 500 }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`${value.toFixed(metric === 'avg_risk' ? 2 : 0)}${metricConfig[metric].unit}`,
|
||||
metricConfig[metric].label,
|
||||
]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey={metric}
|
||||
name={metricConfig[metric].label}
|
||||
radius={[0, 4, 4, 0]}
|
||||
maxBarSize={32}
|
||||
>
|
||||
{sortedData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={metric === 'avg_risk'
|
||||
? RISK_COLORS[getRiskLevel(entry.avg_risk)]
|
||||
: COLORS[index % COLORS.length]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{sortedData.map((district, index) => (
|
||||
<div key={district.district} className="card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-primary" />
|
||||
<span className="text-[14px] font-semibold text-text-primary">
|
||||
{district.district}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[11px] font-semibold px-2 py-0.5 rounded ${
|
||||
district.avg_risk >= 0.7
|
||||
? 'bg-danger-light text-danger'
|
||||
: district.avg_risk >= 0.4
|
||||
? 'bg-warning-light text-warning'
|
||||
: 'bg-success-light text-success'
|
||||
}`}
|
||||
>
|
||||
#{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] text-text-secondary">平均AQI</span>
|
||||
<span className="text-[13px] font-semibold text-text-primary">
|
||||
{district.avg_aqi}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] text-text-secondary">平均风险</span>
|
||||
<span className="text-[13px] font-semibold text-text-primary">
|
||||
{(district.avg_risk * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] text-text-secondary">高风险网格</span>
|
||||
<span className="text-[13px] font-semibold text-danger">
|
||||
{district.high_risk_count}个
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] text-text-secondary flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
人口
|
||||
</span>
|
||||
<span className="text-[13px] font-semibold text-text-primary">
|
||||
{(district.population / 10000).toFixed(0)}万
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[11px] text-text-muted">风险指数</span>
|
||||
<span className="text-[11px] font-medium text-text-secondary">
|
||||
<Shield className="w-3 h-3 inline mr-0.5" />
|
||||
{(district.avg_risk * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-[4px] bg-bg-page rounded overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded transition-all ${
|
||||
district.avg_risk >= 0.7
|
||||
? 'bg-danger'
|
||||
: district.avg_risk >= 0.4
|
||||
? 'bg-warning'
|
||||
: 'bg-success'
|
||||
}`}
|
||||
style={{ width: `${district.avg_risk * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
frontend/src/pages/Insights.tsx
Normal file
198
frontend/src/pages/Insights.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import {
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
XCircle,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
|
||||
const TYPE_CONFIG = {
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
bg: 'bg-warning-light',
|
||||
border: 'border-warning',
|
||||
iconColor: 'text-warning',
|
||||
badge: 'bg-warning text-white',
|
||||
},
|
||||
danger: {
|
||||
icon: XCircle,
|
||||
bg: 'bg-danger-light',
|
||||
border: 'border-danger',
|
||||
iconColor: 'text-danger',
|
||||
badge: 'bg-danger text-white',
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
bg: 'bg-success-light',
|
||||
border: 'border-success',
|
||||
iconColor: 'text-success',
|
||||
badge: 'bg-success text-white',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
bg: 'bg-primary-muted',
|
||||
border: 'border-primary',
|
||||
iconColor: 'text-primary',
|
||||
badge: 'bg-primary text-white',
|
||||
},
|
||||
};
|
||||
|
||||
export function Insights() {
|
||||
const { insights, isLoading, error, clearError, fetchInsights } = useAnalysisStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchInsights();
|
||||
}, []);
|
||||
|
||||
const stats = insights
|
||||
? [
|
||||
{
|
||||
label: '总洞察数',
|
||||
value: insights.total_insights,
|
||||
icon: Lightbulb,
|
||||
color: 'text-primary',
|
||||
bg: 'bg-primary-muted',
|
||||
},
|
||||
{
|
||||
label: '预警',
|
||||
value: insights.warning_count + ((insights as any).danger_count || 0),
|
||||
icon: AlertTriangle,
|
||||
color: 'text-warning',
|
||||
bg: 'bg-warning-light',
|
||||
},
|
||||
{
|
||||
label: '正常',
|
||||
value: insights.success_count,
|
||||
icon: CheckCircle,
|
||||
color: 'text-success',
|
||||
bg: 'bg-success-light',
|
||||
},
|
||||
{
|
||||
label: '信息',
|
||||
value: insights.info_count,
|
||||
icon: Info,
|
||||
color: 'text-primary',
|
||||
bg: 'bg-primary-muted',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={() => { clearError(); fetchInsights(); }}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-5">
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<Lightbulb className="w-5 h-5 text-primary" />
|
||||
智能洞察
|
||||
</h1>
|
||||
<p className="text-[12px] text-text-muted">
|
||||
基于数据分析自动生成的风险洞察与建议
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
||||
<span className="text-text-secondary">数据加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insights && (
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={`w-8 h-8 rounded-lg ${stat.bg} flex items-center justify-center`}>
|
||||
<stat.icon className={`w-4 h-4 ${stat.color}`} />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-display text-[26px] font-bold text-text-primary">
|
||||
{stat.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insights && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{insights.cards.map((card) => {
|
||||
const config = TYPE_CONFIG[card.type];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className={`card p-4 border-l-4 ${config.border} hover:shadow-md transition-shadow`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-lg ${config.bg} flex items-center justify-center`}>
|
||||
<Icon className={`w-4 h-4 ${config.iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[14px] font-semibold text-text-primary">
|
||||
{card.title}
|
||||
</h3>
|
||||
<span className="text-[11px] text-text-muted flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{card.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded ${config.badge}`}>
|
||||
{card.type === 'warning' ? '预警' : card.type === 'danger' ? '紧急' : card.type === 'success' ? '正常' : '信息'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-text-secondary leading-relaxed mb-3">
|
||||
{card.description}
|
||||
</p>
|
||||
|
||||
{card.metric && card.metricValue && (
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-border">
|
||||
<span className="text-[12px] text-text-muted">{card.metric}:</span>
|
||||
<span className={`text-[14px] font-bold flex items-center gap-1 ${
|
||||
card.type === 'warning' || card.type === 'danger'
|
||||
? 'text-danger'
|
||||
: card.type === 'success'
|
||||
? 'text-success'
|
||||
: 'text-primary'
|
||||
}`}>
|
||||
{card.metricValue.includes('+') ? (
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
) : card.metricValue.includes('-') ? (
|
||||
<TrendingDown className="w-3.5 h-3.5" />
|
||||
) : null}
|
||||
{card.metricValue}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!insights && !isLoading && (
|
||||
<div className="card p-8 text-center">
|
||||
<Lightbulb className="w-12 h-12 text-text-muted mx-auto mb-3" />
|
||||
<p className="text-text-secondary">暂无洞察数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/pages/Login.tsx
Normal file
78
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import api from '@/services/api';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: (token: string) => void;
|
||||
}
|
||||
|
||||
export function Login({ onLogin }: LoginProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post('/auth/login', { username, password });
|
||||
const token = res.data.access_token;
|
||||
localStorage.setItem('cbpoa_token', token);
|
||||
onLogin(token);
|
||||
} catch {
|
||||
setError('用户名或密码错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-page flex items-center justify-center">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
|
||||
>
|
||||
<h1 className="text-xl font-semibold text-text-primary mb-6 text-center">
|
||||
CBPOA 登录
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-2 bg-red-50 text-danger text-sm rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block mb-4">
|
||||
<span className="text-text-secondary text-sm">用户名</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block mb-6">
|
||||
<span className="text-text-secondary text-sm">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
frontend/src/pages/MonitoringDashboard.tsx
Normal file
300
frontend/src/pages/MonitoringDashboard.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Building2 } from 'lucide-react';
|
||||
import { useTimelineStore, useMonitoringStore } from '@/stores';
|
||||
import { gridApi } from '@/services/api';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { TimelinePlayer } from '@/components/TimelinePlayer';
|
||||
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
||||
import { CaseLocationMap } from '@/components/CaseLocationMap';
|
||||
|
||||
interface MonitoringDashboardProps {
|
||||
defaultStartDate?: string;
|
||||
defaultEndDate?: string;
|
||||
}
|
||||
|
||||
const WUHAN_DISTRICTS = [
|
||||
'江岸区', '江汉区', '硚口区', '汉阳区', '武昌区',
|
||||
'青山区', '洪山区', '东西湖区', '汉南区', '蔡甸区',
|
||||
'江夏区', '黄陂区', '新洲区',
|
||||
];
|
||||
|
||||
export function MonitoringDashboard({
|
||||
defaultStartDate = '2022-12-01',
|
||||
defaultEndDate = '2024-12-30',
|
||||
}: MonitoringDashboardProps) {
|
||||
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null);
|
||||
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
|
||||
|
||||
const {
|
||||
currentDate,
|
||||
isPlaying,
|
||||
playbackSpeed,
|
||||
setCurrentDate,
|
||||
setPlaying,
|
||||
setPlaybackSpeed,
|
||||
setDateRange,
|
||||
} = useTimelineStore();
|
||||
|
||||
const {
|
||||
districtCases,
|
||||
error,
|
||||
clearError,
|
||||
fetchDistrictCases,
|
||||
isLoading,
|
||||
} = useMonitoringStore();
|
||||
|
||||
useEffect(() => {
|
||||
setDateRange(defaultStartDate, defaultEndDate);
|
||||
setCurrentDate(defaultEndDate);
|
||||
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const loadChartData = useCallback((district?: string) => {
|
||||
const end = new Date(defaultEndDate);
|
||||
const start = new Date(defaultEndDate);
|
||||
start.setDate(start.getDate() - 90);
|
||||
gridApi.getHistoricalAggregated(
|
||||
start.toISOString().split('T')[0],
|
||||
end.toISOString().split('T')[0],
|
||||
'daily',
|
||||
district,
|
||||
).then((data) => {
|
||||
const rows = data.aggregations || [];
|
||||
const dailyCases: Record<string, number> = {};
|
||||
rows.forEach((item: { date: string; total_cases: number }) => {
|
||||
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
|
||||
});
|
||||
setChartData(
|
||||
Object.entries(dailyCases)
|
||||
.map(([date, cases]) => ({ date, cases }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
);
|
||||
}).catch(() => {});
|
||||
fetchDistrictCases();
|
||||
}, [defaultEndDate, fetchDistrictCases]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
loadChartData(selectedDistrict || undefined);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [selectedDistrict, loadChartData]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
const totalCases = chartData.reduce((sum, d) => sum + d.cases, 0);
|
||||
const avgCases = totalCases / chartData.length;
|
||||
const maxDay = chartData.reduce((max, d) => d.cases > max.cases ? d : max, chartData[0]);
|
||||
|
||||
const firstHalf = chartData.slice(0, Math.floor(chartData.length / 2));
|
||||
const secondHalf = chartData.slice(Math.floor(chartData.length / 2));
|
||||
const firstAvg = firstHalf.reduce((s, d) => s + d.cases, 0) / firstHalf.length;
|
||||
const secondAvg = secondHalf.reduce((s, d) => s + d.cases, 0) / secondHalf.length;
|
||||
const trend = secondAvg > firstAvg * 1.1 ? 'up' : secondAvg < firstAvg * 0.9 ? 'down' : 'stable';
|
||||
|
||||
// Case type breakdown from districtCases
|
||||
const totalOutpatient = districtCases.reduce((s, d) => s + d.outpatient, 0);
|
||||
const totalInpatient = districtCases.reduce((s, d) => s + d.inpatient, 0);
|
||||
|
||||
return { totalCases, avgCases: Math.round(avgCases), maxDay, trend, totalOutpatient, totalInpatient };
|
||||
}, [chartData, districtCases]);
|
||||
|
||||
const handleDateChange = useCallback((date: string) => {
|
||||
setCurrentDate(date);
|
||||
}, [setCurrentDate]);
|
||||
|
||||
const handlePlayPause = useCallback((playing: boolean) => {
|
||||
setPlaying(playing);
|
||||
}, [setPlaying]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{error && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
clearError();
|
||||
loadChartData(selectedDistrict || undefined);
|
||||
}}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Top stats bar */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
{stats && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">累计病例</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalCases.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">日均病例</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.avgCases}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{stats.trend === 'up' ? (
|
||||
<TrendingUp className="w-5 h-5 text-red-500" />
|
||||
) : stats.trend === 'down' ? (
|
||||
<TrendingDown className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<Activity className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">趋势</div>
|
||||
<div className={`text-2xl font-bold ${
|
||||
stats.trend === 'up' ? 'text-red-600' :
|
||||
stats.trend === 'down' ? 'text-green-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-8 bg-gray-200" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Stethoscope className="w-5 h-5 text-orange-500" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">门诊</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalOutpatient.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-red-500" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">住院</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.totalInpatient.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* District filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">区域筛选:</span>
|
||||
<select
|
||||
value={selectedDistrict || ''}
|
||||
onChange={(e) => setSelectedDistrict(e.target.value || null)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">全市</option>
|
||||
{WUHAN_DISTRICTS.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content — bottom padding for floating player */}
|
||||
<div className="flex-1 overflow-auto p-6 pb-24">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Case Location Map */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">病例分布地图</h3>
|
||||
<CaseLocationMap height="400px" />
|
||||
</div>
|
||||
|
||||
{/* Statistical Charts */}
|
||||
<StatisticalCharts
|
||||
data={chartData}
|
||||
height={350}
|
||||
showCases={true}
|
||||
showAQI={true}
|
||||
/>
|
||||
|
||||
{/* District breakdown */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">区县病例分布</h3>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const maxTotal = Math.max(...districtCases.map(d => d.total), 1);
|
||||
return districtCases
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.map((d) => {
|
||||
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
|
||||
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
|
||||
const barWidth = (d.total / maxTotal) * 100;
|
||||
return (
|
||||
<div
|
||||
key={d.district}
|
||||
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedDistrict(
|
||||
selectedDistrict === d.district ? null : d.district
|
||||
)}
|
||||
>
|
||||
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
|
||||
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
|
||||
<div
|
||||
className="bg-orange-400 h-full transition-all"
|
||||
style={{ width: `${barWidth * outPct / 100}%` }}
|
||||
title={`门诊: ${d.outpatient.toLocaleString()}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-400 h-full transition-all"
|
||||
style={{ width: `${barWidth * inPct / 100}%` }}
|
||||
title={`住院: ${d.inpatient.toLocaleString()}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
|
||||
{d.total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="w-3 h-3 bg-orange-400 rounded-sm" />门诊
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="w-3 h-3 bg-red-400 rounded-sm" />住院
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline Player */}
|
||||
<TimelinePlayer
|
||||
startDate={defaultStartDate}
|
||||
endDate={defaultEndDate}
|
||||
currentDate={currentDate}
|
||||
onDateChange={handleDateChange}
|
||||
isPlaying={isPlaying}
|
||||
speed={playbackSpeed}
|
||||
onSpeedChange={setPlaybackSpeed}
|
||||
onPlayPause={handlePlayPause}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
frontend/src/pages/TrendAnalysis.tsx
Normal file
262
frontend/src/pages/TrendAnalysis.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
} from 'recharts';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { TrendingUp, Calendar, Activity } from 'lucide-react';
|
||||
|
||||
const POLLUTANT_OPTIONS = [
|
||||
{ key: 'aqi', label: 'AQI', color: '#2563EB', unit: '' },
|
||||
{ key: 'pm25', label: 'PM2.5', color: '#DC2626', unit: 'μg/m³' },
|
||||
{ key: 'pm10', label: 'PM10', color: '#D97706', unit: 'μg/m³' },
|
||||
{ key: 'so2', label: 'SO₂', color: '#7C3AED', unit: 'μg/m³' },
|
||||
{ key: 'no2', label: 'NO₂', color: '#059669', unit: 'μg/m³' },
|
||||
{ key: 'co', label: 'CO', color: '#0891B2', unit: 'mg/m³' },
|
||||
{ key: 'o3', label: 'O₃', color: '#EA580C', unit: 'μg/m³' },
|
||||
];
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ label: '7天', value: 7 },
|
||||
{ label: '14天', value: 14 },
|
||||
{ label: '30天', value: 30 },
|
||||
];
|
||||
|
||||
export function TrendAnalysis() {
|
||||
const { trendData, isLoading, error, clearError, selectedDays, setSelectedDays, fetchTrend } = useAnalysisStore();
|
||||
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrend(selectedDays);
|
||||
}, [selectedDays, fetchTrend]);
|
||||
|
||||
const togglePollutant = (key: string) => {
|
||||
setSelectedPollutants((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
};
|
||||
|
||||
const latestData = trendData[trendData.length - 1];
|
||||
const firstData = trendData[0];
|
||||
|
||||
const getChange = (key: string) => {
|
||||
if (!latestData || !firstData) return 0;
|
||||
const latest = latestData[key as keyof typeof latestData] as number;
|
||||
const first = firstData[key as keyof typeof firstData] as number;
|
||||
if (!first) return 0;
|
||||
return ((latest - first) / first) * 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={() => { clearError(); fetchTrend(selectedDays); }}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-5">
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-primary" />
|
||||
趋势分析
|
||||
</h1>
|
||||
<p className="text-[12px] text-text-muted">
|
||||
空气质量与污染物浓度时间序列分析
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-text-muted" />
|
||||
<span className="text-[13px] text-text-secondary">时间范围:</span>
|
||||
<div className="flex gap-1 bg-bg-page p-0.5 rounded">
|
||||
{DAY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setSelectedDays(opt.value)}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||
selectedDays === opt.value
|
||||
? 'bg-bg-card text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<Activity className="w-4 h-4 text-text-muted" />
|
||||
<span className="text-[13px] text-text-secondary">指标选择:</span>
|
||||
{POLLUTANT_OPTIONS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => togglePollutant(p.key)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[12px] font-medium transition-all ${
|
||||
selectedPollutants.includes(p.key)
|
||||
? 'bg-bg-active text-text-primary'
|
||||
: 'bg-bg-page text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mb-4 text-center py-8 bg-bg-card rounded-lg border border-border">
|
||||
<span className="text-text-secondary">数据加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
污染物浓度趋势
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={360}>
|
||||
<LineChart data={trendData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px', paddingTop: '12px' }}
|
||||
/>
|
||||
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).map(
|
||||
(p) => (
|
||||
<Line
|
||||
key={p.key}
|
||||
type="monotone"
|
||||
dataKey={p.key}
|
||||
name={p.label}
|
||||
stroke={p.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: p.color }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{selectedPollutants.includes('aqi') && (
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
AQI 变化趋势
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<AreaChart data={trendData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="aqiGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#2563EB" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#2563EB" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="aqi"
|
||||
name="AQI"
|
||||
stroke="#2563EB"
|
||||
strokeWidth={2}
|
||||
fill="url(#aqiGradient)"
|
||||
dot={{ r: 3, fill: '#2563EB' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{latestData && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{POLLUTANT_OPTIONS.filter((p) => selectedPollutants.includes(p.key)).slice(0, 4).map((p) => {
|
||||
const value = latestData[p.key as keyof typeof latestData] as number;
|
||||
const change = getChange(p.key);
|
||||
return (
|
||||
<div key={p.key} className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-text-muted uppercase tracking-wide">
|
||||
{p.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-display text-[24px] font-bold text-text-primary mb-1">
|
||||
{typeof value === 'number' ? value.toFixed(p.key === 'co' ? 1 : 0) : value}
|
||||
<span className="text-[12px] font-normal text-text-muted ml-1">
|
||||
{p.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-[11px] font-medium ${
|
||||
change > 0 ? 'text-danger' : change < 0 ? 'text-success' : 'text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{change > 0 ? '↑' : change < 0 ? '↓' : '→'} {Math.abs(change).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user