From 8ddd8e87bb29ae00ccbef273bad475e45a8ff371 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Mon, 8 Jun 2026 18:40:08 +0800 Subject: [PATCH] feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/data/__init__.py | 0 backend/data/case_loader.py | 105 ++++++ backend/models.py | 9 + backend/routers/cases.py | 138 +++----- backend/routers/geocoded.py | 49 ++- backend/routers/grid.py | 8 +- backend/routers/insights.py | 2 +- backend/routers/reports.py | 66 +++- backend/routers/risk.py | 47 +++ frontend/src/App.tsx | 5 +- frontend/src/components/AdminBreadcrumb.tsx | 52 +++ frontend/src/components/CaseLocationMap.tsx | 49 ++- frontend/src/components/CaseMap.tsx | 6 +- frontend/src/components/DiseaseFilter.tsx | 124 +++++++ frontend/src/components/RiskMap.tsx | 2 +- frontend/src/components/SideNav.tsx | 83 ++--- frontend/src/components/TimelinePlayer.tsx | 8 +- frontend/src/components/TopNav.tsx | 17 +- frontend/src/pages/AlertsDashboard.tsx | 21 +- frontend/src/pages/DistrictComparison.tsx | 8 +- frontend/src/pages/Insights.tsx | 8 +- frontend/src/pages/MonitoringDashboard.tsx | 203 +++++++----- frontend/src/pages/ReportsCenter.tsx | 349 ++++++++++++++++++++ frontend/src/pages/TrendAnalysis.tsx | 8 +- frontend/src/services/api.ts | 44 ++- frontend/src/stores/diseaseStore.ts | 38 +++ frontend/src/stores/drilldownStore.ts | 63 ++++ frontend/src/stores/index.ts | 21 +- frontend/src/stores/reportsStore.ts | 54 +++ frontend/src/types/index.ts | 83 ++++- 30 files changed, 1368 insertions(+), 302 deletions(-) create mode 100644 backend/data/__init__.py create mode 100644 backend/data/case_loader.py create mode 100644 frontend/src/components/AdminBreadcrumb.tsx create mode 100644 frontend/src/components/DiseaseFilter.tsx create mode 100644 frontend/src/pages/ReportsCenter.tsx create mode 100644 frontend/src/stores/diseaseStore.ts create mode 100644 frontend/src/stores/drilldownStore.ts create mode 100644 frontend/src/stores/reportsStore.ts diff --git a/backend/data/__init__.py b/backend/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/case_loader.py b/backend/data/case_loader.py new file mode 100644 index 0000000..f451b9e --- /dev/null +++ b/backend/data/case_loader.py @@ -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"] diff --git a/backend/models.py b/backend/models.py index 3b8ccab..f6775ff 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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): diff --git a/backend/routers/cases.py b/backend/routers/cases.py index 5914b02..1659d6e 100644 --- a/backend/routers/cases.py +++ b/backend/routers/cases.py @@ -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) diff --git a/backend/routers/geocoded.py b/backend/routers/geocoded.py index 3ff92d1..02c0c4d 100644 --- a/backend/routers/geocoded.py +++ b/backend/routers/geocoded.py @@ -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") diff --git a/backend/routers/grid.py b/backend/routers/grid.py index 8252c34..27fbc79 100644 --- a/backend/routers/grid.py +++ b/backend/routers/grid.py @@ -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) diff --git a/backend/routers/insights.py b/backend/routers/insights.py index 7b1148b..52c7a65 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -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]) diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 1a26713..77f7fbb 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -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], ) diff --git a/backend/routers/risk.py b/backend/routers/risk.py index 044281c..5455178 100644 --- a/backend/routers/risk.py +++ b/backend/routers/risk.py @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4ea55cb..9782f1b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(() => 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' && } {activePage === 'district-comparison' && } {activePage === 'insights' && } + {activePage === 'reports' && } diff --git a/frontend/src/components/AdminBreadcrumb.tsx b/frontend/src/components/AdminBreadcrumb.tsx new file mode 100644 index 0000000..7379216 --- /dev/null +++ b/frontend/src/components/AdminBreadcrumb.tsx @@ -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 ( +
+ + + + + + {showStreetDropdown && (<> + + {isLoadingStreets ? (加载街道...) + : hasStreets ? ( + + ) : (该区域暂无街道数据)} + )} +
+ ); +} diff --git a/frontend/src/components/CaseLocationMap.tsx b/frontend/src/components/CaseLocationMap.tsx index ebad330..6e99739 100644 --- a/frontend/src/components/CaseLocationMap.tsx +++ b/frontend/src/components/CaseLocationMap.tsx @@ -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(null); const mapInstanceRef = useRef(null); const layerRef = useRef(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(); - 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 (
@@ -114,3 +127,5 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
); } + +export const CaseLocationMap = memo(CaseLocationMapComponent); diff --git a/frontend/src/components/CaseMap.tsx b/frontend/src/components/CaseMap.tsx index 8c0b01c..6c5c430 100644 --- a/frontend/src/components/CaseMap.tsx +++ b/frontend/src/components/CaseMap.tsx @@ -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 || []); diff --git a/frontend/src/components/DiseaseFilter.tsx b/frontend/src/components/DiseaseFilter.tsx new file mode 100644 index 0000000..c89aa88 --- /dev/null +++ b/frontend/src/components/DiseaseFilter.tsx @@ -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(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 ( +
+ + + {isOpen && ( +
+ {/* Search input */} +
+
+ + setSearch(e.target.value)} + placeholder="搜索诊断..." + className="flex-1 bg-transparent text-xs outline-none" + /> + {search && ( + + )} +
+
+ + {/* Quick actions */} +
+ + | + +
+ + {/* Options list */} +
+ {isLoading ? ( +
加载诊断列表...
+ ) : filtered.length === 0 ? ( +
+ {availableDiagnoses.length === 0 ? '暂无可选诊断' : '无匹配诊断'} +
+ ) : ( + filtered.map((diagnosis) => ( + + )) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/RiskMap.tsx b/frontend/src/components/RiskMap.tsx index bb6230c..bd9ecf0 100644 --- a/frontend/src/components/RiskMap.tsx +++ b/frontend/src/components/RiskMap.tsx @@ -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'; diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx index a1b796d..9a95761 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -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: ( + + + + ), + items: [ + { id: 'monitoring', label: '监测面板' }, + ], + }, + { + id: 'alert', + label: '预警', + icon: ( + + + + ), + items: [ + { id: 'alerts', label: '预警地图' }, + ], + }, + { + id: 'analysis', + label: '分析', + icon: ( + + + + ), + 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('monitoring'); - const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [ - { - id: 'monitoring', - label: '监测', - icon: ( - - - - ), - items: [ - { id: 'monitoring', label: '监测面板' }, - ], - }, - { - id: 'alert', - label: '预警', - icon: ( - - - - ), - items: [ - { id: 'alerts', label: '预警地图' }, - ], - }, - { - id: 'analysis', - label: '分析', - icon: ( - - - - ), - items: [ - { id: 'trend-analysis', label: '趋势分析' }, - { id: 'district-comparison', label: '区域对比' }, - { id: 'insights', label: '智能洞察' }, - ], - }, - ]; - const handleItemClick = (moduleId: string, itemId: string) => { setExpanded(moduleId); onPageChange(itemId); diff --git a/frontend/src/components/TimelinePlayer.tsx b/frontend/src/components/TimelinePlayer.tsx index 1c67fc1..b1ed296 100644 --- a/frontend/src/components/TimelinePlayer.tsx +++ b/frontend/src/components/TimelinePlayer.tsx @@ -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); diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index f2599fc..d661a2d 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -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 {time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}; +} + +export function TopNav({ onLogout }: TopNavProps) { return (