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:
302
frontend/src/components/AlertMap.tsx
Normal file
302
frontend/src/components/AlertMap.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import L from 'leaflet';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { LodGridLayer } from '@/components/LodGridLayer';
|
||||
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
|
||||
import { useLodGrid } from '@/hooks/useLodGrid';
|
||||
import type { Alert } from '@/types';
|
||||
|
||||
export interface CellInfo {
|
||||
lat: number;
|
||||
lon: number;
|
||||
risk: number;
|
||||
nearestAlertId: string | null;
|
||||
nearestAlertDist: number;
|
||||
}
|
||||
|
||||
interface AlertMapProps {
|
||||
selectedGridId: string | null;
|
||||
onGridClick: (id: string) => void;
|
||||
onCellInfo?: (info: CellInfo) => void;
|
||||
forecastDay?: 1 | 3 | 7;
|
||||
showAlertMarkers?: boolean;
|
||||
showGrid?: boolean;
|
||||
filteredAlerts?: Alert[];
|
||||
riskRange?: [number, number];
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||
|
||||
const RISK_COLORS: [number, number, string][] = [
|
||||
[0.0, 0.2, '#22c55e'],
|
||||
[0.2, 0.4, '#3b82f6'],
|
||||
[0.4, 0.6, '#eab308'],
|
||||
[0.6, 0.8, '#f97316'],
|
||||
[0.8, 1.0, '#ef4444'],
|
||||
];
|
||||
|
||||
function getRiskLabel(value: number): string {
|
||||
if (value >= 0.8) return '高风险';
|
||||
if (value >= 0.6) return '中高';
|
||||
if (value >= 0.4) return '中风险';
|
||||
if (value >= 0.2) return '中低';
|
||||
return '低风险';
|
||||
}
|
||||
|
||||
function AlertMapComponent({
|
||||
selectedGridId,
|
||||
onGridClick,
|
||||
onCellInfo,
|
||||
forecastDay = 1,
|
||||
showAlertMarkers = true,
|
||||
showGrid = true,
|
||||
filteredAlerts = [],
|
||||
riskRange,
|
||||
isFullscreen = false,
|
||||
}: AlertMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const alertLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
|
||||
const clickHandlerRef = useRef(onGridClick);
|
||||
const [currentZoom, setCurrentZoom] = useState(10);
|
||||
|
||||
const grids = useRiskStore((s) => s.grids ?? []);
|
||||
|
||||
// LOD grid data for stats overlay
|
||||
const { count, avgRisk, maxRisk, loading } = useLodGrid(currentZoom, forecastDay);
|
||||
|
||||
useEffect(() => {
|
||||
clickHandlerRef.current = onGridClick;
|
||||
}, [onGridClick]);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
const map = L.map(mapRef.current, {
|
||||
center: WUHAN_CENTER,
|
||||
zoom: 9,
|
||||
zoomControl: true,
|
||||
preferCanvas: true,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
map.on('zoomend', () => {
|
||||
setCurrentZoom(map.getZoom());
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Render alert markers overlay
|
||||
const renderAlertMarkers = useCallback(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
|
||||
if (alertLayerRef.current) {
|
||||
try { map.removeLayer(alertLayerRef.current); } catch { /* ok */ }
|
||||
alertLayerRef.current = null;
|
||||
}
|
||||
|
||||
if (!showAlertMarkers || !filteredAlerts || filteredAlerts.length === 0) return;
|
||||
|
||||
const layer = L.layerGroup();
|
||||
const mapBounds = map.getBounds();
|
||||
const maxMarkers = 500;
|
||||
const step = Math.max(1, Math.floor(filteredAlerts.length / maxMarkers));
|
||||
|
||||
for (let i = 0; i < filteredAlerts.length; i += step) {
|
||||
const alert = filteredAlerts[i];
|
||||
if (!alert.latitude || !alert.longitude) continue;
|
||||
|
||||
// Skip if outside viewport
|
||||
if (
|
||||
alert.latitude < mapBounds.getSouth() ||
|
||||
alert.latitude > mapBounds.getNorth() ||
|
||||
alert.longitude < mapBounds.getWest() ||
|
||||
alert.longitude > mapBounds.getEast()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isP1 = alert.priority === 'P1';
|
||||
const latHalf = 0.00045;
|
||||
const lonHalf = 0.00052;
|
||||
|
||||
const rect = L.rectangle(
|
||||
[
|
||||
[alert.latitude - latHalf, alert.longitude - lonHalf],
|
||||
[alert.latitude + latHalf, alert.longitude + lonHalf],
|
||||
],
|
||||
{
|
||||
fillColor: isP1 ? '#ef4444' : '#f97316',
|
||||
fillOpacity: 0.4,
|
||||
color: isP1 ? '#ef4444' : '#f97316',
|
||||
weight: 2,
|
||||
dashArray: isP1 ? undefined : '4 2',
|
||||
}
|
||||
);
|
||||
|
||||
rect.bindTooltip(
|
||||
`<div style="font-size:12px;">
|
||||
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
|
||||
${alert.region || ''} ${alert.street || ''}
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -5] }
|
||||
);
|
||||
|
||||
rect.on('click', () => {
|
||||
if (alert.grid_id) clickHandlerRef.current(alert.grid_id);
|
||||
});
|
||||
|
||||
rect.addTo(layer);
|
||||
}
|
||||
|
||||
layer.addTo(map);
|
||||
alertLayerRef.current = layer;
|
||||
}, [filteredAlerts, showAlertMarkers]);
|
||||
|
||||
// Re-render alert markers when data changes
|
||||
useEffect(() => {
|
||||
renderAlertMarkers();
|
||||
}, [renderAlertMarkers]);
|
||||
|
||||
// Also re-render on map zoom/pan
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const handleMove = () => renderAlertMarkers();
|
||||
map.on('moveend', handleMove);
|
||||
return () => { map.off('moveend', handleMove); };
|
||||
}, [renderAlertMarkers]);
|
||||
|
||||
// Selected grid highlight
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
|
||||
if (selectedMarkerRef.current) {
|
||||
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
|
||||
selectedMarkerRef.current = null;
|
||||
}
|
||||
|
||||
if (selectedGridId) {
|
||||
let grid = grids.find((g) => g.grid_id === selectedGridId);
|
||||
if (!grid) {
|
||||
const selectedAlertObj = filteredAlerts.find((a) => a.grid_id === selectedGridId);
|
||||
if (selectedAlertObj) {
|
||||
grid = grids.find((g) =>
|
||||
Math.abs(g.latitude - selectedAlertObj.latitude) < 0.001 &&
|
||||
Math.abs(g.longitude - selectedAlertObj.longitude) < 0.001
|
||||
);
|
||||
}
|
||||
}
|
||||
if (grid) {
|
||||
const latHalf = 0.00045;
|
||||
const lonHalf = 0.00052;
|
||||
const marker = L.rectangle(
|
||||
[
|
||||
[grid.latitude - latHalf, grid.longitude - lonHalf],
|
||||
[grid.latitude + latHalf, grid.longitude + lonHalf],
|
||||
],
|
||||
{
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.3,
|
||||
color: '#3b82f6',
|
||||
weight: 3,
|
||||
}
|
||||
).addTo(map);
|
||||
selectedMarkerRef.current = marker;
|
||||
|
||||
map.flyTo([grid.latitude, grid.longitude], Math.max(map.getZoom(), 12), { duration: 0.5 });
|
||||
}
|
||||
}
|
||||
}, [selectedGridId, grids]);
|
||||
|
||||
// Handle LOD grid cell click → find nearest alert
|
||||
const handleCellClick = useCallback(
|
||||
(lat: number, lon: number, risk: number) => {
|
||||
let nearestId: string | null = null;
|
||||
let minDist = Infinity;
|
||||
|
||||
if (filteredAlerts) {
|
||||
for (const a of filteredAlerts) {
|
||||
const d = Math.sqrt((a.latitude - lat) ** 2 + (a.longitude - lon) ** 2);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
nearestId = a.grid_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestId && minDist < 0.01) {
|
||||
clickHandlerRef.current(nearestId);
|
||||
} else if (onCellInfo) {
|
||||
onCellInfo({ lat, lon, risk, nearestAlertId: nearestId, nearestAlertDist: minDist });
|
||||
}
|
||||
},
|
||||
[filteredAlerts, onCellInfo]
|
||||
);
|
||||
|
||||
// Invalidate Leaflet size after fullscreen toggle
|
||||
useEffect(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isFullscreen]);
|
||||
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
|
||||
|
||||
{/* LOD Grid Layer */}
|
||||
<LodGridLayer
|
||||
map={mapInstanceRef.current}
|
||||
forecastDay={forecastDay}
|
||||
visible={showGrid}
|
||||
riskRange={riskRange}
|
||||
onCellClick={handleCellClick}
|
||||
/>
|
||||
|
||||
{/* Stats overlay */}
|
||||
<GridStatsOverlay
|
||||
count={count}
|
||||
avgRisk={avgRisk}
|
||||
maxRisk={maxRisk}
|
||||
loading={loading}
|
||||
forecastDay={forecastDay}
|
||||
/>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级</div>
|
||||
<div className="space-y-1.5">
|
||||
{RISK_COLORS.slice().reverse().map(([min, max, color]) => (
|
||||
<div key={color} className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
|
||||
<span className="text-[11px] text-text-secondary">
|
||||
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AlertMap = AlertMapComponent;
|
||||
116
frontend/src/components/CaseLocationMap.tsx
Normal file
116
frontend/src/components/CaseLocationMap.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
|
||||
interface CaseLocation {
|
||||
case_id: string;
|
||||
case_type: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
district: string;
|
||||
street: string;
|
||||
}
|
||||
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||
|
||||
export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [caseCount, setCaseCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
const map = L.map(mapRef.current, {
|
||||
center: WUHAN_CENTER,
|
||||
zoom: 11,
|
||||
zoomControl: true,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
layerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
// Fetch case locations
|
||||
fetch('/api/geocoded/geocoded?limit=5000')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const cases: CaseLocation[] = data.cases || [];
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
layer.clearLayers();
|
||||
|
||||
// Deduplicate by case_id to avoid overlapping markers
|
||||
const seen = new Set<string>();
|
||||
const unique: CaseLocation[] = [];
|
||||
for (const c of cases) {
|
||||
if (!seen.has(c.case_id)) {
|
||||
seen.add(c.case_id);
|
||||
unique.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of unique) {
|
||||
if (!c.latitude || !c.longitude) continue;
|
||||
|
||||
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
|
||||
const marker = L.circleMarker([c.latitude, c.longitude], {
|
||||
radius: 3,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.6,
|
||||
color: color,
|
||||
weight: 1,
|
||||
});
|
||||
|
||||
marker.bindTooltip(
|
||||
`<div style="font-size:12px">
|
||||
<strong>${c.district}</strong> ${c.street}<br/>
|
||||
类型: ${c.case_type === 'inpatient' ? '住院' : '门诊'}
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -4] }
|
||||
);
|
||||
|
||||
marker.addTo(layer);
|
||||
}
|
||||
|
||||
setCaseCount(unique.length);
|
||||
setIsLoading(false);
|
||||
|
||||
// Fit bounds to case locations
|
||||
if (unique.length > 0) {
|
||||
const bounds = L.latLngBounds(unique.map((c) => [c.latitude, c.longitude]));
|
||||
map.fitBounds(bounds, { padding: [30, 30] });
|
||||
}
|
||||
})
|
||||
.catch(() => setIsLoading(false));
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
|
||||
<div className="text-sm text-gray-500">加载病例位置...</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
||||
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span> 个病例位置
|
||||
<span className="ml-2 text-red-500">● 住院</span>
|
||||
<span className="ml-1 text-blue-500">● 门诊</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
376
frontend/src/components/CaseMap.tsx
Normal file
376
frontend/src/components/CaseMap.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { caseApi } from '@/services/api';
|
||||
import type { CaseGrid, GeocodedCase } from '@/types';
|
||||
|
||||
interface CaseMapProps {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'grid' | 'point';
|
||||
|
||||
// Grid is 100m x 100m at Wuhan latitude (~30.5°N)
|
||||
const GRID_HALF_SIZE_LAT = 0.00045; // ~50m in degrees
|
||||
const GRID_HALF_SIZE_LON = 0.00052; // ~50m in degrees
|
||||
|
||||
function getGridBounds(g: { latitude: number; longitude: number }) {
|
||||
if (typeof g.latitude !== 'number' || typeof g.longitude !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
lat_min: g.latitude - GRID_HALF_SIZE_LAT,
|
||||
lat_max: g.latitude + GRID_HALF_SIZE_LAT,
|
||||
lon_min: g.longitude - GRID_HALF_SIZE_LON,
|
||||
lon_max: g.longitude + GRID_HALF_SIZE_LON,
|
||||
};
|
||||
}
|
||||
|
||||
const RISK_COLORS = {
|
||||
high: '#ff4444',
|
||||
medium: '#ffaa44',
|
||||
low: '#44bb44',
|
||||
};
|
||||
|
||||
function getRiskColor(riskIndex: number): string {
|
||||
if (riskIndex >= 0.67) return RISK_COLORS.high;
|
||||
if (riskIndex >= 0.33) return RISK_COLORS.medium;
|
||||
return RISK_COLORS.low;
|
||||
}
|
||||
|
||||
function getRiskLabel(riskIndex: number): string {
|
||||
if (riskIndex >= 0.67) return '高风险';
|
||||
if (riskIndex >= 0.33) return '中风险';
|
||||
return '低风险';
|
||||
}
|
||||
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
function CaseMapComponent({ height = '480px' }: CaseMapProps) {
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const gridLayerRef = useRef<any>(null);
|
||||
const pointLayerRef = useRef<any>(null);
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [grids, setGrids] = useState<CaseGrid[]>([]);
|
||||
const [cases, setCases] = useState<GeocodedCase[]>([]);
|
||||
const [totalCases, setTotalCases] = useState(0);
|
||||
const [gridCount, setGridCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchData() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [gridRes, geoRes] = await Promise.all([
|
||||
caseApi.getGrid(),
|
||||
caseApi.getGeocoded(5000),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setGrids(gridRes.grids || []);
|
||||
setGridCount(gridRes.total_count || 0);
|
||||
setTotalCases(gridRes.total_cases || 0);
|
||||
setCases(geoRes.cases || []);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapDivRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(mapDivRef.current, {
|
||||
center: [30.59, 114.31],
|
||||
zoom: 11,
|
||||
zoomControl: true,
|
||||
preferCanvas: false,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
const handleZoom = debounce(() => renderLayers(), 150);
|
||||
const handleMove = debounce(() => renderLayers(), 150);
|
||||
|
||||
map.on('zoomend', handleZoom);
|
||||
map.on('moveend', handleMove);
|
||||
|
||||
return () => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
gridLayerRef.current = null;
|
||||
pointLayerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
renderLayers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [grids, cases, viewMode]);
|
||||
|
||||
const renderLayers = useCallback(() => {
|
||||
if (!mapRef.current) return;
|
||||
const map = mapRef.current;
|
||||
|
||||
if (gridLayerRef.current) {
|
||||
try { map.removeLayer(gridLayerRef.current); } catch { /* silent */ }
|
||||
gridLayerRef.current = null;
|
||||
}
|
||||
if (pointLayerRef.current) {
|
||||
try { map.removeLayer(pointLayerRef.current); } catch { /* silent */ }
|
||||
pointLayerRef.current = null;
|
||||
}
|
||||
|
||||
const zoom = map.getZoom();
|
||||
|
||||
if (viewMode === 'grid') {
|
||||
const gridLayer = L.layerGroup();
|
||||
const bounds = map.getBounds();
|
||||
|
||||
let rendered = 0;
|
||||
const maxRender = 5000;
|
||||
|
||||
for (const g of grids) {
|
||||
if (rendered >= maxRender) break;
|
||||
|
||||
const gBounds = getGridBounds(g);
|
||||
if (!gBounds) continue;
|
||||
|
||||
if (
|
||||
gBounds.lat_max < bounds.getSouth() ||
|
||||
gBounds.lat_min > bounds.getNorth() ||
|
||||
gBounds.lon_max < bounds.getWest() ||
|
||||
gBounds.lon_min > bounds.getEast()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const color = getRiskColor(g.risk_index);
|
||||
const opacity = 0.5 + g.risk_index * 0.35;
|
||||
|
||||
const rect = L.rectangle(
|
||||
[[gBounds.lat_min, gBounds.lon_min], [gBounds.lat_max, gBounds.lon_max]],
|
||||
{
|
||||
fillColor: color,
|
||||
fillOpacity: opacity,
|
||||
color: color,
|
||||
weight: zoom >= 14 ? 1 : 0,
|
||||
opacity: 0.3,
|
||||
}
|
||||
);
|
||||
|
||||
rect.bindTooltip(
|
||||
`<div style="font-size: 12px;">
|
||||
<strong>网格 ${g.grid_id}</strong><br/>
|
||||
病例数: ${g.total_cases.toLocaleString()}<br/>
|
||||
风险指数: ${(g.risk_index * 100).toFixed(1)}%<br/>
|
||||
<span style="color: ${color}; font-weight: 600;">${getRiskLabel(g.risk_index)}</span>
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -5] }
|
||||
);
|
||||
|
||||
rect.addTo(gridLayer);
|
||||
rendered++;
|
||||
}
|
||||
|
||||
gridLayer.addTo(map);
|
||||
gridLayerRef.current = gridLayer;
|
||||
} else {
|
||||
const pointLayer = L.layerGroup();
|
||||
const bounds = map.getBounds();
|
||||
|
||||
const caseColor = (c: GeocodedCase) =>
|
||||
c.case_type === 'inpatient' ? '#DC2626' : '#2563EB';
|
||||
|
||||
// Viewport culling + maxRender to avoid Leaflet canvas intersects bug
|
||||
const maxRender = 500;
|
||||
let rendered = 0;
|
||||
|
||||
for (const c of cases) {
|
||||
if (rendered >= maxRender) break;
|
||||
if (typeof c.latitude !== 'number' || typeof c.longitude !== 'number') continue;
|
||||
|
||||
// Viewport culling - skip points outside visible area
|
||||
if (
|
||||
c.latitude < bounds.getSouth() ||
|
||||
c.latitude > bounds.getNorth() ||
|
||||
c.longitude < bounds.getWest() ||
|
||||
c.longitude > bounds.getEast()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use tiny rectangles instead of circleMarker to avoid Leaflet 1.9.4 intersects bug
|
||||
const size = zoom >= 14 ? 0.00005 : zoom >= 12 ? 0.00003 : 0.00002;
|
||||
const rect = L.rectangle(
|
||||
[[c.latitude - size, c.longitude - size], [c.latitude + size, c.longitude + size]],
|
||||
{
|
||||
fillColor: caseColor(c),
|
||||
fillOpacity: 0.8,
|
||||
color: '#FFFFFF',
|
||||
weight: 0.5,
|
||||
}
|
||||
);
|
||||
|
||||
rect.bindTooltip(
|
||||
`<div style="font-size: 12px;">
|
||||
<strong>${c.case_type === 'inpatient' ? '住院' : '门诊'}病例</strong><br/>
|
||||
坐标:${c.latitude.toFixed(5)}, ${c.longitude.toFixed(5)}
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -5] }
|
||||
);
|
||||
|
||||
rect.addTo(pointLayer);
|
||||
rendered++;
|
||||
}
|
||||
|
||||
pointLayer.addTo(map);
|
||||
pointLayerRef.current = pointLayer;
|
||||
}
|
||||
}, [grids, cases, viewMode]);
|
||||
|
||||
const handleToggle = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-light">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
|
||||
</svg>
|
||||
<span className="font-medium text-[14px]">病例空间分布</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
|
||||
<button
|
||||
onClick={() => handleToggle('grid')}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-bg-card text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
网格视图
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle('point')}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||
viewMode === 'point'
|
||||
? 'bg-bg-card text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
点分布
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-text-muted">
|
||||
{viewMode === 'grid' ? '100×100m 网格' : '个体病例定位'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ height }}>
|
||||
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
|
||||
|
||||
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
|
||||
{viewMode === 'grid' ? (
|
||||
<>
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.high }} />
|
||||
<span className="text-[11px] text-text-secondary">高风险 (>67%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.medium }} />
|
||||
<span className="text-[11px] text-text-secondary">中风险 (33-67%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.low }} />
|
||||
<span className="text-[11px] text-text-secondary">低风险 (<33%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">病例类型</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#DC2626' }} />
|
||||
<span className="text-[11px] text-text-secondary">住院病例</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#2563EB' }} />
|
||||
<span className="text-[11px] text-text-secondary">门诊病例</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 left-4 space-y-2 z-[1000]">
|
||||
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
|
||||
<div className="text-[11px] text-text-secondary">
|
||||
{isLoading ? (
|
||||
<span className="text-text-muted">数据加载中...</span>
|
||||
) : error ? (
|
||||
<span className="text-danger">加载失败: {error}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-text-primary">{totalCases.toLocaleString()}</span> 例病例
|
||||
<span className="mx-2 text-border">|</span>
|
||||
{viewMode === 'grid' ? (
|
||||
<>
|
||||
<span className="font-semibold text-text-primary">{gridCount.toLocaleString()}</span> 个网格
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-text-primary">{cases.length.toLocaleString()}</span> 个定位点
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isLoading && !error && viewMode === 'grid' && (
|
||||
<div className="bg-success/10 backdrop-blur rounded-lg border border-success/30 shadow-sm px-3 py-2">
|
||||
<div className="text-[11px] text-success font-medium">
|
||||
基于真实病例地理编码数据
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CaseMap = memo(CaseMapComponent);
|
||||
51
frontend/src/components/DistributionChart.tsx
Normal file
51
frontend/src/components/DistributionChart.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
interface DistributionChartProps {
|
||||
distribution: {
|
||||
high: number;
|
||||
medium_high: number;
|
||||
medium: number;
|
||||
medium_low: number;
|
||||
low: number;
|
||||
};
|
||||
}
|
||||
|
||||
const LEVELS = [
|
||||
{ key: 'high', label: '高风险 (86-100%)', color: 'bg-danger' },
|
||||
{ key: 'medium_high', label: '中高风险 (71-85%)', color: 'bg-[#FB923C]' },
|
||||
{ key: 'medium', label: '中风险 (51-70%)', color: 'bg-warning' },
|
||||
{ key: 'medium_low', label: '中低风险 (31-50%)', color: 'bg-[#7DD3FC]' },
|
||||
{ key: 'low', label: '低风险 (0-30%)', color: 'bg-success' },
|
||||
];
|
||||
|
||||
export function DistributionChart({ distribution }: DistributionChartProps) {
|
||||
const total = Object.values(distribution).reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return (
|
||||
<div className="card p-4 h-fit">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
风险等级分布
|
||||
</div>
|
||||
|
||||
{LEVELS.map((level) => {
|
||||
const value = distribution[level.key as keyof typeof distribution];
|
||||
const percentage = total > 0 ? (value / total) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={level.key} className="mb-3.5 last:mb-0">
|
||||
<div className="flex justify-between mb-1.5">
|
||||
<span className="text-[12px] text-text-secondary">{level.label}</span>
|
||||
<span className="text-[12px] font-semibold">
|
||||
{value} ({percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-[5px] bg-bg-page rounded overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded ${level.color}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ErrorBanner.tsx
Normal file
35
frontend/src/components/ErrorBanner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AlertCircle, RefreshCw, X } from 'lucide-react';
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: string;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ error, onRetry, onDismiss }: ErrorBannerProps) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-lg border border-danger/20 bg-danger-light px-4 py-3">
|
||||
<AlertCircle className="h-5 w-5 shrink-0 text-danger" />
|
||||
<span className="flex-1 text-[13px] text-danger">{error}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-1 rounded px-2.5 py-1 text-[12px] font-medium text-danger transition-colors hover:bg-danger/10"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="rounded p-1 text-danger transition-colors hover:bg-danger/10"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/GridStatsOverlay.tsx
Normal file
30
frontend/src/components/GridStatsOverlay.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
interface GridStatsOverlayProps {
|
||||
count: number;
|
||||
avgRisk: number;
|
||||
maxRisk: number;
|
||||
loading?: boolean;
|
||||
forecastDay?: 1 | 3 | 7;
|
||||
}
|
||||
|
||||
export function GridStatsOverlay({ count, avgRisk, maxRisk, loading, forecastDay }: GridStatsOverlayProps) {
|
||||
return (
|
||||
<div className="absolute top-3 left-3 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-3 py-2">
|
||||
<div className="text-[11px] text-text-secondary space-y-1">
|
||||
{forecastDay && (
|
||||
<div className="font-semibold text-text-primary mb-1">
|
||||
{forecastDay}天预测 · LOD网格
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
网格数:<span className="font-semibold text-text-primary">{loading ? '...' : count.toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
平均风险:<span className="font-semibold text-text-primary">{loading ? '...' : `${(avgRisk * 100).toFixed(1)}%`}</span>
|
||||
</div>
|
||||
<div>
|
||||
最大风险:<span className="font-semibold text-text-primary">{loading ? '...' : `${(maxRisk * 100).toFixed(1)}%`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
358
frontend/src/components/LodGridLayer.tsx
Normal file
358
frontend/src/components/LodGridLayer.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import { useLodGrid, type MapBounds } from '@/hooks/useLodGrid';
|
||||
|
||||
const RISK_COLORS: [number, number, string][] = [
|
||||
[0.0, 0.2, '#22c55e'],
|
||||
[0.2, 0.4, '#3b82f6'],
|
||||
[0.4, 0.6, '#eab308'],
|
||||
[0.6, 0.8, '#f97316'],
|
||||
[0.8, 1.0, '#ef4444'],
|
||||
];
|
||||
|
||||
// Pre-computed color buckets for fillStyle caching
|
||||
const COLOR_BUCKETS: Record<string, { full: string; dim: string }> = {};
|
||||
for (const [, , color] of RISK_COLORS) {
|
||||
COLOR_BUCKETS[color] = { full: color, dim: color + '14' };
|
||||
}
|
||||
|
||||
function getRiskColor(value: number): string {
|
||||
for (const [min, max, color] of RISK_COLORS) {
|
||||
if (value >= min && value <= max) return color;
|
||||
}
|
||||
return '#22c55e';
|
||||
}
|
||||
|
||||
// 100m grid step in degrees
|
||||
const LAT_STEP = 0.0009;
|
||||
const LON_STEP = 0.001046;
|
||||
|
||||
// Mercator helpers (avoid per-cell latLngToContainerPoint)
|
||||
function latToMercY(lat: number): number {
|
||||
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
|
||||
}
|
||||
|
||||
function lonToMercX(lon: number): number {
|
||||
return ((lon + 180) / 360) * 256;
|
||||
}
|
||||
|
||||
interface LodGridLayerProps {
|
||||
map: L.Map | null;
|
||||
forecastDay: 1 | 3 | 7;
|
||||
visible?: boolean;
|
||||
riskRange?: [number, number];
|
||||
onCellClick?: (lat: number, lon: number, risk: number) => void;
|
||||
}
|
||||
|
||||
export function LodGridLayer({
|
||||
map,
|
||||
forecastDay,
|
||||
visible = true,
|
||||
riskRange,
|
||||
onCellClick,
|
||||
}: LodGridLayerProps) {
|
||||
const [zoom, setZoom] = useState(map?.getZoom() ?? 10);
|
||||
const [mapBounds, setMapBounds] = useState<MapBounds | undefined>();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const paneRef = useRef<HTMLElement | null>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const clickCallbackRef = useRef(onCellClick);
|
||||
const gridsRef = useRef<number[][]>([]);
|
||||
const forecastDayRef = useRef(forecastDay);
|
||||
const riskRangeRef = useRef(riskRange);
|
||||
const visibleRef = useRef(visible);
|
||||
const drawnOriginRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => { clickCallbackRef.current = onCellClick; }, [onCellClick]);
|
||||
useEffect(() => { forecastDayRef.current = forecastDay; }, [forecastDay]);
|
||||
useEffect(() => { riskRangeRef.current = riskRange; }, [riskRange]);
|
||||
useEffect(() => { visibleRef.current = visible; }, [visible]);
|
||||
|
||||
// Track map bounds and zoom
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const update = () => {
|
||||
const b = map.getBounds();
|
||||
setMapBounds({
|
||||
min_lat: b.getSouth(),
|
||||
max_lat: b.getNorth(),
|
||||
min_lon: b.getWest(),
|
||||
max_lon: b.getEast(),
|
||||
});
|
||||
setZoom(map.getZoom());
|
||||
};
|
||||
update();
|
||||
map.on('moveend', update);
|
||||
map.on('zoomend', update);
|
||||
return () => {
|
||||
map.off('moveend', update);
|
||||
map.off('zoomend', update);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
const { grids } = useLodGrid(zoom, forecastDay, mapBounds);
|
||||
|
||||
// Update gridsRef only when we have actual data (preserve stale data during loading)
|
||||
useEffect(() => {
|
||||
if (grids.length > 0) {
|
||||
gridsRef.current = grids;
|
||||
}
|
||||
}, [grids]);
|
||||
|
||||
// Create canvas overlay pane and attach to map
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const pane = map.createPane('lod-grid-pane');
|
||||
pane.style.zIndex = '450';
|
||||
pane.style.pointerEvents = 'none';
|
||||
paneRef.current = pane;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
pane.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
// Handle map clicks for grid cell selection
|
||||
const handleMapClick = (e: L.LeafletMouseEvent) => {
|
||||
if (!clickCallbackRef.current) return;
|
||||
const currentGrids = gridsRef.current;
|
||||
if (!currentGrids || currentGrids.length === 0) return;
|
||||
|
||||
const { lat, lng } = e.latlng;
|
||||
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
|
||||
let nearestDist = Infinity;
|
||||
let nearestRisk = 0;
|
||||
let nearestLat = 0;
|
||||
let nearestLon = 0;
|
||||
|
||||
for (const g of currentGrids) {
|
||||
const d = Math.sqrt((g[0] - lat) ** 2 + (g[1] - lng) ** 2);
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
nearestRisk = g[riskIdx] ?? 0;
|
||||
nearestLat = g[0];
|
||||
nearestLon = g[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestDist < 0.01) {
|
||||
clickCallbackRef.current(nearestLat, nearestLon, nearestRisk);
|
||||
}
|
||||
};
|
||||
|
||||
map.on('click', handleMapClick);
|
||||
|
||||
// Full redraw function
|
||||
const redraw = () => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
animFrameRef.current = requestAnimationFrame(() => {
|
||||
const container = map.getContainer();
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Reset drift transform after redraw
|
||||
canvas.style.transform = '';
|
||||
drawnOriginRef.current = null;
|
||||
|
||||
if (!visibleRef.current) return;
|
||||
|
||||
const currentGrids = gridsRef.current;
|
||||
if (!currentGrids || currentGrids.length === 0) return;
|
||||
|
||||
const z = map.getZoom();
|
||||
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
|
||||
const range = riskRangeRef.current;
|
||||
const mapBounds = map.getBounds();
|
||||
const south = mapBounds.getSouth();
|
||||
const north = mapBounds.getNorth();
|
||||
const west = mapBounds.getWest();
|
||||
const east = mapBounds.getEast();
|
||||
|
||||
// Use Mercator math for pixel conversion (avoids per-cell latLngToContainerPoint)
|
||||
const scale = 2 ** z;
|
||||
const origin = map.getPixelOrigin();
|
||||
drawnOriginRef.current = { x: origin.x, y: origin.y };
|
||||
|
||||
// Pre-compute Mercator Y steps for cell size at this zoom
|
||||
const halfLat = LAT_STEP / 2;
|
||||
const halfLon = LON_STEP / 2;
|
||||
|
||||
// Group cells by color to minimize fillStyle changes
|
||||
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
|
||||
|
||||
// Viewport culling margin in degrees
|
||||
const margin = 0.02;
|
||||
const isHighZoom = z >= 12;
|
||||
const isMedZoom = z >= 10;
|
||||
|
||||
for (const g of currentGrids) {
|
||||
const lat = g[0];
|
||||
const lon = g[1];
|
||||
const risk = g[riskIdx] ?? 0;
|
||||
|
||||
// Pre-filter: skip zero-risk cells (majority of cells at most zooms)
|
||||
if (risk === 0) continue;
|
||||
|
||||
// Viewport culling
|
||||
if (lat < south - margin || lat > north + margin ||
|
||||
lon < west - margin || lon > east + margin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Risk range filter
|
||||
let alpha = 0.85;
|
||||
if (range) {
|
||||
if (risk < range[0]) {
|
||||
alpha = 0.08;
|
||||
} else if (risk > range[1]) {
|
||||
alpha = 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
const color = getRiskColor(risk);
|
||||
|
||||
if (isHighZoom) {
|
||||
// Compute cell rectangle using Mercator math
|
||||
const lx = lonToMercX(lon - halfLon) * scale - origin.x;
|
||||
const rx = lonToMercX(lon + halfLon) * scale - origin.x;
|
||||
const ty = latToMercY(lat + halfLat) * scale - origin.y;
|
||||
const by = latToMercY(lat - halfLat) * scale - origin.y;
|
||||
const cellW = rx - lx;
|
||||
const cellH = by - ty;
|
||||
|
||||
if (cellW < 0.5 || cellH < 0.5) continue;
|
||||
|
||||
// Group by color+alpha for batch rendering
|
||||
const key = alpha < 1 ? `${color}_${alpha}` : color;
|
||||
if (!colorGroups[key]) colorGroups[key] = [];
|
||||
colorGroups[key].push({ x: lx, y: ty, w: cellW, h: cellH });
|
||||
} else {
|
||||
// Medium/low zoom: compute center pixel
|
||||
const cx = lonToMercX(lon) * scale - origin.x;
|
||||
const cy = latToMercY(lat) * scale - origin.y;
|
||||
|
||||
const key = alpha < 1 ? `${color}_${alpha}` : color;
|
||||
if (!colorGroups[key]) colorGroups[key] = [];
|
||||
colorGroups[key].push({ x: cx, y: cy, w: 0, h: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Render grouped cells
|
||||
for (const [key, cells] of Object.entries(colorGroups)) {
|
||||
const parts = key.split('_');
|
||||
const color = parts[0];
|
||||
const alpha = parts.length > 1 ? parseFloat(parts[1]) : 1;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = color;
|
||||
|
||||
if (isHighZoom) {
|
||||
for (const c of cells) {
|
||||
ctx.fillRect(c.x, c.y, c.w, c.h);
|
||||
}
|
||||
// Stroke only at high enough cell sizes
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (const c of cells) {
|
||||
if (c.w > 2 && c.h > 2) {
|
||||
ctx.strokeRect(c.x, c.y, c.w, c.h);
|
||||
}
|
||||
}
|
||||
} else if (isMedZoom) {
|
||||
const size = Math.max(2, Math.min(6, z - 7));
|
||||
const halfSize = size / 2;
|
||||
for (const c of cells) {
|
||||
ctx.fillRect(c.x - halfSize, c.y - halfSize, size, size);
|
||||
}
|
||||
} else {
|
||||
const radius = Math.max(1, Math.min(3, z - 5));
|
||||
for (const c of cells) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
};
|
||||
|
||||
// During pan: apply CSS transform to track tile movement (fixes drift)
|
||||
const onMove = () => {
|
||||
const drawn = drawnOriginRef.current;
|
||||
if (!drawn) {
|
||||
// No previous draw yet, just request a redraw
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
const current = map.getPixelOrigin();
|
||||
const dx = drawn.x - current.x;
|
||||
const dy = drawn.y - current.y;
|
||||
canvas.style.transform = `translate(${dx}px, ${dy}px)`;
|
||||
};
|
||||
|
||||
// On moveend/zoomend: reset transform and do full redraw
|
||||
const onMoveEnd = () => {
|
||||
canvas.style.transform = '';
|
||||
drawnOriginRef.current = null;
|
||||
redraw();
|
||||
};
|
||||
|
||||
const onResize = () => redraw();
|
||||
|
||||
map.on('move', onMove);
|
||||
map.on('moveend', onMoveEnd);
|
||||
map.on('zoomend', onMoveEnd);
|
||||
map.on('resize', onResize);
|
||||
|
||||
// Store redraw reference for external triggers
|
||||
(canvas as any).__lodRedraw = redraw;
|
||||
|
||||
// Initial draw
|
||||
redraw();
|
||||
|
||||
return () => {
|
||||
map.off('move', onMove);
|
||||
map.off('moveend', onMoveEnd);
|
||||
map.off('zoomend', onMoveEnd);
|
||||
map.off('resize', onResize);
|
||||
map.off('click', handleMapClick);
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
pane.removeChild(canvas);
|
||||
if (pane.parentNode) pane.parentNode.removeChild(pane);
|
||||
canvasRef.current = null;
|
||||
paneRef.current = null;
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Trigger redraw when data changes
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && (canvas as any).__lodRedraw) {
|
||||
(canvas as any).__lodRedraw();
|
||||
}
|
||||
}, [grids, forecastDay, riskRange, visible]);
|
||||
|
||||
return null;
|
||||
}
|
||||
314
frontend/src/components/RiskMap.tsx
Normal file
314
frontend/src/components/RiskMap.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { GridRisk, GridDetail, ForecastDay } from '@/types';
|
||||
|
||||
interface RiskMapProps {
|
||||
grids: GridRisk[];
|
||||
selectedGridId: string | null;
|
||||
selectedGrid: GridDetail | null;
|
||||
forecastDay: ForecastDay;
|
||||
onGridSelect: (gridId: string) => void;
|
||||
onClosePanel: () => void;
|
||||
onFullscreen: () => void;
|
||||
onForecastChange: (day: ForecastDay) => void;
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
low: '#22c55e',
|
||||
medium_low: '#3b82f6',
|
||||
medium: '#eab308',
|
||||
medium_high: '#f97316',
|
||||
high: '#ef4444',
|
||||
};
|
||||
|
||||
const RISK_LABELS: Record<string, string> = {
|
||||
low: '低风险',
|
||||
medium_low: '中低',
|
||||
medium: '中风险',
|
||||
medium_high: '中高',
|
||||
high: '高风险',
|
||||
};
|
||||
|
||||
const WUHAN_BOUNDS = {
|
||||
minLat: 29.97,
|
||||
maxLat: 31.37,
|
||||
minLon: 113.69,
|
||||
maxLon: 115.07,
|
||||
};
|
||||
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
function RiskMapComponent(props: RiskMapProps) {
|
||||
const {
|
||||
grids,
|
||||
selectedGrid,
|
||||
forecastDay,
|
||||
onGridSelect,
|
||||
onClosePanel,
|
||||
onFullscreen,
|
||||
onForecastChange,
|
||||
isFullscreen,
|
||||
} = props;
|
||||
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const gridLayerRef = useRef<any>(null);
|
||||
const zoomRef = useRef(9);
|
||||
const callbacksRef = useRef({ onGridSelect, onClosePanel, onFullscreen, onForecastChange });
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
|
||||
});
|
||||
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';
|
||||
|
||||
const gridMap = useMemo(() => {
|
||||
const map = new Map<string, GridRisk>();
|
||||
grids.forEach((g) => {
|
||||
const key = `${g.latitude.toFixed(4)}-${g.longitude.toFixed(4)}`;
|
||||
map.set(key, g);
|
||||
});
|
||||
return map;
|
||||
}, [grids]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapDivRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(mapDivRef.current, {
|
||||
center: [(WUHAN_BOUNDS.minLat + WUHAN_BOUNDS.maxLat) / 2, (WUHAN_BOUNDS.minLon + WUHAN_BOUNDS.maxLon) / 2],
|
||||
zoom: 9,
|
||||
zoomControl: true,
|
||||
preferCanvas: true,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
const handleZoom = debounce(() => {
|
||||
zoomRef.current = map.getZoom();
|
||||
renderGridLayer();
|
||||
}, 150);
|
||||
|
||||
const handleMove = debounce(() => {
|
||||
renderGridLayer();
|
||||
}, 150);
|
||||
|
||||
map.on('zoomend', handleZoom);
|
||||
map.on('moveend', handleMove);
|
||||
|
||||
function renderGridLayer() {
|
||||
if (!mapRef.current) return;
|
||||
const map = mapRef.current;
|
||||
|
||||
if (gridLayerRef.current) {
|
||||
try {
|
||||
map.removeLayer(gridLayerRef.current);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
gridLayerRef.current = null;
|
||||
}
|
||||
|
||||
const zoom = map.getZoom();
|
||||
let cellSize: number;
|
||||
let step: number;
|
||||
|
||||
if (zoom <= 8) {
|
||||
cellSize = 0.1;
|
||||
step = 10;
|
||||
} else if (zoom <= 10) {
|
||||
cellSize = 0.025;
|
||||
step = 4;
|
||||
} else if (zoom <= 12) {
|
||||
cellSize = 0.01;
|
||||
step = 2;
|
||||
} else {
|
||||
cellSize = 0.001;
|
||||
step = 1;
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const minLat = Math.max(bounds.getSouth(), WUHAN_BOUNDS.minLat);
|
||||
const maxLat = Math.min(bounds.getNorth(), WUHAN_BOUNDS.maxLat);
|
||||
const minLon = Math.max(bounds.getWest(), WUHAN_BOUNDS.minLon);
|
||||
const maxLon = Math.min(bounds.getEast(), WUHAN_BOUNDS.maxLon);
|
||||
|
||||
const latStart = Math.floor((minLat - WUHAN_BOUNDS.minLat) / cellSize) * cellSize + WUHAN_BOUNDS.minLat;
|
||||
const lonStart = Math.floor((minLon - WUHAN_BOUNDS.minLon) / cellSize) * cellSize + WUHAN_BOUNDS.minLon;
|
||||
|
||||
const gridLayer = L.layerGroup();
|
||||
const currentGridMap = gridMap;
|
||||
|
||||
let count = 0;
|
||||
const maxCount = 3000;
|
||||
|
||||
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellSize * step) {
|
||||
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellSize * step) {
|
||||
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
|
||||
const grid = currentGridMap.get(key);
|
||||
|
||||
const riskValue = grid?.risk_value ?? 0.5;
|
||||
let riskLevel = 'medium';
|
||||
if (riskValue >= 0.7) riskLevel = 'high';
|
||||
else if (riskValue >= 0.5) riskLevel = 'medium_high';
|
||||
else if (riskValue >= 0.3) riskLevel = 'medium_low';
|
||||
else riskLevel = 'low';
|
||||
|
||||
const color = RISK_COLORS[riskLevel];
|
||||
|
||||
const rect = L.rectangle(
|
||||
[[lat, lon], [lat + cellSize * step, lon + cellSize * step]],
|
||||
{
|
||||
fillColor: color,
|
||||
fillOpacity: 0.6,
|
||||
color: 'transparent',
|
||||
weight: 0,
|
||||
}
|
||||
);
|
||||
|
||||
if (grid) {
|
||||
const gridId = grid.grid_id;
|
||||
rect.bindTooltip(
|
||||
`<b>${gridId}</b><br/>风险:${Math.round(riskValue * 100)}%`,
|
||||
{ direction: 'center', permanent: false }
|
||||
);
|
||||
rect.on('click', () => {
|
||||
callbacksRef.current.onGridSelect(gridId);
|
||||
});
|
||||
}
|
||||
|
||||
rect.addTo(gridLayer);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
gridLayer.addTo(map);
|
||||
gridLayerRef.current = gridLayer;
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderGridLayer();
|
||||
|
||||
return () => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
gridLayerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [gridMap]);
|
||||
|
||||
const handleForecastChange = useCallback((d: ForecastDay) => {
|
||||
callbacksRef.current.onForecastChange(d);
|
||||
}, []);
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
callbacksRef.current.onFullscreen();
|
||||
}, []);
|
||||
|
||||
const handleClosePanel = useCallback(() => {
|
||||
callbacksRef.current.onClosePanel();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
|
||||
</svg>
|
||||
<span className="font-medium text-[14px]">武汉市儿童呼吸道疾病风险监控</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-0.5 bg-gray-100 p-0.5 rounded">
|
||||
{([0, 1, 3, 7] as ForecastDay[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => handleForecastChange(d)}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||
forecastDay === d ? 'bg-blue-500 text-white' : 'text-gray-600 hover:text-blue-500'
|
||||
}`}
|
||||
>
|
||||
{d === 0 ? '今日' : d + '天后'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="px-3 py-1.5 text-[12px] text-gray-600 bg-gray-100 border border-gray-200 rounded hover:border-blue-400 transition-colors"
|
||||
>
|
||||
全屏
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ height: containerHeight }}>
|
||||
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
|
||||
|
||||
<div className="absolute bottom-4 right-4 bg-white px-4 py-3 rounded-lg border border-gray-200 shadow-sm z-[1000]">
|
||||
<div className="text-[11px] font-semibold text-gray-600 mb-2">风险等级</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(RISK_LABELS).map(([level, label]) => (
|
||||
<div key={level} className="flex items-center gap-1.5 text-[11px] text-gray-600">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS[level] }} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 left-4 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm z-[1000]">
|
||||
<div className="text-[11px] text-gray-600">
|
||||
<span className="font-semibold text-gray-900">{grids.length.toLocaleString()}</span> 个监测点
|
||||
<span className="mx-2 text-gray-300">|</span>
|
||||
{forecastDay === 0 ? '实时监测' : forecastDay + '天预报'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedGrid && (
|
||||
<div className="absolute top-4 right-4 w-[280px] bg-white border border-gray-200 rounded-lg shadow-lg z-[1001]">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<span className="text-[13px] font-semibold">网格详情</span>
|
||||
<button onClick={handleClosePanel} className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100">
|
||||
<svg className="w-3.5 h-3.5 fill-gray-400" viewBox="0 0 24 24">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className={`rounded-md p-3 mb-4 ${selectedGrid.risk_value >= 0.7 ? 'bg-red-50' : 'bg-yellow-50'}`}>
|
||||
<div className="text-[12px] text-gray-500 mb-1">风险指数</div>
|
||||
<div className={`text-[24px] font-bold ${selectedGrid.risk_value >= 0.7 ? 'text-red-600' : 'text-yellow-600'}`}>
|
||||
{Math.round(selectedGrid.risk_value * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-[12px]">
|
||||
<div className="flex justify-between py-1.5 border-b border-gray-100">
|
||||
<span className="text-gray-400">区域</span>
|
||||
<span className="font-medium">{selectedGrid.region || '--'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1.5 border-b border-gray-100">
|
||||
<span className="text-gray-400">街道</span>
|
||||
<span className="font-medium">{selectedGrid.street || '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const RiskMap = memo(RiskMapComponent);
|
||||
112
frontend/src/components/SideNav.tsx
Normal file
112
frontend/src/components/SideNav.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SideNavProps {
|
||||
activePage: string;
|
||||
onPageChange: (page: string) => void;
|
||||
alertCount?: number;
|
||||
}
|
||||
|
||||
export function SideNav({
|
||||
activePage,
|
||||
onPageChange,
|
||||
alertCount = 0,
|
||||
}: SideNavProps) {
|
||||
const [expanded, setExpanded] = useState<string | null>('monitoring');
|
||||
|
||||
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
|
||||
{
|
||||
id: 'monitoring',
|
||||
label: '监测',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'monitoring', label: '监测面板' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'alert',
|
||||
label: '预警',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" 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>
|
||||
),
|
||||
items: [
|
||||
{ id: 'alerts', label: '预警地图' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: '分析',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'trend-analysis', label: '趋势分析' },
|
||||
{ id: 'district-comparison', label: '区域对比' },
|
||||
{ id: 'insights', label: '智能洞察' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleItemClick = (moduleId: string, itemId: string) => {
|
||||
setExpanded(moduleId);
|
||||
onPageChange(itemId);
|
||||
};
|
||||
|
||||
const isActiveModule = (moduleId: string) => {
|
||||
const module = modules.find(m => m.id === moduleId);
|
||||
if (!module) return false;
|
||||
return module.items.some(item => item.id === activePage);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-[200px] bg-bg-card border-r border-border fixed top-[52px] left-0 bottom-0 overflow-y-auto py-4 px-2">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id} className="mb-4">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === module.id ? null : module.id)}
|
||||
className={`w-full flex items-center gap-[10px] px-3 py-[9px] rounded-md text-[14px] font-semibold transition-colors ${
|
||||
isActiveModule(module.id)
|
||||
? 'bg-primary-muted text-primary'
|
||||
: 'text-text-primary hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 h-4 flex items-center justify-center">
|
||||
{module.icon}
|
||||
</span>
|
||||
<span>{module.label}</span>
|
||||
{module.id === 'alert' && alertCount > 0 && (
|
||||
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
|
||||
{alertCount > 99 ? '99+' : alertCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded === module.id && (
|
||||
<div className="mt-1 pl-7">
|
||||
{module.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(module.id, item.id)}
|
||||
className={`w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
|
||||
activePage === item.id
|
||||
? 'bg-bg-active text-primary'
|
||||
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/StatCard.tsx
Normal file
44
frontend/src/components/StatCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
change?: string;
|
||||
changeType?: 'up' | 'down' | 'neutral';
|
||||
progress?: number;
|
||||
progressColor?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
changeType = 'neutral',
|
||||
progress,
|
||||
progressColor = 'bg-warning',
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-1.5">
|
||||
{label}
|
||||
</div>
|
||||
<div className="font-display text-[26px] font-bold text-text-primary mb-1">
|
||||
{value}
|
||||
</div>
|
||||
{change && (
|
||||
<div className={`text-[11px] ${
|
||||
changeType === 'up' ? 'text-danger' :
|
||||
changeType === 'down' ? 'text-success' : 'text-text-muted'
|
||||
}`}>
|
||||
{change}
|
||||
</div>
|
||||
)}
|
||||
{progress !== undefined && (
|
||||
<div className="h-[3px] bg-bg-page rounded mt-2.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded ${progressColor}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
frontend/src/components/StatisticalCharts.tsx
Normal file
225
frontend/src/components/StatisticalCharts.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { TrendingUp, Activity } from 'lucide-react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface StatisticalChartsProps {
|
||||
data: Array<{
|
||||
date: string;
|
||||
cases: number;
|
||||
risk?: number;
|
||||
aqi?: number;
|
||||
}>;
|
||||
height?: number;
|
||||
showCases?: boolean;
|
||||
showRisk?: boolean;
|
||||
showAQI?: boolean;
|
||||
}
|
||||
|
||||
export function StatisticalCharts({
|
||||
data,
|
||||
height = 300,
|
||||
showCases = true,
|
||||
showRisk = false,
|
||||
showAQI = false,
|
||||
}: StatisticalChartsProps) {
|
||||
const [activeChart, setActiveChart] = useState<'cases' | 'risk' | 'aqi'>('cases');
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
date: new Date(item.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }),
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const calculateTrend = (values: number[]) => {
|
||||
if (values.length < 2) return 'stable';
|
||||
|
||||
const firstHalf = values.slice(0, Math.floor(values.length / 2));
|
||||
const secondHalf = values.slice(Math.floor(values.length / 2));
|
||||
|
||||
const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
|
||||
const secondAvg = secondHalf.reduce((a, b) => a + b) / secondHalf.length;
|
||||
|
||||
const change = ((secondAvg - firstAvg) / firstAvg) * 100;
|
||||
|
||||
if (change > 10) return 'up';
|
||||
if (change < -10) return 'down';
|
||||
return 'stable';
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const totalCases = data.reduce((sum, item) => sum + item.cases, 0);
|
||||
const avgCases = totalCases / data.length;
|
||||
const maxCases = Math.max(...data.map((item) => item.cases));
|
||||
const trend = calculateTrend(data.map((item) => item.cases));
|
||||
|
||||
return {
|
||||
totalCases,
|
||||
avgCases: Math.round(avgCases),
|
||||
maxCases,
|
||||
trend,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const getTrendIcon = () => {
|
||||
if (!stats) return null;
|
||||
|
||||
switch (stats.trend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="w-5 h-5 text-red-500" />;
|
||||
case 'down':
|
||||
return <TrendingUp className="w-5 h-5 text-green-500 rotate-180" />;
|
||||
default:
|
||||
return <Activity className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendLabel = () => {
|
||||
if (!stats) return '';
|
||||
|
||||
switch (stats.trend) {
|
||||
case 'up':
|
||||
return '上升趋势';
|
||||
case 'down':
|
||||
return '下降趋势';
|
||||
default:
|
||||
return '平稳';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">统计图表</h3>
|
||||
{getTrendIcon()}
|
||||
<span className={`text-sm font-medium ${
|
||||
stats?.trend === 'up' ? 'text-red-600' :
|
||||
stats?.trend === 'down' ? 'text-green-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{getTrendLabel()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{showCases && (
|
||||
<button
|
||||
onClick={() => setActiveChart('cases')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
activeChart === 'cases'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
病例数
|
||||
</button>
|
||||
)}
|
||||
{showRisk && (
|
||||
<button
|
||||
onClick={() => setActiveChart('risk')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
activeChart === 'risk'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
风险指数
|
||||
</button>
|
||||
)}
|
||||
{showAQI && (
|
||||
<button
|
||||
onClick={() => setActiveChart('aqi')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
activeChart === 'aqi'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
AQI
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
{stats && activeChart === 'cases' && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<div className="text-sm text-gray-600">总病例数</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.totalCases}</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<div className="text-sm text-gray-600">日均病例</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.avgCases}</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3">
|
||||
<div className="text-sm text-gray-600">峰值病例</div>
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.maxCases}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
<div style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorCases" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorRisk" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorAQI" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => Math.round(value).toString()}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={activeChart === 'cases' ? 'cases' : activeChart === 'risk' ? 'risk' : 'aqi'}
|
||||
stroke={
|
||||
activeChart === 'cases' ? '#3b82f6' :
|
||||
activeChart === 'risk' ? '#ef4444' :
|
||||
'#f59e0b'
|
||||
}
|
||||
fill={
|
||||
activeChart === 'cases' ? 'url(#colorCases)' :
|
||||
activeChart === 'risk' ? 'url(#colorRisk)' :
|
||||
'url(#colorAQI)'
|
||||
}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
frontend/src/components/TimelinePlayer.tsx
Normal file
200
frontend/src/components/TimelinePlayer.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react';
|
||||
|
||||
interface TimelinePlayerProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
currentDate: string;
|
||||
onDateChange: (date: string) => void;
|
||||
isPlaying?: boolean;
|
||||
speed?: number;
|
||||
onSpeedChange?: (speed: number) => void;
|
||||
onPlayPause?: (playing: boolean) => void;
|
||||
}
|
||||
|
||||
const SPEEDS = [0.5, 1, 2, 5, 10];
|
||||
|
||||
export function TimelinePlayer({
|
||||
startDate,
|
||||
endDate,
|
||||
currentDate,
|
||||
onDateChange,
|
||||
isPlaying = false,
|
||||
speed = 1,
|
||||
onSpeedChange,
|
||||
onPlayPause,
|
||||
}: TimelinePlayerProps) {
|
||||
const [playing, setPlaying] = useState(isPlaying);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const generateDateRange = useCallback((start: string, end: string) => {
|
||||
const dates: string[] = [];
|
||||
const current = new Date(start);
|
||||
const final = new Date(end);
|
||||
|
||||
while (current <= final) {
|
||||
dates.push(current.toISOString().split('T')[0]);
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}, []);
|
||||
|
||||
const dateRange = generateDateRange(startDate, endDate);
|
||||
const currentIndex = dateRange.indexOf(currentDate);
|
||||
const progress = ((currentIndex + 1) / dateRange.length) * 100;
|
||||
|
||||
const play = useCallback(() => {
|
||||
setPlaying(true);
|
||||
onPlayPause?.(true);
|
||||
}, [onPlayPause]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setPlaying(false);
|
||||
onPlayPause?.(false);
|
||||
}, [onPlayPause]);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (playing) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
};
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
const nextIndex = Math.min(currentIndex + 1, dateRange.length - 1);
|
||||
onDateChange(dateRange[nextIndex]);
|
||||
}, [currentIndex, dateRange, onDateChange]);
|
||||
|
||||
const goToStart = () => {
|
||||
onDateChange(dateRange[0]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
const interval = 1000 / speed;
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
goToNext();
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [playing, speed, goToNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIndex >= dateRange.length - 1) {
|
||||
pause();
|
||||
}
|
||||
}, [currentIndex, dateRange.length, pause]);
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const index = Math.round((Number(e.target.value) / 100) * (dateRange.length - 1));
|
||||
onDateChange(dateRange[index]);
|
||||
};
|
||||
|
||||
const handleSpeedChange = () => {
|
||||
const currentIndex = SPEEDS.indexOf(speed);
|
||||
const nextIndex = (currentIndex + 1) % SPEEDS.length;
|
||||
onSpeedChange?.(SPEEDS[nextIndex]);
|
||||
};
|
||||
|
||||
const formatSpeed = (s: number) => {
|
||||
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
|
||||
<div className="bg-white/95 backdrop-blur-xl border border-gray-200/80 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] px-4 py-3">
|
||||
{/* Date display */}
|
||||
<div className="text-center mb-3">
|
||||
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
第 {currentIndex + 1} / {dateRange.length} 天
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical slider */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={progress}
|
||||
onChange={handleSliderChange}
|
||||
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
|
||||
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
||||
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
||||
</div>
|
||||
|
||||
{/* Transport controls */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={goToStart}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
||||
title="跳到开始"
|
||||
>
|
||||
<SkipBack className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-2.5 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-md"
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="w-5 h-5" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
||||
title="跳到下一天"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Speed */}
|
||||
<div className="flex items-center justify-center gap-2 mt-2">
|
||||
<button
|
||||
onClick={handleSpeedChange}
|
||||
className="px-2 py-0.5 text-xs font-medium text-gray-600 bg-gray-100/80 rounded-full hover:bg-gray-200 transition-colors"
|
||||
title="调整播放速度"
|
||||
>
|
||||
{formatSpeed(speed)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/TopNav.tsx
Normal file
57
frontend/src/components/TopNav.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface TopNavProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export function TopNav({ onLogout }: TopNavProps) {
|
||||
const [currentTime, setCurrentTime] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setCurrentTime(new Date().toLocaleString('zh-CN'));
|
||||
update();
|
||||
const timer = setInterval(update, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 fixed top-0 left-0 right-0 z-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
|
||||
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-display font-semibold text-[15px] text-text-primary">
|
||||
WuhanChildRisk
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-border ml-4 mr-4" />
|
||||
|
||||
<span className="text-[13px] text-text-secondary">
|
||||
儿童呼吸道疾病风险监测预警平台
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-5">
|
||||
<span className="text-[12px] text-text-muted">
|
||||
{currentTime}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
admin
|
||||
</div>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="text-[12px] text-text-muted hover:text-danger transition-colors"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user