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:
2026-06-08 18:40:08 +08:00
parent 47f4bb4ab2
commit 8ddd8e87bb
30 changed files with 1368 additions and 302 deletions

View File

@@ -9,6 +9,7 @@ const AlertsDashboard = lazy(() => import('@/pages/AlertsDashboard').then(m => (
const TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ default: m.TrendAnalysis })));
const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison })));
const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights })));
const ReportsCenter = lazy(() => import('@/pages/ReportsCenter').then(m => ({ default: m.ReportsCenter })));
interface Props {
@@ -62,7 +63,8 @@ function PageLoader() {
function App() {
const [activePage, setActivePage] = useState('monitoring');
const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token'));
const { alerts, fetchAlerts } = useRiskStore();
const alerts = useRiskStore((s) => s.alerts);
const fetchAlerts = useRiskStore((s) => s.fetchAlerts);
useEffect(() => {
if (token) fetchAlerts();
@@ -108,6 +110,7 @@ function App() {
{activePage === 'trend-analysis' && <TrendAnalysis />}
{activePage === 'district-comparison' && <DistrictComparison />}
{activePage === 'insights' && <Insights />}
{activePage === 'reports' && <ReportsCenter />}
</Suspense>
</main>
</div>

View File

@@ -0,0 +1,52 @@
import { ChevronRight } from 'lucide-react';
import { useDrilldownStore } from '@/stores/drilldownStore';
const WUHAN_DISTRICTS = [
'江岸区', '江汉区', '硚口区', '汉阳区', '武昌区', '青山区', '洪山区',
'东西湖区', '汉南区', '蔡甸区', '江夏区', '黄陂区', '新洲区',
];
export function AdminBreadcrumb() {
const {
currentLevel, selectedDistrict, selectedStreet,
availableStreets, isLoadingStreets,
drillDown, drillUp,
} = useDrilldownStore();
const showStreetDropdown = currentLevel === 'district' || currentLevel === 'street';
const hasStreets = availableStreets.length > 1;
const districtBtnCls = currentLevel === 'district' || currentLevel === 'street'
? 'border-blue-300 text-blue-700 font-medium' : 'border-gray-300 text-gray-600';
return (
<div className="flex items-center gap-1.5 text-xs">
<button onClick={() => drillUp()} className={`px-1.5 py-0.5 rounded hover:bg-gray-100 transition-colors ${currentLevel === 'province' ? 'text-blue-600 font-semibold bg-blue-50' : 'text-gray-600'}`}>
</button>
<ChevronRight className="w-3 h-3 text-gray-300" />
<button onClick={() => currentLevel !== 'city' && drillUp()} className={`px-1.5 py-0.5 rounded hover:bg-gray-100 transition-colors ${currentLevel === 'city' ? 'text-blue-600 font-semibold bg-blue-50' : 'text-gray-600'}`}>
</button>
<ChevronRight className="w-3 h-3 text-gray-300" />
<select value={selectedDistrict || ''} onChange={(e) => e.target.value ? drillDown('district', e.target.value) : drillUp()}
className={`px-2 py-0.5 border rounded text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 ${districtBtnCls}`}>
<option value=""></option>
{WUHAN_DISTRICTS.map((d) => (<option key={d} value={d}>{d}</option>))}
</select>
{showStreetDropdown && (<>
<ChevronRight className="w-3 h-3 text-gray-300" />
{isLoadingStreets ? (<span className="text-gray-400 text-xs">...</span>)
: hasStreets ? (
<select value={selectedStreet || ''} onChange={(e) => e.target.value ? drillDown('street', e.target.value) : drillUp()}
className={`px-2 py-0.5 border rounded text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 ${currentLevel === 'street' ? 'border-blue-300 text-blue-700 font-medium' : 'border-gray-300 text-gray-600'}`}>
<option value=""></option>
{availableStreets.map((s) => (
<option key={s.name} value={s.name}>{s.name} ({s.total_cases})</option>
))}
</select>
) : (<span className="text-gray-400 text-[11px]"></span>)}
</>)}
</div>
);
}

View File

@@ -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);

View File

@@ -1,7 +1,7 @@
import { memo, useEffect, useRef, useState, useCallback } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { caseApi } from '@/services/api';
import { geocodedApi } from '@/services/api';
import type { CaseGrid, GeocodedCase } from '@/types';
interface CaseMapProps {
@@ -74,8 +74,8 @@ function CaseMapComponent({ height = '480px' }: CaseMapProps) {
setError(null);
try {
const [gridRes, geoRes] = await Promise.all([
caseApi.getGrid(),
caseApi.getGeocoded(5000),
geocodedApi.getGrid(),
geocodedApi.getGeocoded({ limit: 5000 }),
]);
if (cancelled) return;
setGrids(gridRes.grids || []);

View File

@@ -0,0 +1,124 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { Search, ChevronDown, X } from 'lucide-react';
import { useDiseaseStore } from '@/stores/diseaseStore';
interface DiseaseFilterProps {
onFilterChange?: (diagnoses: string[]) => void;
}
export function DiseaseFilter({ onFilterChange }: DiseaseFilterProps) {
const availableDiagnoses = useDiseaseStore((s) => s.availableDiagnoses);
const selectedDiagnoses = useDiseaseStore((s) => s.selectedDiagnoses);
const isLoading = useDiseaseStore((s) => s.isLoading);
const fetchDiagnoses = useDiseaseStore((s) => s.fetchDiagnoses);
const setSelectedDiagnoses = useDiseaseStore((s) => s.setSelectedDiagnoses);
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchDiagnoses();
}, [fetchDiagnoses]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filtered = useMemo(() =>
availableDiagnoses.filter((d) =>
d.toLowerCase().includes(search.toLowerCase())
), [availableDiagnoses, search]);
const handleToggle = useCallback((diagnosis: string) => {
const next = selectedDiagnoses.includes(diagnosis)
? selectedDiagnoses.filter((d) => d !== diagnosis)
: [...selectedDiagnoses, diagnosis];
setSelectedDiagnoses(next);
onFilterChange?.(next);
}, [selectedDiagnoses, setSelectedDiagnoses, onFilterChange]);
const handleSelectAll = useCallback(() => {
setSelectedDiagnoses([...availableDiagnoses]);
onFilterChange?.([...availableDiagnoses]);
}, [availableDiagnoses, setSelectedDiagnoses, onFilterChange]);
const handleClear = useCallback(() => {
setSelectedDiagnoses([]);
onFilterChange?.([]);
}, [setSelectedDiagnoses, onFilterChange]);
return (
<div ref={containerRef} className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-1.5 border border-gray-300 rounded-lg text-xs bg-white hover:border-gray-400 transition-colors min-w-[140px]"
>
<span className={selectedDiagnoses.length > 0 ? 'text-blue-600 font-medium' : 'text-gray-500'}>
{selectedDiagnoses.length > 0 ? `已选 ${selectedDiagnoses.length}` : '按病种筛选'}
</span>
<ChevronDown className={`w-3.5 h-3.5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute top-full mt-1 left-0 w-64 bg-white border border-gray-200 rounded-lg shadow-lg z-50">
{/* Search input */}
<div className="p-2 border-b border-gray-100">
<div className="flex items-center gap-1.5 px-2 py-1 bg-gray-50 rounded">
<Search className="w-3 h-3 text-gray-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索诊断..."
className="flex-1 bg-transparent text-xs outline-none"
/>
{search && (
<button onClick={() => setSearch('')} className="text-gray-400 hover:text-gray-600">
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{/* Quick actions */}
<div className="flex gap-1 px-2 py-1.5 border-b border-gray-100">
<button onClick={handleSelectAll} className="text-[11px] text-blue-600 hover:text-blue-800 px-1"></button>
<span className="text-gray-300">|</span>
<button onClick={handleClear} className="text-[11px] text-gray-500 hover:text-gray-700 px-1"></button>
</div>
{/* Options list */}
<div className="max-h-48 overflow-y-auto p-1">
{isLoading ? (
<div className="text-center py-4 text-xs text-gray-400">...</div>
) : filtered.length === 0 ? (
<div className="text-center py-4 text-xs text-gray-400">
{availableDiagnoses.length === 0 ? '暂无可选诊断' : '无匹配诊断'}
</div>
) : (
filtered.map((diagnosis) => (
<label
key={diagnosis}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
>
<input
type="checkbox"
checked={selectedDiagnoses.includes(diagnosis)}
onChange={() => handleToggle(diagnosis)}
className="w-3.5 h-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-gray-700">{diagnosis}</span>
</label>
))
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -66,7 +66,7 @@ function RiskMapComponent(props: RiskMapProps) {
useEffect(() => {
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
});
}, [onGridSelect, onClosePanel, onFullscreen, onForecastChange]);
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';

View File

@@ -6,6 +6,48 @@ interface SideNavProps {
alertCount?: number;
}
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: '智能洞察' },
{ id: 'reports', label: '报表中心' },
],
},
];
export function SideNav({
activePage,
onPageChange,
@@ -13,47 +55,6 @@ export function SideNav({
}: 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);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react';
interface TimelinePlayerProps {
@@ -40,9 +40,9 @@ export function TimelinePlayer({
return dates;
}, []);
const dateRange = generateDateRange(startDate, endDate);
const currentIndex = dateRange.indexOf(currentDate);
const progress = ((currentIndex + 1) / dateRange.length) * 100;
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
const currentIndex = useMemo(() => dateRange.indexOf(currentDate), [dateRange, currentDate]);
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
const play = useCallback(() => {
setPlaying(true);

View File

@@ -4,15 +4,16 @@ interface TopNavProps {
onLogout?: () => void;
}
export function TopNav({ onLogout }: TopNavProps) {
const [currentTime, setCurrentTime] = useState('');
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const update = () => setCurrentTime(new Date().toLocaleString('zh-CN'));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
}
export function TopNav({ onLogout }: TopNavProps) {
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">
@@ -35,7 +36,7 @@ export function TopNav({ onLogout }: TopNavProps) {
<div className="ml-auto flex items-center gap-5">
<span className="text-[12px] text-text-muted">
{currentTime}
<Clock />
</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">

View File

@@ -29,7 +29,12 @@ const HORIZON_LABELS: Record<number, string> = {
};
export function AlertsDashboard() {
const { alerts, isLoading, error, clearError, fetchRiskMap, fetchAlerts } = useRiskStore();
const alerts = useRiskStore((s) => s.alerts);
const isLoading = useRiskStore((s) => s.isLoading);
const error = useRiskStore((s) => s.error);
const clearError = useRiskStore((s) => s.clearError);
const fetchRiskMap = useRiskStore((s) => s.fetchRiskMap);
const fetchAlerts = useRiskStore((s) => s.fetchAlerts);
const [selectedHorizon, setSelectedHorizon] = useState<number | 'all'>('all');
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
@@ -474,7 +479,8 @@ export function AlertsDashboard() {
key={alert.alert_id}
alert={alert}
isSelected={selectedAlert === alert.alert_id}
onClick={() => handleAlertCardClick(alert.alert_id)}
alertId={alert.alert_id}
onCardClick={handleAlertCardClick}
/>
))}
{filteredAlerts.length > 50 && (
@@ -579,19 +585,24 @@ export function AlertsDashboard() {
interface AlertCardProps {
alert: ExtendedAlert;
isSelected?: boolean;
onClick?: () => void;
alertId: string;
onCardClick: (id: string) => void;
}
const AlertCard = React.memo(function AlertCard({ alert, isSelected, onClick }: AlertCardProps) {
const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, onCardClick }: AlertCardProps) {
const isP1 = alert.priority === 'P1';
const riskPercent = Math.round(alert.risk_value * 100);
const handleClick = useCallback(() => {
onCardClick(alertId);
}, [alertId, onCardClick]);
return (
<div
className={`card overflow-hidden transition-colors cursor-pointer ${
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
}`}
onClick={onClick}
onClick={handleClick}
>
<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">

View File

@@ -22,12 +22,16 @@ const RISK_COLORS: Record<string, string> = {
};
export function DistrictComparison() {
const { districtData, isLoading, error, clearError, fetchDistricts } = useAnalysisStore();
const districtData = useAnalysisStore((s) => s.districtData);
const isLoading = useAnalysisStore((s) => s.isLoading);
const error = useAnalysisStore((s) => s.error);
const clearError = useAnalysisStore((s) => s.clearError);
const fetchDistricts = useAnalysisStore((s) => s.fetchDistricts);
const [metric, setMetric] = useState<'avg_aqi' | 'avg_risk' | 'high_risk_count'>('avg_aqi');
useEffect(() => {
fetchDistricts();
}, []);
}, [fetchDistricts]);
const metricConfig = {
avg_aqi: { label: '平均AQI', color: '#2563EB', unit: '' },

View File

@@ -45,11 +45,15 @@ const TYPE_CONFIG = {
};
export function Insights() {
const { insights, isLoading, error, clearError, fetchInsights } = useAnalysisStore();
const insights = useAnalysisStore((s) => s.insights);
const isLoading = useAnalysisStore((s) => s.isLoading);
const error = useAnalysisStore((s) => s.error);
const clearError = useAnalysisStore((s) => s.clearError);
const fetchInsights = useAnalysisStore((s) => s.fetchInsights);
useEffect(() => {
fetchInsights();
}, []);
}, [fetchInsights]);
const stats = insights
? [

View File

@@ -1,28 +1,25 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useEffect, useState, useMemo, useRef, useCallback, memo } from 'react';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Building2 } from 'lucide-react';
import { useTimelineStore, useMonitoringStore } from '@/stores';
import { gridApi } from '@/services/api';
import { useDiseaseStore } from '@/stores/diseaseStore';
import { useDrilldownStore } from '@/stores/drilldownStore';
import { gridApi, caseApi } from '@/services/api';
import { ErrorBanner } from '@/components/ErrorBanner';
import { TimelinePlayer } from '@/components/TimelinePlayer';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { CaseLocationMap } from '@/components/CaseLocationMap';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
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 {
@@ -35,13 +32,14 @@ export function MonitoringDashboard({
setDateRange,
} = useTimelineStore();
const {
districtCases,
error,
clearError,
fetchDistrictCases,
isLoading,
} = useMonitoringStore();
const districtCases = useMonitoringStore((s) => s.districtCases);
const error = useMonitoringStore((s) => s.error);
const clearError = useMonitoringStore((s) => s.clearError);
const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
const isLoading = useMonitoringStore((s) => s.isLoading);
const { selectedDistrict, selectedStreet } = useDrilldownStore();
const { selectedDiagnoses } = useDiseaseStore();
useEffect(() => {
setDateRange(defaultStartDate, defaultEndDate);
@@ -54,25 +52,42 @@ export function MonitoringDashboard({
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]);
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
// Use caseApi for diagnosis-filtered data
caseApi.getTrend({
start_date: startStr,
end_date: endStr,
group_by: 'day',
diagnosis: selectedDiagnoses.join(','),
}).then((data) => {
const trend = data.trend || [];
setChartData(
trend.map((t: { date: string; total: number }) => ({ date: t.date, cases: t.total }))
);
}).catch((e) => { console.error('Failed to load chart data:', e); });
} else {
// Use gridApi for unfiltered data (or too many diagnoses selected)
gridApi.getHistoricalAggregated(startStr, endStr, '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((e) => { console.error('Failed to load chart data:', e); });
}
// Fetch districtCases with diagnosis filter
fetchDistrictCases(selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined);
}, [defaultEndDate, fetchDistrictCases, selectedDiagnoses]);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -189,19 +204,16 @@ export function MonitoringDashboard({
)}
</div>
{/* District filter */}
{/* Disease 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>
<DiseaseFilter onFilterChange={() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
loadChartData(selectedDistrict || undefined);
}, 300);
}} />
{/* District filter - AdminBreadcrumb for drill-down */}
<AdminBreadcrumb />
</div>
</div>
</div>
@@ -217,7 +229,7 @@ export function MonitoringDashboard({
{/* 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" />
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} />
</div>
{/* Statistical Charts */}
@@ -232,44 +244,7 @@ export function MonitoringDashboard({
<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>
);
});
})()}
<DistrictBreakdown districtCases={districtCases} selectedDistrict={selectedDistrict} />
</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">
@@ -297,4 +272,58 @@ export function MonitoringDashboard({
/>
</div>
);
}
}
interface DistrictBreakdownProps {
districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
selectedDistrict: string | null;
}
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict }: DistrictBreakdownProps) {
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
const handleDistrictClick = useCallback((district: string) => {
if (selectedDistrict === district) {
useDrilldownStore.getState().drillUp();
} else {
useDrilldownStore.getState().drillDown('district', district);
}
}, [selectedDistrict]);
return (
<>
{sortedCases.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={() => handleDistrictClick(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>
);
})}
</>
);
});

View File

@@ -0,0 +1,349 @@
import { useEffect, useState, useCallback } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { FileText, Download, Activity, TrendingUp, TrendingDown, AlertTriangle, ChevronLeft, RefreshCw } from 'lucide-react';
import { useReportsStore } from '@/stores/reportsStore';
import { useDiseaseStore } from '@/stores/diseaseStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import type { ReportResponse } from '@/types';
const TYPE_LABELS: Record<string, string> = {
daily: '日报', weekly: '周报', monthly: '月报', custom: '自定义',
};
const TYPE_COLORS: Record<string, string> = {
daily: 'bg-blue-100 text-blue-700', weekly: 'bg-purple-100 text-purple-700',
monthly: 'bg-green-100 text-green-700', custom: 'bg-gray-100 text-gray-700',
};
const PRIORITY_COLORS: Record<string, string> = {
high: 'bg-red-100 text-red-700 border-red-300',
medium: 'bg-yellow-100 text-yellow-700 border-yellow-300',
low: 'bg-gray-100 text-gray-600 border-gray-300',
};
function downloadCSV(report: ReportResponse) {
const BOM = '';
const headers = ['报告ID', '标题', '类型', '报告日期', '周期开始', '周期结束', '总病例数', '平均风险',
'峰值风险日期', '峰值风险值', '高风险区域数', '趋势方向', '诊断名称', '门诊病例', '住院病例', '诊断总病例'];
const { metadata, summary } = report;
const breakdowns = report.diagnosis_breakdown || [];
const typeLabel = TYPE_LABELS[metadata.type] || metadata.type;
let csv = BOM + headers.join(',') + '\n';
if (breakdowns.length === 0) {
csv += [
metadata.report_id, `"${metadata.title}"`, typeLabel, metadata.generated_at,
metadata.period_start, metadata.period_end, summary.total_cases, summary.avg_risk,
summary.peak_risk_date, summary.peak_risk_value, summary.high_risk_areas, summary.trend_direction,
'', '', '', ''
].join(',');
} else {
breakdowns.forEach((d) => {
csv += [
metadata.report_id, `"${metadata.title}"`, typeLabel, metadata.generated_at,
metadata.period_start, metadata.period_end, summary.total_cases, summary.avg_risk,
summary.peak_risk_date, summary.peak_risk_value, summary.high_risk_areas, summary.trend_direction,
`"${d.diagnosis}"`, d.outpatient, d.inpatient, d.total
].join(',') + '\n';
});
}
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${report.metadata.report_id}.csv`;
link.click();
URL.revokeObjectURL(url);
}
function ReportList({ onSelect }: { onSelect: (id: string) => void }) {
const reports = useReportsStore((s) => s.reports);
const isLoading = useReportsStore((s) => s.isLoading);
const error = useReportsStore((s) => s.error);
const clearError = useReportsStore((s) => s.clearError);
const fetchReportsList = useReportsStore((s) => s.fetchReportsList);
const [filter, setFilter] = useState<string>('all');
useEffect(() => { fetchReportsList(filter === 'all' ? undefined : filter); }, [filter, fetchReportsList]);
const filters = [
{ key: 'all', label: '全部' },
{ key: 'daily', label: '日报' },
{ key: 'weekly', label: '周报' },
{ key: 'monthly', label: '月报' },
];
if (error) return <ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(filter === 'all' ? undefined : filter); }} onDismiss={clearError} />;
return (
<div>
<div className="flex items-center gap-2 mb-4">
{filters.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-3 py-1 text-[12px] rounded transition-colors ${
filter === f.key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>{f.label}</button>
))}
</div>
{isLoading ? (
<div className="text-center py-8 text-sm text-gray-500">...</div>
) : reports.length === 0 ? (
<div className="text-center py-12">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm"></p>
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase">ID</th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase"></th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase"></th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase"></th>
<th className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody>
{reports.map((r) => (
<tr
key={r.report_id}
onClick={() => onSelect(r.report_id)}
className="border-b border-gray-100 hover:bg-blue-50 cursor-pointer transition-colors"
>
<td className="px-4 py-3 font-mono text-xs text-gray-900">{r.report_id}</td>
<td className="px-4 py-3 text-gray-900">{r.title}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-[11px] font-medium ${TYPE_COLORS[r.type] || 'bg-gray-100 text-gray-600'}`}>
{TYPE_LABELS[r.type] || r.type}
</span>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{r.period_start} ~ {r.period_end}
</td>
<td className="px-4 py-3 text-gray-500 text-xs">{r.generated_at?.slice(0, 10)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function ReportDetail({ reportId, onBack }: { reportId: string; onBack: () => void }) {
const currentReport = useReportsStore((s) => s.currentReport);
const isLoading = useReportsStore((s) => s.isLoading);
const error = useReportsStore((s) => s.error);
const clearError = useReportsStore((s) => s.clearError);
const fetchReport = useReportsStore((s) => s.fetchReport);
const { selectedDiagnoses } = useDiseaseStore();
useEffect(() => { fetchReport(reportId); }, [reportId]);
if (error) return <ErrorBanner error={error} onRetry={() => { clearError(); fetchReport(reportId); }} onDismiss={clearError} />;
if (isLoading || !currentReport) return <div className="text-center py-8 text-sm text-gray-500">...</div>;
const { metadata, summary, sections, recommendations, diagnosis_breakdown } = currentReport;
const filteredBreakdown = diagnosis_breakdown?.filter(
d => selectedDiagnoses.length === 0 || selectedDiagnoses.includes(d.diagnosis)
) || [];
return (
<div>
<button onClick={onBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">{metadata.title}</h2>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span className={`px-2 py-0.5 rounded font-medium ${TYPE_COLORS[metadata.type] || ''}`}>
{TYPE_LABELS[metadata.type] || metadata.type}
</span>
<span>{metadata.period_start} ~ {metadata.period_end}</span>
<span>: {metadata.generated_at?.slice(0, 10)}</span>
</div>
</div>
<button
onClick={() => downloadCSV(currentReport)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
<Download className="w-3.5 h-3.5" /> CSV
</button>
</div>
{/* Summary cards */}
<div className="grid grid-cols-5 gap-3 mb-6">
{[
{ label: '总病例数', value: summary.total_cases.toLocaleString(), icon: Activity, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '平均风险', value: (summary.avg_risk * 100).toFixed(1) + '%', icon: AlertTriangle, color: 'text-orange-600', bg: 'bg-orange-50' },
{ label: '峰值风险', value: (summary.peak_risk_value * 100).toFixed(1) + '%', icon: TrendingUp, color: 'text-red-600', bg: 'bg-red-50' },
{ label: '高风险区域', value: `${summary.high_risk_areas}`, icon: TrendingUp, color: 'text-red-600', bg: 'bg-red-50' },
{
label: '趋势', value: summary.trend_direction === 'improving' ? '好转' : summary.trend_direction === 'worsening' ? '恶化' : '平稳',
icon: summary.trend_direction === 'improving' ? TrendingDown : summary.trend_direction === 'worsening' ? TrendingUp : Activity,
color: summary.trend_direction === 'improving' ? 'text-green-600' : summary.trend_direction === 'worsening' ? 'text-red-600' : 'text-gray-600',
bg: summary.trend_direction === 'improving' ? 'bg-green-50' : summary.trend_direction === 'worsening' ? 'bg-red-50' : 'bg-gray-50',
},
].map((stat) => (
<div key={stat.label} className="bg-white rounded-lg border border-gray-200 p-3">
<div className="flex items-center gap-2 mb-1">
<div className={`w-7 h-7 rounded ${stat.bg} flex items-center justify-center`}>
<stat.icon className={`w-3.5 h-3.5 ${stat.color}`} />
</div>
<span className="text-[11px] text-gray-500">{stat.label}</span>
</div>
<div className="text-xl font-bold text-gray-900">{stat.value}</div>
</div>
))}
</div>
{/* Sections and charts in 2-column layout */}
<div className="grid grid-cols-2 gap-4 mb-6">
{sections.map((section, idx) => (
<div key={idx} className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2">{section.title}</h3>
<p className="text-xs text-gray-600 leading-relaxed">{section.content}</p>
</div>
))}
</div>
{/* Diagnosis breakdown chart */}
{filteredBreakdown.length > 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-900"></h3>
<DiseaseFilter />
</div>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={filteredBreakdown} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="diagnosis" tick={{ fontSize: 11, fill: '#6b7280' }} />
<YAxis tick={{ fontSize: 11, fill: '#6b7280' }} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: '1px solid #e5e7eb' }} />
<Bar dataKey="outpatient" name="门诊" stackId="a" fill="#3b82f6" radius={[0, 0, 0, 0]} />
<Bar dataKey="inpatient" name="住院" stackId="a" fill="#ef4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-2 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-blue-500 rounded-sm" /></div>
<div className="flex items-center gap-1.5 text-xs text-gray-500"><span className="w-3 h-3 bg-red-500 rounded-sm" /></div>
</div>
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6 text-center">
<p className="text-sm text-gray-400"></p>
</div>
)}
{/* Recommendations */}
{recommendations.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3"></h3>
<div className="space-y-2">
{recommendations.map((rec, idx) => (
<div key={idx} className={`flex items-start gap-3 p-3 rounded border ${PRIORITY_COLORS[rec.priority] || ''}`}>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold shrink-0 ${
rec.priority === 'high' ? 'bg-red-500 text-white' :
rec.priority === 'medium' ? 'bg-yellow-500 text-white' : 'bg-gray-400 text-white'
}`}>
{rec.priority === 'high' ? '高' : rec.priority === 'medium' ? '中' : '低'}
</span>
<div>
<div className="text-sm font-medium text-gray-900">{rec.title}</div>
<div className="text-xs text-gray-600 mt-0.5">{rec.description}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
export function ReportsCenter() {
const [view, setView] = useState<'list' | 'detail'>('list');
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
const { error, clearError, fetchReportsList, generateReport } = useReportsStore();
const [genType, setGenType] = useState<string>('daily');
const [isGenerating, setIsGenerating] = useState(false);
const handleGenerate = useCallback(async () => {
setIsGenerating(true);
try {
await generateReport(genType);
setView('detail');
} finally {
setIsGenerating(false);
}
}, [genType, generateReport]);
const handleSelect = useCallback((id: string) => {
setSelectedReportId(id);
setView('detail');
}, []);
const handleBack = useCallback(() => {
setView('list');
setSelectedReportId(null);
fetchReportsList();
}, [fetchReportsList]);
return (
<div>
{error && view === 'list' && (
<ErrorBanner error={error} onRetry={() => { clearError(); fetchReportsList(); }} onDismiss={clearError} />
)}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
</h1>
<p className="text-[12px] text-text-muted"></p>
</div>
{view === 'list' && (
<div className="flex items-center gap-2">
<select
value={genType}
onChange={(e) => setGenType(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="daily"></option>
<option value="weekly"></option>
<option value="monthly"></option>
</select>
<button
onClick={handleGenerate}
disabled={isGenerating}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-dark transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${isGenerating ? 'animate-spin' : ''}`} />
</button>
</div>
)}
</div>
{view === 'list' && <ReportList onSelect={handleSelect} />}
{view === 'detail' && selectedReportId && (
<ReportDetail reportId={selectedReportId} onBack={handleBack} />
)}
</div>
);
}

View File

@@ -32,7 +32,13 @@ const DAY_OPTIONS = [
];
export function TrendAnalysis() {
const { trendData, isLoading, error, clearError, selectedDays, setSelectedDays, fetchTrend } = useAnalysisStore();
const trendData = useAnalysisStore((s) => s.trendData);
const isLoading = useAnalysisStore((s) => s.isLoading);
const error = useAnalysisStore((s) => s.error);
const clearError = useAnalysisStore((s) => s.clearError);
const selectedDays = useAnalysisStore((s) => s.selectedDays);
const setSelectedDays = useAnalysisStore((s) => s.setSelectedDays);
const fetchTrend = useAnalysisStore((s) => s.fetchTrend);
const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
useEffect(() => {

View File

@@ -10,6 +10,10 @@ import type {
CaseStatsResponse,
CaseGridResponse,
GeocodedCasesResponse,
StreetData,
ReportListResponse,
ReportResponse,
ReportSummary,
} from '@/types';
interface CacheEntry<T> {
@@ -146,24 +150,38 @@ export const historyApi = {
grid_id?: string;
region?: string;
days?: number;
}): Promise<any> => cachedGet('/history', params),
}): Promise<any> => cachedGet('/grids/history', params),
getTrend: (gridId: string, days: number = 7): Promise<any> =>
cachedGet('/history/trend', { grid_id: gridId, days }),
cachedGet(`/grids/${encodeURIComponent(gridId)}/history`, { days }),
};
export const caseApi = {
getTrend: (days: number = 7): Promise<CaseTrendResponse> =>
cachedGet('/cases/trend', { days }),
getTrend: (params?: {
start_date?: string;
end_date?: string;
group_by?: 'day' | 'week' | 'month';
diagnosis?: string;
}): Promise<CaseTrendResponse> => cachedGet('/cases/trend', params),
getDistricts: (): Promise<DistrictCaseResponse> => cachedGet('/cases/districts'),
getDistricts: (params?: { diagnosis?: string }): Promise<DistrictCaseResponse> => cachedGet('/cases/districts', params),
getStats: (): Promise<CaseStatsResponse> => cachedGet('/cases/stats'),
getGrid: (): Promise<CaseGridResponse> => cachedGet('/cases/grid'),
getDiagnoses: (): Promise<{ diagnoses: string[] }> => cachedGet('/cases/diagnoses'),
};
getGeocoded: (limit: number = 5000): Promise<GeocodedCasesResponse> =>
cachedGet('/cases/geocoded', { limit }),
export const geocodedApi = {
getGrid: (): Promise<CaseGridResponse> => cachedGet('/geocoded/grid'),
getGeocoded: (params?: { limit?: number; district?: string }): Promise<GeocodedCasesResponse> =>
cachedGet('/geocoded/geocoded', params),
getStreets: (district: string): Promise<{ streets: StreetData[] }> =>
cachedGet('/geocoded/streets', { district }),
getCount: (): Promise<{ total: number; street_matched: number; district_fallback: number; match_rate: number }> =>
cachedGet('/geocoded/geocoded/count'),
};
export function clearApiCache(): void {
@@ -212,4 +230,14 @@ export const insightsApi = {
getCards: (): Promise<any> => cachedGet('/insights/cards'),
};
export const reportApi = {
getList: (params?: { report_type?: string; limit?: number }): Promise<ReportListResponse> =>
cachedGet('/reports/list', params),
getReport: (reportId: string): Promise<ReportResponse> =>
cachedGet(`/reports/${encodeURIComponent(reportId)}`),
generateReport: (report_type: string, date?: string): Promise<ReportResponse> =>
api.get(`/reports/generate/${report_type}`, date ? { params: { date } } : undefined).then((r) => r.data),
getLatestSummary: (): Promise<ReportSummary> => cachedGet('/reports/summary/latest'),
};
export default api;

View File

@@ -0,0 +1,38 @@
import { create } from 'zustand';
import { caseApi } from '@/services/api';
interface DiseaseState {
availableDiagnoses: string[];
selectedDiagnoses: string[];
isLoading: boolean;
error: string | null;
fetchDiagnoses: () => Promise<void>;
setSelectedDiagnoses: (diagnoses: string[]) => void;
clearDiagnoses: () => void;
clearError: () => void;
}
export const useDiseaseStore = create<DiseaseState>((set, get) => ({
availableDiagnoses: [],
selectedDiagnoses: [],
isLoading: false,
error: null,
clearError: () => set({ error: null }),
fetchDiagnoses: async () => {
const { availableDiagnoses } = get();
if (availableDiagnoses.length > 0) return; // already loaded
set({ isLoading: true, error: null });
try {
const data = await caseApi.getDiagnoses();
set({ availableDiagnoses: data.diagnoses || [], isLoading: false });
} catch (e) {
set({ error: (e as Error).message || '加载诊断列表失败', isLoading: false });
}
},
setSelectedDiagnoses: (diagnoses) => set({ selectedDiagnoses: diagnoses }),
clearDiagnoses: () => set({ selectedDiagnoses: [] }),
}));

View File

@@ -0,0 +1,63 @@
import { create } from 'zustand';
import { geocodedApi } from '@/services/api';
import type { StreetData } from '@/types';
type AdminLevel = 'province' | 'city' | 'district' | 'street';
interface DrilldownState {
currentLevel: AdminLevel;
selectedDistrict: string | null;
selectedStreet: string | null;
availableStreets: StreetData[];
isLoadingStreets: boolean;
drillDown: (level: AdminLevel, value: string) => void;
drillUp: () => void;
fetchStreets: (district: string) => Promise<void>;
resetDrillDown: () => void;
}
export const useDrilldownStore = create<DrilldownState>((set, get) => ({
currentLevel: 'city',
selectedDistrict: null,
selectedStreet: null,
availableStreets: [],
isLoadingStreets: false,
drillDown: (level, value) => {
if (level === 'district') {
set({ currentLevel: 'district', selectedDistrict: value, selectedStreet: null, availableStreets: [] });
get().fetchStreets(value);
} else if (level === 'street') {
set({ currentLevel: 'street', selectedStreet: value });
}
},
drillUp: () => {
const { currentLevel } = get();
if (currentLevel === 'street') {
set({ currentLevel: 'district', selectedStreet: null });
} else if (currentLevel === 'district') {
set({ currentLevel: 'city', selectedDistrict: null, selectedStreet: null, availableStreets: [] });
} else {
set({ currentLevel: 'city', selectedDistrict: null, selectedStreet: null, availableStreets: [] });
}
},
fetchStreets: async (district) => {
set({ isLoadingStreets: true });
try {
const data = await geocodedApi.getStreets(district);
set({ availableStreets: data.streets || [], isLoadingStreets: false });
} catch {
set({ availableStreets: [], isLoadingStreets: false });
}
},
resetDrillDown: () => set({
currentLevel: 'city',
selectedDistrict: null,
selectedStreet: null,
availableStreets: [],
}),
}));

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand';
import axios from 'axios';
import type { GridRisk, GridDetail, Alert, Stats, ForecastDay } from '@/types';
import { riskApi, alertApi, gridApi } from '@/services/api';
import { riskApi, alertApi, gridApi, caseApi } from '@/services/api';
function isCancelError(e: unknown): boolean {
return axios.isCancel(e) || (e as Error)?.message === 'canceled';
@@ -84,7 +84,7 @@ export const useRiskStore = create<RiskState>((set, get) => ({
set({ alerts: data.alerts || [] });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载预警数据失败' });
set({ alerts: [], error: (e as Error).message || '加载预警数据失败' });
}
},
@@ -100,6 +100,8 @@ export const useRiskStore = create<RiskState>((set, get) => ({
}));
export { useAnalysisStore } from './analysisStore';
export { useDrilldownStore } from './drilldownStore';
export { useDiseaseStore } from './diseaseStore';
interface TimelineState {
@@ -166,13 +168,11 @@ interface MonitoringState {
gridFeatures: GridFeature[];
aggregatedData: Array<{ date: string; district: string; total_cases: number; avg_AQI: number }>;
districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
selectedDistrict: string | null;
isLoading: boolean;
error: string | null;
fetchGridFeatures: (date: string) => Promise<void>;
fetchAggregatedData: (startDate: string, endDate: string, district?: string) => Promise<void>;
fetchDistrictCases: () => Promise<void>;
setSelectedDistrict: (district: string | null) => void;
fetchDistrictCases: (diagnosis?: string) => Promise<void>;
clearError: () => void;
}
@@ -180,7 +180,6 @@ export const useMonitoringStore = create<MonitoringState>((set) => ({
gridFeatures: [],
aggregatedData: [],
districtCases: [],
selectedDistrict: null,
isLoading: false,
error: null,
@@ -220,19 +219,17 @@ export const useMonitoringStore = create<MonitoringState>((set) => ({
}
},
fetchDistrictCases: async () => {
fetchDistrictCases: async (diagnosis) => {
set({ isLoading: true, error: null });
try {
const { caseApi } = await import('@/services/api');
const data = await caseApi.getDistricts();
set({ districtCases: data.districts || [], isLoading: false });
const data = await caseApi.getDistricts(diagnosis ? { diagnosis } : undefined);
const districts = Array.isArray(data) ? data : (data as any).districts || [];
set({ districtCases: districts, isLoading: false });
} catch (e) {
if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载区县病例数据失败', isLoading: false });
}
},
setSelectedDistrict: (district) => set({ selectedDistrict: district }),
}));

View File

@@ -0,0 +1,54 @@
import { create } from 'zustand';
import { reportApi } from '@/services/api';
import type { ReportMetadata, ReportResponse } from '@/types';
interface ReportsState {
reports: ReportMetadata[];
currentReport: ReportResponse | null;
isLoading: boolean;
error: string | null;
fetchReportsList: (report_type?: string, limit?: number) => Promise<void>;
fetchReport: (reportId: string) => Promise<void>;
generateReport: (report_type: string, date?: string) => Promise<void>;
clearError: () => void;
}
export const useReportsStore = create<ReportsState>((set) => ({
reports: [],
currentReport: null,
isLoading: false,
error: null,
clearError: () => set({ error: null }),
fetchReportsList: async (report_type, limit = 20) => {
set({ isLoading: true, error: null });
try {
const data = await reportApi.getList({ report_type, limit });
set({ reports: data.reports || [], isLoading: false });
} catch (e) {
set({ error: (e as Error).message || '加载报告列表失败', isLoading: false });
}
},
fetchReport: async (reportId) => {
set({ isLoading: true, error: null });
try {
const data = await reportApi.getReport(reportId);
set({ currentReport: data, isLoading: false });
} catch (e) {
set({ error: (e as Error).message || '加载报告失败', isLoading: false });
}
},
generateReport: async (report_type, date) => {
set({ isLoading: true, error: null });
try {
const data = await reportApi.generateReport(report_type, date);
set({ currentReport: data, isLoading: false });
} catch (e) {
set({ error: (e as Error).message || '生成报告失败', isLoading: false });
}
},
}));

View File

@@ -117,9 +117,14 @@ export interface CaseInsight {
}
export interface CaseTrendResponse {
data: CaseTrendPoint[];
days: number;
timestamp: string;
trend: CaseTrendPoint[];
summary: {
total_outpatient: number;
total_inpatient: number;
period_count: number;
avg_daily_outpatient: number;
avg_daily_inpatient: number;
};
}
export interface DistrictCaseResponse {
@@ -128,8 +133,11 @@ export interface DistrictCaseResponse {
}
export interface CaseStatsResponse {
stats: CaseStats;
timestamp: string;
total_outpatient: number;
total_inpatient: number;
date_range: { start: string; end: string };
top_districts: Array<{ district: string; count: number }>;
top_diagnoses: Array<{ diagnosis: string; outpatient: number; inpatient: number }>;
}
// --- High-Resolution Geocoded Case Types ---
@@ -167,3 +175,68 @@ export interface GeocodedCasesResponse {
cases: GeocodedCase[];
total_count: number;
}
// --- Street-level types ---
export interface StreetData {
name: string;
total_cases: number;
outpatient: number;
inpatient: number;
}
// --- Report Types ---
export interface ReportMetadata {
report_id: string;
title: string;
type: 'daily' | 'weekly' | 'monthly' | 'custom';
generated_at: string;
period_start: string;
period_end: string;
author: string;
}
export interface ReportSummary {
total_cases: number;
avg_risk: number;
peak_risk_date: string;
peak_risk_value: number;
high_risk_areas: number;
trend_direction: 'improving' | 'stable' | 'worsening';
}
export interface ReportSection {
title: string;
content: string;
charts: string[];
}
export interface ReportRecommendation {
priority: 'high' | 'medium' | 'low';
category: 'prevention' | 'monitoring' | 'intervention' | 'resource_allocation';
title: string;
description: string;
target_areas: string[];
}
export interface DiagnosisBreakdown {
diagnosis: string;
outpatient: number;
inpatient: number;
total: number;
}
export interface ReportResponse {
metadata: ReportMetadata;
summary: ReportSummary;
sections: ReportSection[];
recommendations: ReportRecommendation[];
attachments: string[];
timestamp: string;
diagnosis_breakdown?: DiagnosisBreakdown[];
}
export interface ReportListResponse {
reports: ReportMetadata[];
total: number;
timestamp: string;
}