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:
0
backend/data/__init__.py
Normal file
0
backend/data/__init__.py
Normal file
105
backend/data/case_loader.py
Normal file
105
backend/data/case_loader.py
Normal 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"]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,19 +70,26 @@ 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"),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
获取病例总体统计信息
|
获取病例总体统计信息
|
||||||
|
|
||||||
- 总门诊量、总住院量
|
- 总门诊量、总住院量
|
||||||
- 数据日期范围
|
- 数据日期范围
|
||||||
- 就诊量前 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,10 +132,11 @@ 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,13 +146,17 @@ 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:
|
||||||
df = df[df['date'] >= pd.to_datetime(start_date)]
|
df = df[df['date'] >= pd.to_datetime(start_date)]
|
||||||
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":
|
||||||
@@ -272,15 +202,20 @@ 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"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取病例区域分布数据
|
获取病例区域分布数据
|
||||||
|
|
||||||
- 支持按病例类型筛选
|
- 支持按病例类型筛选
|
||||||
- 可设置最小病例数过滤
|
- 可设置最小病例数过滤
|
||||||
- 返回各区门诊、住院量及占比
|
- 返回各区门诊、住院量及占比
|
||||||
"""
|
"""
|
||||||
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,8 +266,8 @@ 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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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,7 +55,10 @@ 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"
|
||||||
|
|
||||||
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(
|
return ReportSummary(
|
||||||
total_cases=total_cases,
|
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]:
|
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],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
52
frontend/src/components/AdminBreadcrumb.tsx
Normal file
52
frontend/src/components/AdminBreadcrumb.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 || []);
|
||||||
|
|||||||
124
frontend/src/components/DiseaseFilter.tsx
Normal file
124
frontend/src/components/DiseaseFilter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,48 @@ interface SideNavProps {
|
|||||||
alertCount?: number;
|
alertCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
|
||||||
|
{
|
||||||
|
id: 'monitoring',
|
||||||
|
label: '监测',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
{ id: 'monitoring', label: '监测面板' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert',
|
||||||
|
label: '预警',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
{ id: 'alerts', label: '预警地图' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analysis',
|
||||||
|
label: '分析',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
{ id: 'trend-analysis', label: '趋势分析' },
|
||||||
|
{ id: 'district-comparison', label: '区域对比' },
|
||||||
|
{ id: 'insights', label: '智能洞察' },
|
||||||
|
{ id: 'reports', label: '报表中心' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function SideNav({
|
export function SideNav({
|
||||||
activePage,
|
activePage,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
@@ -13,47 +55,6 @@ export function SideNav({
|
|||||||
}: SideNavProps) {
|
}: SideNavProps) {
|
||||||
const [expanded, setExpanded] = useState<string | null>('monitoring');
|
const [expanded, setExpanded] = useState<string | null>('monitoring');
|
||||||
|
|
||||||
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
|
|
||||||
{
|
|
||||||
id: 'monitoring',
|
|
||||||
label: '监测',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
{ id: 'monitoring', label: '监测面板' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alert',
|
|
||||||
label: '预警',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
{ id: 'alerts', label: '预警地图' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'analysis',
|
|
||||||
label: '分析',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
{ id: 'trend-analysis', label: '趋势分析' },
|
|
||||||
{ id: 'district-comparison', label: '区域对比' },
|
|
||||||
{ id: 'insights', label: '智能洞察' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleItemClick = (moduleId: string, itemId: string) => {
|
const handleItemClick = (moduleId: string, itemId: string) => {
|
||||||
setExpanded(moduleId);
|
setExpanded(moduleId);
|
||||||
onPageChange(itemId);
|
onPageChange(itemId);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
|
|||||||
@@ -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
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -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,25 +52,42 @@ 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({
|
||||||
const rows = data.aggregations || [];
|
start_date: startStr,
|
||||||
const dailyCases: Record<string, number> = {};
|
end_date: endStr,
|
||||||
rows.forEach((item: { date: string; total_cases: number }) => {
|
group_by: 'day',
|
||||||
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
|
diagnosis: selectedDiagnoses.join(','),
|
||||||
});
|
}).then((data) => {
|
||||||
setChartData(
|
const trend = data.trend || [];
|
||||||
Object.entries(dailyCases)
|
setChartData(
|
||||||
.map(([date, cases]) => ({ date, cases }))
|
trend.map((t: { date: string; total: number }) => ({ date: t.date, cases: t.total }))
|
||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
);
|
||||||
);
|
}).catch((e) => { console.error('Failed to load chart data:', e); });
|
||||||
}).catch(() => {});
|
} else {
|
||||||
fetchDistrictCases();
|
// Use gridApi for unfiltered data (or too many diagnoses selected)
|
||||||
}, [defaultEndDate, fetchDistrictCases]);
|
gridApi.getHistoricalAggregated(startStr, endStr, 'daily', district)
|
||||||
|
.then((data) => {
|
||||||
|
const rows = data.aggregations || [];
|
||||||
|
const dailyCases: Record<string, number> = {};
|
||||||
|
rows.forEach((item: { date: string; total_cases: number }) => {
|
||||||
|
dailyCases[item.date] = (dailyCases[item.date] || 0) + item.total_cases;
|
||||||
|
});
|
||||||
|
setChartData(
|
||||||
|
Object.entries(dailyCases)
|
||||||
|
.map(([date, cases]) => ({ date, cases }))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
);
|
||||||
|
}).catch((e) => { console.error('Failed to load chart data:', e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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">
|
||||||
@@ -297,4 +272,58 @@ 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
349
frontend/src/pages/ReportsCenter.tsx
Normal file
349
frontend/src/pages/ReportsCenter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
38
frontend/src/stores/diseaseStore.ts
Normal file
38
frontend/src/stores/diseaseStore.ts
Normal 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: [] }),
|
||||||
|
}));
|
||||||
63
frontend/src/stores/drilldownStore.ts
Normal file
63
frontend/src/stores/drilldownStore.ts
Normal 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: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -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 }),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
54
frontend/src/stores/reportsStore.ts
Normal file
54
frontend/src/stores/reportsStore.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user