feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages
Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module
Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter
Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
This commit is contained in:
@@ -1,27 +1,30 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, memo } from 'react';
|
||||
import L from 'leaflet';
|
||||
|
||||
interface CaseLocation {
|
||||
case_id: string;
|
||||
case_type: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
district: string;
|
||||
street: string;
|
||||
}
|
||||
import { geocodedApi } from '@/services/api';
|
||||
import type { GeocodedCase } from '@/types';
|
||||
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||
|
||||
export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
interface CaseLocationMapProps {
|
||||
height?: string;
|
||||
district?: string | null;
|
||||
street?: string | null;
|
||||
}
|
||||
|
||||
function CaseLocationMapComponent({ height = '400px', district = null, street = null }: CaseLocationMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [caseCount, setCaseCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
cancelledRef.current = false;
|
||||
|
||||
const map = L.map(mapRef.current, {
|
||||
center: WUHAN_CENTER,
|
||||
zoom: 11,
|
||||
@@ -37,10 +40,10 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
layerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
// Fetch case locations
|
||||
fetch('/api/geocoded/geocoded?limit=5000')
|
||||
.then((res) => res.json())
|
||||
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined })
|
||||
.then((data) => {
|
||||
const cases: CaseLocation[] = data.cases || [];
|
||||
if (cancelledRef.current) return;
|
||||
const cases: GeocodedCase[] = data.cases || [];
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
@@ -48,7 +51,7 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
|
||||
// Deduplicate by case_id to avoid overlapping markers
|
||||
const seen = new Set<string>();
|
||||
const unique: CaseLocation[] = [];
|
||||
let unique: GeocodedCase[] = [];
|
||||
for (const c of cases) {
|
||||
if (!seen.has(c.case_id)) {
|
||||
seen.add(c.case_id);
|
||||
@@ -56,6 +59,13 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side street filtering
|
||||
if (street) {
|
||||
unique = unique.filter((c) => c.street === street);
|
||||
}
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
for (const c of unique) {
|
||||
if (!c.latitude || !c.longitude) continue;
|
||||
|
||||
@@ -88,13 +98,16 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
map.fitBounds(bounds, { padding: [30, 30] });
|
||||
}
|
||||
})
|
||||
.catch(() => setIsLoading(false));
|
||||
.catch(() => {
|
||||
if (!cancelledRef.current) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [district, street]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -114,3 +127,5 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CaseLocationMap = memo(CaseLocationMapComponent);
|
||||
|
||||
Reference in New Issue
Block a user