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:
2026-06-05 02:13:49 +08:00
commit fc468464b2
117 changed files with 18282 additions and 0 deletions

View 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">&times;</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}