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 # 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): class ReportSection(BaseModel):
"""Single section of a report""" """Single section of a report"""
title: str = Field(..., description="Section title") title: str = Field(..., description="Section title")
@@ -207,6 +215,7 @@ class ReportResponse(BaseModel):
recommendations: List[ReportRecommendation] = Field(..., description="Recommendations") recommendations: List[ReportRecommendation] = Field(..., description="Recommendations")
attachments: List[str] = Field(default=[], description="Attachment file paths") attachments: List[str] = Field(default=[], description="Attachment file paths")
timestamp: str = Field(..., description="Response timestamp") timestamp: str = Field(..., description="Response timestamp")
diagnosis_breakdown: List[DiagnosisBreakdown] = Field(default=[], description="Diagnosis breakdown data")
class ReportListResponse(BaseModel): class ReportListResponse(BaseModel):

View File

@@ -9,94 +9,12 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import datetime, date from datetime import datetime, date
import pandas as pd import pandas as pd
import re
from pathlib import Path
import json 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"]) 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 ============== # ============== Response Models ==============
@@ -152,7 +70,9 @@ class RealtimeData(BaseModel):
# ============== API Endpoints ============== # ============== API Endpoints ==============
@router.get("/stats", response_model=StatsResponse, summary="获取病例统计数据") @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"),
):
""" """
获取病例总体统计信息 获取病例总体统计信息
@@ -161,10 +81,15 @@ async def get_cases_stats():
- 就诊量前 10 的区域 - 就诊量前 10 的区域
- 最常见诊断前 10 - 最常见诊断前 10
""" """
_load_data() load_data()
df_out = _cache["outpatient"] df_out = get_outpatient_data()
df_in = _cache["inpatient"] 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) total_outpatient = len(df_out)
@@ -207,6 +132,7 @@ async def get_cases_trend(
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"), start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"),
end_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"), group_by: str = Query("day", description="分组粒度day, week, month"),
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
): ):
""" """
获取病例时间趋势数据 获取病例时间趋势数据
@@ -220,7 +146,7 @@ async def get_cases_trend(
if end_date and not DATE_PATTERN.match(end_date): if end_date and not DATE_PATTERN.match(end_date):
raise HTTPException(status_code=400, detail="Invalid end_date format. Use YYYY-MM-DD") 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: if start_date:
@@ -228,6 +154,10 @@ async def get_cases_trend(
if end_date: if end_date:
df = df[df['date'] <= pd.to_datetime(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": if group_by == "week":
df['period'] = df['date'].dt.to_period('W').dt.start_time df['period'] = df['date'].dt.to_period('W').dt.start_time
@@ -272,6 +202,7 @@ async def get_cases_trend(
async def get_cases_districts( async def get_cases_districts(
case_type: Optional[str] = Query(None, description="病例类型outpatient, inpatient, all"), case_type: Optional[str] = Query(None, description="病例类型outpatient, inpatient, all"),
min_count: int = Query(10, description="最小病例数过滤"), min_count: int = Query(10, description="最小病例数过滤"),
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
): ):
""" """
获取病例区域分布数据 获取病例区域分布数据
@@ -280,7 +211,11 @@ async def get_cases_districts(
- 可设置最小病例数过滤 - 可设置最小病例数过滤
- 返回各区门诊、住院量及占比 - 返回各区门诊、住院量及占比
""" """
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": if case_type == "outpatient":
@@ -331,7 +266,7 @@ async def get_cases_realtime():
- 变化率 - 变化率
- 状态评估 (正常/偏高/偏低) - 状态评估 (正常/偏高/偏低)
""" """
df = _get_combined_data() df = get_combined_data()
today = pd.Timestamp.today().normalize() today = pd.Timestamp.today().normalize()
last_7d = today - pd.Timedelta(days=7) last_7d = today - pd.Timedelta(days=7)
@@ -368,3 +303,16 @@ async def get_cases_realtime():
change_ratio=change_ratio, change_ratio=change_ratio,
status=status 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 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 pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
import logging import logging
@@ -53,6 +53,18 @@ class GeocodedResponse(BaseModel):
cases: List[GeocodedCaseData] cases: List[GeocodedCaseData]
total_count: int 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") @router.get("/grid", response_model=GridCaseResponse, summary="Get aggregated grid case data")
async def get_grid_cases(): async def get_grid_cases():
""" """
@@ -176,3 +188,38 @@ async def get_geocoded_count():
except Exception as e: except Exception as e:
logger.exception("Error counting geocoded cases") logger.exception("Error counting geocoded cases")
raise HTTPException(status_code=500, detail="Internal server error") 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"]) router = APIRouter(prefix="/api", tags=["grid"])
@lru_cache(maxsize=1) _parquet_cache: dict[str, "pd.DataFrame"] = {}
def _load_parquet(path: Path) -> "pd.DataFrame": def _load_parquet(path: Path) -> "pd.DataFrame":
import pandas as pd 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) @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 = [g for g in grids if g["risk_value"] >= min_risk]
high_risk_grids.sort(key=lambda x: x["risk_value"], reverse=True) 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]) @router.get("/correlations", response_model=List[InsightCorrelation])

View File

@@ -15,14 +15,16 @@ from models import (
ReportSummary, ReportSummary,
ReportSection, ReportSection,
ReportRecommendation, ReportRecommendation,
DiagnosisBreakdown,
) )
from utils.date_helpers import get_latest_date, get_available_dates from utils.date_helpers import get_latest_date, get_available_dates
from utils.geojson import parse_geojson_file from utils.geojson import parse_geojson_file
from data.case_loader import get_combined_data
router = APIRouter(prefix="/api/reports", tags=["reports"]) 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""" """Calculate summary statistics for report"""
if not grids: if not grids:
return ReportSummary( return ReportSummary(
@@ -53,6 +55,9 @@ def calculate_report_summary(grids: List[dict], period_days: int) -> ReportSumma
elif avg_risk < avg_3d * 0.95: elif avg_risk < avg_3d * 0.95:
trend_direction = "improving" trend_direction = "improving"
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) total_cases = int(len(grids) * avg_risk * 0.1 * period_days)
return ReportSummary( return ReportSummary(
@@ -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]: def generate_report_sections(summary: ReportSummary, grids: List[Dict], period_days: int) -> List[ReportSection]:
"""Generate report sections""" """Generate report sections"""
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 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) sections = generate_report_sections(summary, grids, period_days)
recommendations = generate_recommendations(summary, grids) recommendations = generate_recommendations(summary, grids)
diagnosis_breakdown = compute_diagnosis_breakdown(case_data)
metadata = ReportMetadata( metadata = ReportMetadata(
report_id=report_id, report_id=report_id,
@@ -285,7 +322,8 @@ async def get_report(report_id: str):
sections=sections, sections=sections,
recommendations=recommendations, recommendations=recommendations,
attachments=attachments, 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 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) sections = generate_report_sections(summary, grids, period_days)
recommendations = generate_recommendations(summary, grids) recommendations = generate_recommendations(summary, grids)
diagnosis_breakdown = compute_diagnosis_breakdown(case_data)
metadata = ReportMetadata( metadata = ReportMetadata(
report_id=report_id, report_id=report_id,
@@ -362,7 +415,8 @@ async def generate_new_report(
sections=sections, sections=sections,
recommendations=recommendations, recommendations=recommendations,
attachments=attachments, 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) @router.get("/stats", response_model=Stats)
async def get_stats(date: str | None = None): async def get_stats(date: str | None = None):
if date is None: if date is None:

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 TrendAnalysis = lazy(() => import('@/pages/TrendAnalysis').then(m => ({ default: m.TrendAnalysis })));
const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison }))); const DistrictComparison = lazy(() => import('@/pages/DistrictComparison').then(m => ({ default: m.DistrictComparison })));
const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights }))); const Insights = lazy(() => import('@/pages/Insights').then(m => ({ default: m.Insights })));
const ReportsCenter = lazy(() => import('@/pages/ReportsCenter').then(m => ({ default: m.ReportsCenter })));
interface Props { interface Props {
@@ -62,7 +63,8 @@ function PageLoader() {
function App() { function App() {
const [activePage, setActivePage] = useState('monitoring'); const [activePage, setActivePage] = useState('monitoring');
const [token, setToken] = useState<string | null>(() => localStorage.getItem('cbpoa_token')); 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(() => { useEffect(() => {
if (token) fetchAlerts(); if (token) fetchAlerts();
@@ -108,6 +110,7 @@ function App() {
{activePage === 'trend-analysis' && <TrendAnalysis />} {activePage === 'trend-analysis' && <TrendAnalysis />}
{activePage === 'district-comparison' && <DistrictComparison />} {activePage === 'district-comparison' && <DistrictComparison />}
{activePage === 'insights' && <Insights />} {activePage === 'insights' && <Insights />}
{activePage === 'reports' && <ReportsCenter />}
</Suspense> </Suspense>
</main> </main>
</div> </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'; import L from 'leaflet';
import { geocodedApi } from '@/services/api';
interface CaseLocation { import type { GeocodedCase } from '@/types';
case_id: string;
case_type: string;
latitude: number;
longitude: number;
district: string;
street: string;
}
const WUHAN_CENTER: [number, number] = [30.59, 114.31]; 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 mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null); const mapInstanceRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null); const layerRef = useRef<L.LayerGroup | null>(null);
const cancelledRef = useRef(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [caseCount, setCaseCount] = useState(0); const [caseCount, setCaseCount] = useState(0);
useEffect(() => { useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return; if (!mapRef.current || mapInstanceRef.current) return;
setIsLoading(true);
cancelledRef.current = false;
const map = L.map(mapRef.current, { const map = L.map(mapRef.current, {
center: WUHAN_CENTER, center: WUHAN_CENTER,
zoom: 11, zoom: 11,
@@ -37,10 +40,10 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
layerRef.current = L.layerGroup().addTo(map); layerRef.current = L.layerGroup().addTo(map);
// Fetch case locations // Fetch case locations
fetch('/api/geocoded/geocoded?limit=5000') geocodedApi.getGeocoded({ limit: 5000, district: district || undefined })
.then((res) => res.json())
.then((data) => { .then((data) => {
const cases: CaseLocation[] = data.cases || []; if (cancelledRef.current) return;
const cases: GeocodedCase[] = data.cases || [];
const layer = layerRef.current; const layer = layerRef.current;
if (!layer) return; if (!layer) return;
@@ -48,7 +51,7 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
// Deduplicate by case_id to avoid overlapping markers // Deduplicate by case_id to avoid overlapping markers
const seen = new Set<string>(); const seen = new Set<string>();
const unique: CaseLocation[] = []; let unique: GeocodedCase[] = [];
for (const c of cases) { for (const c of cases) {
if (!seen.has(c.case_id)) { if (!seen.has(c.case_id)) {
seen.add(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) { for (const c of unique) {
if (!c.latitude || !c.longitude) continue; if (!c.latitude || !c.longitude) continue;
@@ -88,13 +98,16 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
map.fitBounds(bounds, { padding: [30, 30] }); map.fitBounds(bounds, { padding: [30, 30] });
} }
}) })
.catch(() => setIsLoading(false)); .catch(() => {
if (!cancelledRef.current) setIsLoading(false);
});
return () => { return () => {
cancelledRef.current = true;
map.remove(); map.remove();
mapInstanceRef.current = null; mapInstanceRef.current = null;
}; };
}, []); }, [district, street]);
return ( return (
<div className="relative"> <div className="relative">
@@ -114,3 +127,5 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
</div> </div>
); );
} }
export const CaseLocationMap = memo(CaseLocationMapComponent);

View File

@@ -1,7 +1,7 @@
import { memo, useEffect, useRef, useState, useCallback } from 'react'; import { memo, useEffect, useRef, useState, useCallback } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { caseApi } from '@/services/api'; import { geocodedApi } from '@/services/api';
import type { CaseGrid, GeocodedCase } from '@/types'; import type { CaseGrid, GeocodedCase } from '@/types';
interface CaseMapProps { interface CaseMapProps {
@@ -74,8 +74,8 @@ function CaseMapComponent({ height = '480px' }: CaseMapProps) {
setError(null); setError(null);
try { try {
const [gridRes, geoRes] = await Promise.all([ const [gridRes, geoRes] = await Promise.all([
caseApi.getGrid(), geocodedApi.getGrid(),
caseApi.getGeocoded(5000), geocodedApi.getGeocoded({ limit: 5000 }),
]); ]);
if (cancelled) return; if (cancelled) return;
setGrids(gridRes.grids || []); 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(() => { useEffect(() => {
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange }; callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
}); }, [onGridSelect, onClosePanel, onFullscreen, onForecastChange]);
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px'; const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';

View File

@@ -6,14 +6,7 @@ interface SideNavProps {
alertCount?: number; alertCount?: number;
} }
export function SideNav({ const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
activePage,
onPageChange,
alertCount = 0,
}: SideNavProps) {
const [expanded, setExpanded] = useState<string | null>('monitoring');
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
{ {
id: 'monitoring', id: 'monitoring',
label: '监测', label: '监测',
@@ -50,9 +43,17 @@ export function SideNav({
{ id: 'trend-analysis', label: '趋势分析' }, { id: 'trend-analysis', label: '趋势分析' },
{ id: 'district-comparison', label: '区域对比' }, { id: 'district-comparison', label: '区域对比' },
{ id: 'insights', label: '智能洞察' }, { id: 'insights', label: '智能洞察' },
{ id: 'reports', label: '报表中心' },
], ],
}, },
]; ];
export function SideNav({
activePage,
onPageChange,
alertCount = 0,
}: SideNavProps) {
const [expanded, setExpanded] = useState<string | null>('monitoring');
const handleItemClick = (moduleId: string, itemId: string) => { const handleItemClick = (moduleId: string, itemId: string) => {
setExpanded(moduleId); setExpanded(moduleId);

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

View File

@@ -4,15 +4,16 @@ interface TopNavProps {
onLogout?: () => void; onLogout?: () => void;
} }
export function TopNav({ onLogout }: TopNavProps) { function Clock() {
const [currentTime, setCurrentTime] = useState(''); const [time, setTime] = useState(new Date());
useEffect(() => { useEffect(() => {
const update = () => setCurrentTime(new Date().toLocaleString('zh-CN')); const id = setInterval(() => setTime(new Date()), 1000);
update(); return () => clearInterval(id);
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, []); }, []);
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
}
export function TopNav({ onLogout }: TopNavProps) {
return ( 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"> <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"> <div className="ml-auto flex items-center gap-5">
<span className="text-[12px] text-text-muted"> <span className="text-[12px] text-text-muted">
{currentTime} <Clock />
</span> </span>
<div className="flex items-center gap-2 text-[13px] text-text-secondary"> <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"> <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() { 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 [selectedHorizon, setSelectedHorizon] = useState<number | 'all'>('all');
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all'); const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk'); const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
@@ -474,7 +479,8 @@ export function AlertsDashboard() {
key={alert.alert_id} key={alert.alert_id}
alert={alert} alert={alert}
isSelected={selectedAlert === alert.alert_id} isSelected={selectedAlert === alert.alert_id}
onClick={() => handleAlertCardClick(alert.alert_id)} alertId={alert.alert_id}
onCardClick={handleAlertCardClick}
/> />
))} ))}
{filteredAlerts.length > 50 && ( {filteredAlerts.length > 50 && (
@@ -579,19 +585,24 @@ export function AlertsDashboard() {
interface AlertCardProps { interface AlertCardProps {
alert: ExtendedAlert; alert: ExtendedAlert;
isSelected?: boolean; 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 isP1 = alert.priority === 'P1';
const riskPercent = Math.round(alert.risk_value * 100); const riskPercent = Math.round(alert.risk_value * 100);
const handleClick = useCallback(() => {
onCardClick(alertId);
}, [alertId, onCardClick]);
return ( return (
<div <div
className={`card overflow-hidden transition-colors cursor-pointer ${ className={`card overflow-hidden transition-colors cursor-pointer ${
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary' 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={`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"> <div className="flex items-center justify-between">

View File

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

View File

@@ -45,11 +45,15 @@ const TYPE_CONFIG = {
}; };
export function Insights() { 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(() => { useEffect(() => {
fetchInsights(); fetchInsights();
}, []); }, [fetchInsights]);
const stats = insights 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 { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Building2 } from 'lucide-react';
import { useTimelineStore, useMonitoringStore } from '@/stores'; 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 { ErrorBanner } from '@/components/ErrorBanner';
import { TimelinePlayer } from '@/components/TimelinePlayer'; import { TimelinePlayer } from '@/components/TimelinePlayer';
import { StatisticalCharts } from '@/components/StatisticalCharts'; import { StatisticalCharts } from '@/components/StatisticalCharts';
import { CaseLocationMap } from '@/components/CaseLocationMap'; import { CaseLocationMap } from '@/components/CaseLocationMap';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import { AdminBreadcrumb } from '@/components/AdminBreadcrumb';
interface MonitoringDashboardProps { interface MonitoringDashboardProps {
defaultStartDate?: string; defaultStartDate?: string;
defaultEndDate?: string; defaultEndDate?: string;
} }
const WUHAN_DISTRICTS = [
'江岸区', '江汉区', '硚口区', '汉阳区', '武昌区',
'青山区', '洪山区', '东西湖区', '汉南区', '蔡甸区',
'江夏区', '黄陂区', '新洲区',
];
export function MonitoringDashboard({ export function MonitoringDashboard({
defaultStartDate = '2022-12-01', defaultStartDate = '2022-12-01',
defaultEndDate = '2024-12-30', defaultEndDate = '2024-12-30',
}: MonitoringDashboardProps) { }: MonitoringDashboardProps) {
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null);
const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]); const [chartData, setChartData] = useState<Array<{ date: string; cases: number; aqi?: number }>>([]);
const { const {
@@ -35,13 +32,14 @@ export function MonitoringDashboard({
setDateRange, setDateRange,
} = useTimelineStore(); } = useTimelineStore();
const { const districtCases = useMonitoringStore((s) => s.districtCases);
districtCases, const error = useMonitoringStore((s) => s.error);
error, const clearError = useMonitoringStore((s) => s.clearError);
clearError, const fetchDistrictCases = useMonitoringStore((s) => s.fetchDistrictCases);
fetchDistrictCases, const isLoading = useMonitoringStore((s) => s.isLoading);
isLoading,
} = useMonitoringStore(); const { selectedDistrict, selectedStreet } = useDrilldownStore();
const { selectedDiagnoses } = useDiseaseStore();
useEffect(() => { useEffect(() => {
setDateRange(defaultStartDate, defaultEndDate); setDateRange(defaultStartDate, defaultEndDate);
@@ -54,12 +52,26 @@ export function MonitoringDashboard({
const end = new Date(defaultEndDate); const end = new Date(defaultEndDate);
const start = new Date(defaultEndDate); const start = new Date(defaultEndDate);
start.setDate(start.getDate() - 90); start.setDate(start.getDate() - 90);
gridApi.getHistoricalAggregated( const startStr = start.toISOString().split('T')[0];
start.toISOString().split('T')[0], const endStr = end.toISOString().split('T')[0];
end.toISOString().split('T')[0],
'daily', if (selectedDiagnoses.length > 0 && selectedDiagnoses.length <= 3) {
district, // Use caseApi for diagnosis-filtered data
).then((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 rows = data.aggregations || [];
const dailyCases: Record<string, number> = {}; const dailyCases: Record<string, number> = {};
rows.forEach((item: { date: string; total_cases: number }) => { rows.forEach((item: { date: string; total_cases: number }) => {
@@ -70,9 +82,12 @@ export function MonitoringDashboard({
.map(([date, cases]) => ({ date, cases })) .map(([date, cases]) => ({ date, cases }))
.sort((a, b) => a.date.localeCompare(b.date)) .sort((a, b) => a.date.localeCompare(b.date))
); );
}).catch(() => {}); }).catch((e) => { console.error('Failed to load chart data:', e); });
fetchDistrictCases(); }
}, [defaultEndDate, fetchDistrictCases]);
// Fetch districtCases with diagnosis filter
fetchDistrictCases(selectedDiagnoses.length > 0 ? selectedDiagnoses.join(',') : undefined);
}, [defaultEndDate, fetchDistrictCases, selectedDiagnoses]);
useEffect(() => { useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -189,19 +204,16 @@ export function MonitoringDashboard({
)} )}
</div> </div>
{/* District filter */} {/* Disease filter */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-gray-500">:</span> <DiseaseFilter onFilterChange={() => {
<select if (debounceRef.current) clearTimeout(debounceRef.current);
value={selectedDistrict || ''} debounceRef.current = setTimeout(() => {
onChange={(e) => setSelectedDistrict(e.target.value || null)} loadChartData(selectedDistrict || undefined);
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" }, 300);
> }} />
<option value=""></option> {/* District filter - AdminBreadcrumb for drill-down */}
{WUHAN_DISTRICTS.map((d) => ( <AdminBreadcrumb />
<option key={d} value={d}>{d}</option>
))}
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -217,7 +229,7 @@ export function MonitoringDashboard({
{/* Case Location Map */} {/* Case Location Map */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <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> <h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<CaseLocationMap height="400px" /> <CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} />
</div> </div>
{/* Statistical Charts */} {/* Statistical Charts */}
@@ -232,44 +244,7 @@ export function MonitoringDashboard({
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <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> <h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-2"> <div className="space-y-2">
{(() => { <DistrictBreakdown districtCases={districtCases} selectedDistrict={selectedDistrict} />
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>
);
});
})()}
</div> </div>
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100"> <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"> <div className="flex items-center gap-1.5 text-xs text-gray-500">
@@ -298,3 +273,57 @@ export function MonitoringDashboard({
</div> </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() { 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']); const [selectedPollutants, setSelectedPollutants] = useState<string[]>(['aqi', 'pm25']);
useEffect(() => { useEffect(() => {

View File

@@ -10,6 +10,10 @@ import type {
CaseStatsResponse, CaseStatsResponse,
CaseGridResponse, CaseGridResponse,
GeocodedCasesResponse, GeocodedCasesResponse,
StreetData,
ReportListResponse,
ReportResponse,
ReportSummary,
} from '@/types'; } from '@/types';
interface CacheEntry<T> { interface CacheEntry<T> {
@@ -146,24 +150,38 @@ export const historyApi = {
grid_id?: string; grid_id?: string;
region?: string; region?: string;
days?: number; days?: number;
}): Promise<any> => cachedGet('/history', params), }): Promise<any> => cachedGet('/grids/history', params),
getTrend: (gridId: string, days: number = 7): Promise<any> => getTrend: (gridId: string, days: number = 7): Promise<any> =>
cachedGet('/history/trend', { grid_id: gridId, days }), cachedGet(`/grids/${encodeURIComponent(gridId)}/history`, { days }),
}; };
export const caseApi = { export const caseApi = {
getTrend: (days: number = 7): Promise<CaseTrendResponse> => getTrend: (params?: {
cachedGet('/cases/trend', { days }), 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'), 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> => export const geocodedApi = {
cachedGet('/cases/geocoded', { limit }), 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 { export function clearApiCache(): void {
@@ -212,4 +230,14 @@ export const insightsApi = {
getCards: (): Promise<any> => cachedGet('/insights/cards'), 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; 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 { create } from 'zustand';
import axios from 'axios'; import axios from 'axios';
import type { GridRisk, GridDetail, Alert, Stats, ForecastDay } from '@/types'; 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 { function isCancelError(e: unknown): boolean {
return axios.isCancel(e) || (e as Error)?.message === 'canceled'; return axios.isCancel(e) || (e as Error)?.message === 'canceled';
@@ -84,7 +84,7 @@ export const useRiskStore = create<RiskState>((set, get) => ({
set({ alerts: data.alerts || [] }); set({ alerts: data.alerts || [] });
} catch (e) { } catch (e) {
if (isCancelError(e)) return; 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 { useAnalysisStore } from './analysisStore';
export { useDrilldownStore } from './drilldownStore';
export { useDiseaseStore } from './diseaseStore';
interface TimelineState { interface TimelineState {
@@ -166,13 +168,11 @@ interface MonitoringState {
gridFeatures: GridFeature[]; gridFeatures: GridFeature[];
aggregatedData: Array<{ date: string; district: string; total_cases: number; avg_AQI: number }>; aggregatedData: Array<{ date: string; district: string; total_cases: number; avg_AQI: number }>;
districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>; districtCases: Array<{ district: string; total: number; outpatient: number; inpatient: number }>;
selectedDistrict: string | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
fetchGridFeatures: (date: string) => Promise<void>; fetchGridFeatures: (date: string) => Promise<void>;
fetchAggregatedData: (startDate: string, endDate: string, district?: string) => Promise<void>; fetchAggregatedData: (startDate: string, endDate: string, district?: string) => Promise<void>;
fetchDistrictCases: () => Promise<void>; fetchDistrictCases: (diagnosis?: string) => Promise<void>;
setSelectedDistrict: (district: string | null) => void;
clearError: () => void; clearError: () => void;
} }
@@ -180,7 +180,6 @@ export const useMonitoringStore = create<MonitoringState>((set) => ({
gridFeatures: [], gridFeatures: [],
aggregatedData: [], aggregatedData: [],
districtCases: [], districtCases: [],
selectedDistrict: null,
isLoading: false, isLoading: false,
error: null, error: null,
@@ -220,19 +219,17 @@ export const useMonitoringStore = create<MonitoringState>((set) => ({
} }
}, },
fetchDistrictCases: async () => { fetchDistrictCases: async (diagnosis) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const { caseApi } = await import('@/services/api'); const data = await caseApi.getDistricts(diagnosis ? { diagnosis } : undefined);
const data = await caseApi.getDistricts(); const districts = Array.isArray(data) ? data : (data as any).districts || [];
set({ districtCases: data.districts || [], isLoading: false }); set({ districtCases: districts, isLoading: false });
} catch (e) { } catch (e) {
if (isCancelError(e)) return; if (isCancelError(e)) return;
set({ error: (e as Error).message || '加载区县病例数据失败', isLoading: false }); 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 { export interface CaseTrendResponse {
data: CaseTrendPoint[]; trend: CaseTrendPoint[];
days: number; summary: {
timestamp: string; total_outpatient: number;
total_inpatient: number;
period_count: number;
avg_daily_outpatient: number;
avg_daily_inpatient: number;
};
} }
export interface DistrictCaseResponse { export interface DistrictCaseResponse {
@@ -128,8 +133,11 @@ export interface DistrictCaseResponse {
} }
export interface CaseStatsResponse { export interface CaseStatsResponse {
stats: CaseStats; total_outpatient: number;
timestamp: string; 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 --- // --- High-Resolution Geocoded Case Types ---
@@ -167,3 +175,68 @@ export interface GeocodedCasesResponse {
cases: GeocodedCase[]; cases: GeocodedCase[];
total_count: number; 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;
}