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,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)