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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user