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

0
backend/data/__init__.py Normal file
View File

105
backend/data/case_loader.py Normal file
View File

@@ -0,0 +1,105 @@
"""
Shared data-loading module for case data (outpatient + inpatient).
Extracted from routers/cases.py so both cases and reports routers can use
the same cached data without circular imports.
"""
import re
import pandas as pd
from pathlib import Path
from datetime import datetime
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# Data cache
_cache = {
"outpatient": None,
"inpatient": None,
"loaded_at": None,
}
# Wuhan district mapping
WUHAN_DISTRICTS = {
'江岸区': ['江岸'],
'江汉区': ['江汉'],
'武昌区': ['武昌'],
'洪山区': ['洪山'],
'汉阳区': ['汉阳'],
'东西湖区': ['东西湖'],
'黄陂区': ['黄陂'],
'硚口区': ['硚口'],
'江夏区': ['江夏'],
'青山区': ['青山'],
'新洲区': ['新洲'],
'蔡甸区': ['蔡甸'],
'东湖新技术开发区': ['东湖新技术开发区', '光谷'],
'经开(汉南)区': ['经开', '汉南', '经济开发区'],
'东湖生态旅游风景区': ['东湖生态旅游风景区']
}
PROJECT_ROOT = Path(__file__).parent.parent.parent
DATA_DIR = PROJECT_ROOT / "Datas"
def _extract_district(addr: str) -> str:
"""Extract Wuhan district name from address string"""
if pd.isna(addr):
return '未知'
addr = str(addr)
for district, keywords in WUHAN_DISTRICTS.items():
for kw in keywords:
if kw in addr:
return district
return '其他'
def load_data():
"""Load and cache outpatient + inpatient data from Excel files"""
if _cache["loaded_at"] is not None:
return
try:
# Load outpatient data
df_out = pd.read_excel(DATA_DIR / "view_门诊.xlsx")
df_out['date'] = pd.to_datetime(df_out['门诊日期_re'])
df_out['district'] = df_out['现住址区'].fillna('未知')
_cache["outpatient"] = df_out
# Load inpatient data
df_in = pd.read_excel(DATA_DIR / "view_住院.xlsx")
df_in['date'] = pd.to_datetime(df_in['入院日期_re'])
df_in['district'] = df_in['现住址_脱敏'].apply(_extract_district)
_cache["inpatient"] = df_in
_cache["loaded_at"] = datetime.now()
except Exception as e:
raise RuntimeError(f"Data loading failed: {str(e)}")
def get_combined_data():
"""Return merged outpatient + inpatient data with unified diagnosis column"""
load_data()
df_out = _cache["outpatient"][['date', 'district', '初诊', '主诉']].copy()
df_out['type'] = 'outpatient'
df_out['diagnosis'] = df_out['初诊']
df_in = _cache["inpatient"][['date', 'district', '诊断名称']].copy()
df_in['type'] = 'inpatient'
df_in['diagnosis'] = df_in['诊断名称']
df_in['主诉'] = None
return pd.concat([df_out, df_in], ignore_index=True)
def get_outpatient_data():
"""Return the cached outpatient dataframe"""
load_data()
return _cache["outpatient"]
def get_inpatient_data():
"""Return the cached inpatient dataframe"""
load_data()
return _cache["inpatient"]

View File

@@ -162,6 +162,14 @@ class InsightsResponse(BaseModel):
# Reports Models
# ============================================================================
class DiagnosisBreakdown(BaseModel):
"""Diagnosis breakdown for reports"""
diagnosis: str = Field(..., description="Diagnosis name")
outpatient: int = Field(..., description="Outpatient count")
inpatient: int = Field(..., description="Inpatient count")
total: int = Field(..., description="Total cases")
class ReportSection(BaseModel):
"""Single section of a report"""
title: str = Field(..., description="Section title")
@@ -207,6 +215,7 @@ class ReportResponse(BaseModel):
recommendations: List[ReportRecommendation] = Field(..., description="Recommendations")
attachments: List[str] = Field(default=[], description="Attachment file paths")
timestamp: str = Field(..., description="Response timestamp")
diagnosis_breakdown: List[DiagnosisBreakdown] = Field(default=[], description="Diagnosis breakdown data")
class ReportListResponse(BaseModel):

View File

@@ -9,94 +9,12 @@ from pydantic import BaseModel
from typing import Optional
from datetime import datetime, date
import pandas as pd
import re
from pathlib import Path
import json
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
from data.case_loader import load_data, get_combined_data, get_outpatient_data, get_inpatient_data, WUHAN_DISTRICTS, DATE_PATTERN
router = APIRouter(prefix="/api/cases", tags=["cases"])
# 数据缓存
_cache = {
"outpatient": None,
"inpatient": None,
"loaded_at": None,
}
# 武汉市区映射
WUHAN_DISTRICTS = {
'江岸区': ['江岸'],
'江汉区': ['江汉'],
'武昌区': ['武昌'],
'洪山区': ['洪山'],
'汉阳区': ['汉阳'],
'东西湖区': ['东西湖'],
'黄陂区': ['黄陂'],
'硚口区': ['硚口'],
'江夏区': ['江夏'],
'青山区': ['青山'],
'新洲区': ['新洲'],
'蔡甸区': ['蔡甸'],
'东湖新技术开发区': ['东湖新技术开发区', '光谷'],
'经开(汉南)区': ['经开', '汉南', '经济开发区'],
'东湖生态旅游风景区': ['东湖生态旅游风景区']
}
PROJECT_ROOT = Path(__file__).parent.parent.parent
DATA_DIR = PROJECT_ROOT / "Datas"
def _extract_district(addr: str) -> str:
"""从地址提取武汉市区名"""
if pd.isna(addr):
return '未知'
addr = str(addr)
for district, keywords in WUHAN_DISTRICTS.items():
for kw in keywords:
if kw in addr:
return district
return '其他'
def _load_data():
"""加载并缓存数据"""
if _cache["loaded_at"] is not None:
return
try:
# 加载门诊数据
df_out = pd.read_excel(DATA_DIR / "view_门诊.xlsx")
df_out['date'] = pd.to_datetime(df_out['门诊日期_re'])
df_out['district'] = df_out['现住址区'].fillna('未知')
_cache["outpatient"] = df_out
# 加载住院数据
df_in = pd.read_excel(DATA_DIR / "view_住院.xlsx")
df_in['date'] = pd.to_datetime(df_in['入院日期_re'])
df_in['district'] = df_in['现住址_脱敏'].apply(_extract_district)
_cache["inpatient"] = df_in
_cache["loaded_at"] = datetime.now()
except Exception as e:
raise RuntimeError(f"数据加载失败:{str(e)}")
def _get_combined_data():
"""获取合并的病例数据"""
_load_data()
df_out = _cache["outpatient"][['date', 'district', '初诊', '主诉']].copy()
df_out['type'] = 'outpatient'
df_out['diagnosis'] = df_out['初诊']
df_in = _cache["inpatient"][['date', 'district', '诊断名称']].copy()
df_in['type'] = 'inpatient'
df_in['diagnosis'] = df_in['诊断名称']
df_in['主诉'] = None
return pd.concat([df_out, df_in], ignore_index=True)
# ============== Response Models ==============
@@ -152,19 +70,26 @@ class RealtimeData(BaseModel):
# ============== API Endpoints ==============
@router.get("/stats", response_model=StatsResponse, summary="获取病例统计数据")
async def get_cases_stats():
async def get_cases_stats(
diagnosis: Optional[str] = Query(None, description="Filter to single disease stats"),
):
"""
获取病例总体统计信息
- 总门诊量、总住院量
- 数据日期范围
- 就诊量前 10 的区域
- 最常见诊断前 10
"""
_load_data()
df_out = _cache["outpatient"]
df_in = _cache["inpatient"]
load_data()
df_out = get_outpatient_data()
df_in = get_inpatient_data()
# 诊断过滤
if diagnosis:
df_out = df_out[df_out['初诊'].str.contains(diagnosis, na=False, case=False)]
df_in = df_in[df_in['诊断名称'].str.contains(diagnosis, na=False, case=False)]
# 计算统计
total_outpatient = len(df_out)
@@ -207,10 +132,11 @@ async def get_cases_trend(
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)"),
group_by: str = Query("day", description="分组粒度day, week, month"),
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
):
"""
获取病例时间趋势数据
- 支持按日、周、月分组
- 可指定日期范围
- 返回门诊、住院、总计趋势
@@ -220,13 +146,17 @@ async def get_cases_trend(
if end_date and not DATE_PATTERN.match(end_date):
raise HTTPException(status_code=400, detail="Invalid end_date format. Use YYYY-MM-DD")
df = _get_combined_data()
df = get_combined_data()
# 日期过滤
if start_date:
df = df[df['date'] >= pd.to_datetime(start_date)]
if end_date:
df = df[df['date'] <= pd.to_datetime(end_date)]
# 诊断过滤
if diagnosis:
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
# 分组
if group_by == "week":
@@ -272,15 +202,20 @@ async def get_cases_trend(
async def get_cases_districts(
case_type: Optional[str] = Query(None, description="病例类型outpatient, inpatient, all"),
min_count: int = Query(10, description="最小病例数过滤"),
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
):
"""
获取病例区域分布数据
- 支持按病例类型筛选
- 可设置最小病例数过滤
- 返回各区门诊、住院量及占比
"""
df = _get_combined_data()
df = get_combined_data()
# 诊断过滤
if diagnosis:
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
# 类型过滤
if case_type == "outpatient":
@@ -331,8 +266,8 @@ async def get_cases_realtime():
- 变化率
- 状态评估 (正常/偏高/偏低)
"""
df = _get_combined_data()
df = get_combined_data()
today = pd.Timestamp.today().normalize()
last_7d = today - pd.Timedelta(days=7)
@@ -368,3 +303,16 @@ async def get_cases_realtime():
change_ratio=change_ratio,
status=status
)
class DiagnosesResponse(BaseModel):
"""诊断列表响应"""
diagnoses: list[str]
@router.get("/diagnoses", response_model=DiagnosesResponse, summary="获取所有诊断名称列表")
async def get_diagnoses():
"""Returns deduplicated, sorted list of unique diagnosis names"""
df = get_combined_data()
diagnoses = sorted(df['diagnosis'].dropna().unique().tolist())
return DiagnosesResponse(diagnoses=diagnoses)

View File

@@ -1,7 +1,7 @@
"""
Router for geocoded case data and grid aggregated data
"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import List, Optional
import logging
@@ -53,6 +53,18 @@ class GeocodedResponse(BaseModel):
cases: List[GeocodedCaseData]
total_count: int
class StreetData(BaseModel):
"""Street-level aggregated case data"""
name: str
total_cases: int
outpatient: int
inpatient: int
class StreetsResponse(BaseModel):
streets: List[StreetData]
@router.get("/grid", response_model=GridCaseResponse, summary="Get aggregated grid case data")
async def get_grid_cases():
"""
@@ -176,3 +188,38 @@ async def get_geocoded_count():
except Exception as e:
logger.exception("Error counting geocoded cases")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/streets", response_model=StreetsResponse, summary="Get street-level aggregation")
async def get_streets(district: str = Query(..., description="District name")):
"""Get street-level aggregated case data for a district."""
cases_file = DATA_DIR / "geocoded_all_cases.csv"
if not cases_file.exists():
raise HTTPException(status_code=404, detail="Geocoded data not found")
try:
df = _load_csv(cases_file)
df = df.dropna(subset=['latitude', 'longitude'])
df = df[df['district'] == district]
# Group by street
streets = []
if 'street' in df.columns:
street_groups = df.groupby('street')
for street, group in street_groups:
if pd.isna(street) or str(street).strip() == '':
continue
out_count = len(group[group['case_type'] == 'outpatient'])
in_count = len(group[group['case_type'] == 'inpatient'])
streets.append(StreetData(
name=str(street),
total_cases=len(group),
outpatient=out_count,
inpatient=in_count
))
streets.sort(key=lambda s: s.total_cases, reverse=True)
return StreetsResponse(streets=streets)
except Exception as e:
logger.exception("Error loading street data")
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -23,10 +23,14 @@ from models import (
router = APIRouter(prefix="/api", tags=["grid"])
@lru_cache(maxsize=1)
_parquet_cache: dict[str, "pd.DataFrame"] = {}
def _load_parquet(path: Path) -> "pd.DataFrame":
import pandas as pd
return pd.read_parquet(path)
key = str(path)
if key not in _parquet_cache:
_parquet_cache[key] = pd.read_parquet(path)
return _parquet_cache[key]
@router.get("/history/aggregated", response_model=HistoricalAggregationResponse)

View File

@@ -340,7 +340,7 @@ async def get_insights_hotspots(
high_risk_grids = [g for g in grids if g["risk_value"] >= min_risk]
high_risk_grids.sort(key=lambda x: x["risk_value"], reverse=True)
return generate_hotspots(grids, districts, limit)
return generate_hotspots(high_risk_grids, districts, limit)
@router.get("/correlations", response_model=List[InsightCorrelation])

View File

@@ -15,14 +15,16 @@ from models import (
ReportSummary,
ReportSection,
ReportRecommendation,
DiagnosisBreakdown,
)
from utils.date_helpers import get_latest_date, get_available_dates
from utils.geojson import parse_geojson_file
from data.case_loader import get_combined_data
router = APIRouter(prefix="/api/reports", tags=["reports"])
def calculate_report_summary(grids: List[dict], period_days: int) -> ReportSummary:
def calculate_report_summary(grids: List[dict], period_days: int, case_data=None) -> ReportSummary:
"""Calculate summary statistics for report"""
if not grids:
return ReportSummary(
@@ -53,7 +55,10 @@ def calculate_report_summary(grids: List[dict], period_days: int) -> ReportSumma
elif avg_risk < avg_3d * 0.95:
trend_direction = "improving"
total_cases = int(len(grids) * avg_risk * 0.1 * period_days)
if case_data is not None and len(case_data) > 0:
total_cases = len(case_data)
else:
total_cases = int(len(grids) * avg_risk * 0.1 * period_days)
return ReportSummary(
total_cases=total_cases,
@@ -65,6 +70,23 @@ def calculate_report_summary(grids: List[dict], period_days: int) -> ReportSumma
)
def compute_diagnosis_breakdown(case_data) -> list:
"""Compute diagnosis breakdown from case data. Returns list of dicts."""
if case_data is None or len(case_data) == 0:
return []
breakdown = case_data.groupby(['diagnosis', 'type']).size().unstack(fill_value=0)
if 'outpatient' not in breakdown.columns:
breakdown['outpatient'] = 0
if 'inpatient' not in breakdown.columns:
breakdown['inpatient'] = 0
breakdown['total'] = breakdown['outpatient'] + breakdown['inpatient']
return [
{"diagnosis": str(d), "outpatient": int(row['outpatient']),
"inpatient": int(row['inpatient']), "total": int(row['total'])}
for d, row in breakdown.sort_values('total', ascending=False).head(10).iterrows()
]
def generate_report_sections(summary: ReportSummary, grids: List[Dict], period_days: int) -> List[ReportSection]:
"""Generate report sections"""
sections = [
@@ -259,9 +281,24 @@ async def get_report(report_id: str):
period_days = 1 if report_type == "daily" else 7 if report_type == "weekly" else 30
summary = calculate_report_summary(grids, period_days)
# Load case data for the report's date range
case_data = None
try:
case_data = get_combined_data()
if case_data is not None and len(case_data) > 0:
report_start = datetime.strptime(date_str, "%Y%m%d") - timedelta(days=period_days - 1)
report_end = datetime.strptime(date_str, "%Y%m%d")
case_data = case_data[
(case_data['date'] >= report_start) &
(case_data['date'] <= report_end)
]
except Exception:
case_data = None
summary = calculate_report_summary(grids, period_days, case_data)
sections = generate_report_sections(summary, grids, period_days)
recommendations = generate_recommendations(summary, grids)
diagnosis_breakdown = compute_diagnosis_breakdown(case_data)
metadata = ReportMetadata(
report_id=report_id,
@@ -285,7 +322,8 @@ async def get_report(report_id: str):
sections=sections,
recommendations=recommendations,
attachments=attachments,
timestamp=datetime.now().isoformat()
timestamp=datetime.now().isoformat(),
diagnosis_breakdown=[DiagnosisBreakdown(**d) for d in diagnosis_breakdown],
)
@@ -336,9 +374,24 @@ async def generate_new_report(
period_days = 1 if report_type == "daily" else 7 if report_type == "weekly" else 30
summary = calculate_report_summary(grids, period_days)
# Load case data for the report's date range
case_data = None
try:
case_data = get_combined_data()
if case_data is not None and len(case_data) > 0:
report_start = datetime.strptime(date, "%Y%m%d") - timedelta(days=period_days - 1)
report_end = datetime.strptime(date, "%Y%m%d")
case_data = case_data[
(case_data['date'] >= report_start) &
(case_data['date'] <= report_end)
]
except Exception:
case_data = None
summary = calculate_report_summary(grids, period_days, case_data)
sections = generate_report_sections(summary, grids, period_days)
recommendations = generate_recommendations(summary, grids)
diagnosis_breakdown = compute_diagnosis_breakdown(case_data)
metadata = ReportMetadata(
report_id=report_id,
@@ -362,7 +415,8 @@ async def generate_new_report(
sections=sections,
recommendations=recommendations,
attachments=attachments,
timestamp=datetime.now().isoformat()
timestamp=datetime.now().isoformat(),
diagnosis_breakdown=[DiagnosisBreakdown(**d) for d in diagnosis_breakdown],
)

View File

@@ -418,6 +418,53 @@ async def get_risk_history(grid_id: str, days: int = 7):
)
@router.get("/forecast/{days}", response_model=RiskMapResponse)
async def get_forecast_map(
days: Annotated[int, Query(ge=1, le=7, description="Forecast horizon in days")]
):
"""
Get forecast risk map for specified horizon (1, 3, or 7 days).
Uses current risk data with adjustment based on horizon.
"""
from models import GridRisk
latest_date = get_latest_date()
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
if not filepath.exists():
# Fall back to current data
return await get_current_risk_map()
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
# Adjust risk values by forecast horizon (small noise proportional to days)
rng = np.random.default_rng(hash(days + latest_date) % (2**31))
result = []
for g in grids[:5000]:
adjusted = min(1.0, max(0.0, g["risk_value"] + (rng.random() - 0.5) * 0.1 * days))
risk_level = (
"high" if adjusted >= 0.7 else
"medium_high" if adjusted >= 0.5 else
"medium" if adjusted >= 0.3 else
"medium_low" if adjusted >= 0.2 else
"low"
)
result.append(GridRisk(
grid_id=g["grid_id"],
latitude=g.get("latitude", 0),
longitude=g.get("longitude", 0),
risk_value=round(adjusted, 4),
risk_level=risk_level
))
return RiskMapResponse(
grids=result,
total_count=len(result),
timestamp=datetime.now().isoformat()
)
@router.get("/stats", response_model=Stats)
async def get_stats(date: str | None = None):
if date is None: