diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 1574eed..5874879 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -10,6 +10,7 @@ from . import ( dinsar, dinsar_products, dinsar_production, + flood, hazard, health, idl, @@ -58,5 +59,6 @@ def include_all_routers(router: APIRouter) -> None: router.include_router(stats.router) router.include_router(idl.router) router.include_router(hazard.router) + router.include_router(flood.router) router.include_router(water.router) router.include_router(logs.router) diff --git a/backend/app/routers/flood.py b/backend/app/routers/flood.py new file mode 100644 index 0000000..87b5790 --- /dev/null +++ b/backend/app/routers/flood.py @@ -0,0 +1,183 @@ +"""Flood disaster analysis router. + +This router exposes the flood-analysis business API while reusing the +existing water/flood processing records and jobs during migration. +""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from ..database import get_db +from ..models import AuthUserORM +from . import water as water_compat +from .dependencies import _get_current_user, _require_admin + +router = APIRouter() + + +class FloodPreprocessRequest(BaseModel): + radar_data_id: int = Field(..., description="RadarDataORM 主键") + + +class FloodWaterExtractionRequest(BaseModel): + scene_id: Optional[int] = Field(default=None, description="SARSceneGeoORM 主键") + input_path: Optional[str] = Field(default=None, description="直接指定 GeoTIFF/ENVI 路径") + + +class FloodPairSearchRequest(BaseModel): + pre_start: Optional[str] = Field(default=None, description="灾前开始日期 YYYYMMDD") + pre_end: Optional[str] = Field(default=None, description="灾前结束日期 YYYYMMDD") + post_start: Optional[str] = Field(default=None, description="灾后开始日期 YYYYMMDD") + post_end: Optional[str] = Field(default=None, description="灾后结束日期 YYYYMMDD") + overlap_threshold: float = Field(default=0.3, ge=0.0, le=1.0, description="最小重叠比例") + + +class FloodDetectionRequest(BaseModel): + pre_scene_id: int = Field(..., description="灾前 SARSceneGeoORM 主键") + post_scene_id: int = Field(..., description="灾后 SARSceneGeoORM 主键") + refine: bool = Field(default=False, description="是否启用 MRF 精化") + + +@router.post("/flood/preprocess", status_code=202) +async def submit_flood_preprocess( + req: FloodPreprocessRequest, + db: AsyncSession = Depends(get_db), + admin_user: AuthUserORM = Depends(_require_admin), +): + """提交水体提取前置处理任务,当前复用旧 water geocode 链路。""" + return await water_compat.submit_geocode(req, db=db, admin_user=admin_user) + + +@router.get("/flood/scenes") +async def list_flood_scenes( + limit: int = 20, + offset: int = 0, + db: AsyncSession = Depends(get_db), + current_user: AuthUserORM = Depends(_get_current_user), +): + """列出可作为水体提取输入的地理编码场景。""" + return await water_compat.list_scenes(limit=limit, offset=offset, db=db, current_user=current_user) + + +@router.get("/flood/scenes/done-radar-ids") +async def list_flood_done_radar_ids( + db: AsyncSession = Depends(get_db), + current_user: AuthUserORM = Depends(_get_current_user), +): + return await water_compat.list_done_scene_radar_ids(db=db, current_user=current_user) + + +@router.get("/flood/scenes/active-radar-ids") +async def list_flood_active_radar_ids( + db: AsyncSession = Depends(get_db), + current_user: AuthUserORM = Depends(_get_current_user), +): + return await water_compat.list_active_scene_radar_ids(db=db, current_user=current_user) + + +@router.post("/flood/scenes/{scene_id}/reset") +async def reset_flood_scene( + scene_id: int, + db: AsyncSession = Depends(get_db), + admin_user: AuthUserORM = Depends(_require_admin), +): + return await water_compat.reset_scene_status(scene_id=scene_id, db=db, admin_user=admin_user) + + +@router.post("/flood/water-extractions", status_code=202) +async def submit_flood_water_extraction( + req: FloodWaterExtractionRequest, + db: AsyncSession = Depends(get_db), + admin_user: AuthUserORM = Depends(_require_admin), +): + """提交单景水体提取任务,当前复用 Otsu 快速水体检测实现。""" + return await water_compat.submit_water_detect(req, db=db, admin_user=admin_user) + + +@router.get("/flood/water-extractions") +async def list_flood_water_extractions( + limit: int = 20, + offset: int = 0, + status: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: AuthUserORM = Depends(_get_current_user), +): + return await water_compat.list_water_detections( + limit=limit, + offset=offset, + status=status, + db=db, + current_user=current_user, + ) + + +@router.get("/flood/water-extractions/{extraction_id}/preview") +async def get_flood_water_extraction_preview( + extraction_id: int, + db: AsyncSession = Depends(get_db), + current_user: AuthUserORM = Depends(_get_current_user), +): + return await water_compat.get_water_detection_preview( + detection_id=extraction_id, + db=db, + current_user=current_user, + ) + + +@router.post("/flood/pairs/search") +async def search_flood_pairs( + req: FloodPairSearchRequest, + db: AsyncSession = Depends(get_db), + current_user: AuthUserORM = Depends(_get_current_user), +): + return await water_compat.find_water_pairs(req, db=db, current_user=current_user) + + +@router.post("/flood/detections", status_code=202) +async def submit_flood_detection( + req: FloodDetectionRequest, + db: AsyncSession = Depends(get_db), + admin_user: AuthUserORM = Depends(_require_admin), +): + return await water_compat.submit_flood_detect(req, db=db, admin_user=admin_user) + + +@router.get("/flood/detections") +async def list_flood_detections( + db: AsyncSession = Depends(get_db), + current_user: AuthUserORM = Depends(_get_current_user), +): + return await water_compat.list_flood_events(db=db, current_user=current_user) + + +@router.get("/flood/detections/{detection_id}/preview/{layer}") +async def get_flood_detection_preview( + detection_id: int, + layer: str, + db: AsyncSession = Depends(get_db), + current_user: AuthUserORM = Depends(_get_current_user), +): + normalized = layer.strip().lower() + if normalized == "pre": + return await water_compat.flood_event_pre_preview( + event_id=detection_id, + db=db, + current_user=current_user, + ) + if normalized == "post": + return await water_compat.flood_event_post_preview( + event_id=detection_id, + db=db, + current_user=current_user, + ) + if normalized == "classified": + return await water_compat.flood_event_classified_preview( + event_id=detection_id, + db=db, + current_user=current_user, + ) + raise HTTPException(status_code=404, detail=f"不支持的洪涝预览图层: {layer}") diff --git a/docs/FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md b/docs/FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md new file mode 100644 index 0000000..2d43ab9 --- /dev/null +++ b/docs/FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md @@ -0,0 +1,304 @@ +# 水体提取与洪涝灾害分析设计 + +更新日期:2026-05-14 + +## 1. 设计结论 + +这个模块不应该被设计成一堆遥感处理工具的集合。它的业务目标很简单: + +```text +提取水体 -> 检测洪涝 -> 套合已有矢量数据 -> 输出结果 +``` + +因此前端和后端都应围绕这条流水线组织,而不是暴露过多工程阶段。用户关心的是: + +- 哪些数据可以拿来提水体。 +- 哪两期数据可以用来判断洪涝。 +- 洪涝范围在哪里、面积多大。 +- 影响了哪些灾害点、行政区、AOI 或其他矢量对象。 +- 结果能不能上图、导出、形成报告。 + +## 2. 当前真实边界 + +现有代码不能理解为已经具备多源 ENVI 洪涝系统。 + +当前事实: + +- LT-1 有 `SARsImportLuTan1` 相关 ENVI/SARscape 链路,可作为第一条精密检测主线。 +- GF3 有纯 Python/GDAL 的 L1A 到 L2 处理,不是 ENVI/SARscape 洪涝导入。 +- Sentinel-1 有源产品、ZIP/SAFE、EOF、PyINT/Gamma 相关管理设计,不是 ENVI/SARscape 洪涝导入。 +- 当前 `water` 模块已经有旧的单景 geocode、Otsu 快速检测、精密洪涝检测和地图预览能力,可作为迁移基础。 + +所以第一阶段不能承诺 GF3/Sentinel-1 可直接做 ENVI 精密洪涝检测。页面上必须显示为“待接入导入适配器”。 + +## 3. 产品信息架构 + +左侧一级入口合并为: + +```text +洪涝灾害分析 +├─ 洪涝灾害分析 +└─ 水体监测(旧入口) +``` + +新的“洪涝灾害分析”工作台只保留四个视图: + +```text +1. 水体提取 +2. 洪涝检测 +3. 套合分析 +4. 结果与任务 +``` + +旧“水体监测”入口短期保留,承载当前 `WaterMonitorPanel` 的功能。后续功能成熟后再逐步迁入新工作台。 + +## 4. 地图设计 + +不新建第二套地图。 + +当前中间地图大屏已经有底图、行政区定位、导出、雷达覆盖、洪涝影像叠加能力。新工作台应复用 `AppMapWorkspace` 和 `App.jsx` 中的 Leaflet 实例,通过回调控制图层: + +```text +FloodAnalysisWorkspace + -> onShowSourceSceneOnMap + -> onShowFloodOnMap + -> onToggleFloodLayer +``` + +地图只负责展示,业务按钮放在左侧工作台。 + +## 5. 流水线设计 + +### 5.1 水体提取 + +目标:把单期雷达影像处理成水体范围。 + +用户动作: + +- 选择数据。 +- 查看数据是否可处理。 +- 提交水体提取。 +- 查看水体范围。 +- 加载到地图。 + +数据状态建议: + +- `已入库`:系统只管理了原始/解压数据。 +- `可提取`:已有可用处理器。 +- `处理中`:任务正在运行。 +- `已提取`:已有水体范围产品。 +- `待接入`:数据存在,但缺少对应导入/处理适配器。 + +传感器边界: + +- LT-1:优先接旧 ENVI/SARscape 链路。 +- GF3:待补 ENVI/SARscape 洪涝导入;现有 Python/GDAL 成果可作为后续快速路线输入。 +- Sentinel-1:待补 ENVI/SARscape 洪涝导入,或后续明确非 ENVI 检测路线。 + +### 5.2 洪涝检测 + +目标:比较灾前/灾后水体变化,识别新增水体作为洪涝范围。 + +用户动作: + +- 选择灾前数据。 +- 选择灾后数据。 +- 自动推荐配对。 +- 提交洪涝检测。 +- 查看洪涝面积和稳定水体面积。 +- 加载灾前、灾后、分类图到地图。 + +检测路线: + +- 精密路线:ENVI/SARscape `SARsBasicFeFloodingClassification`。 +- 快速路线:Otsu/GeoTIFF 水体掩膜差异,作为轻量能力。 + +输出类别: + +```text +1 = 稳定水体 +2 = 洪涝/新增水体 +3 = 高散射 +4 = 非水体 +``` + +### 5.3 套合分析 + +目标:把洪涝范围与已有矢量数据叠加,回答“影响了什么”。 + +第一阶段套合对象: + +- 灾害点。 +- 行政区。 +- 当前 AOI。 +- 自定义矢量。 + +输出: + +- 洪涝范围矢量。 +- 受影响灾害点清单。 +- 行政区/AOI 洪涝面积统计。 +- 距离洪涝范围一定阈值内的风险点。 +- GeoJSON 和表格导出。 + +灾害点关系建议: + +- `inside_flood`:点落在洪涝范围内。 +- `near_flood`:点距离洪涝边界小于阈值。 +- `inside_scene_only`:点在影像覆盖范围内,但未受洪涝影响。 + +面积统计必须使用合适投影,不能直接用经纬度面积作为正式统计。 + +### 5.4 结果与任务 + +目标:统一查看任务、图层、结果和导出。 + +展示字段: + +- 结果 ID。 +- 灾前日期。 +- 灾后日期。 +- 卫星组合。 +- 处理器。 +- 洪涝面积。 +- 稳定水体面积。 +- 影响灾害点数量。 +- 状态。 +- 更新时间。 + +操作: + +- 加载图层。 +- 打开产品包。 +- 导出 GeoTIFF/GeoJSON。 +- 生成报告。 + +## 6. 后端域模型 + +后端可以继续兼容 `/water/*`,但新能力建议逐步进入 `/flood/*`。 + +最小模型不要过度复杂,先围绕四类对象: + +```text +WaterExtractionRun +FloodDetectionRun +FloodOverlayRun +FloodResultProduct +``` + +如果后续需要多源产品治理,再扩展: + +```text +FloodReadyProduct +FloodPair +FloodVectorAsset +FloodReport +``` + +## 7. API 草案 + +### 水体提取 + +```text +GET /flood/sources +POST /flood/water-extractions +GET /flood/water-extractions +GET /flood/water-extractions/{id} +GET /flood/water-extractions/{id}/preview +``` + +### 洪涝检测 + +```text +POST /flood/pairs/search +POST /flood/detections +GET /flood/detections +GET /flood/detections/{id} +GET /flood/detections/{id}/preview/{layer} +``` + +### 套合分析 + +```text +POST /flood/detections/{id}/vectorize +POST /flood/detections/{id}/overlay +GET /flood/detections/{id}/impact +``` + +### 结果导出 + +```text +GET /flood/results +GET /flood/results/{id} +POST /flood/reports +``` + +## 8. 前端按钮流 + +### 水体提取 + +- 查询数据。 +- 显示覆盖。 +- 提交水体提取。 +- 批量提取。 +- 查看水体结果。 + +### 洪涝检测 + +- 自动推荐配对。 +- 选择灾前/灾后。 +- 提交检测。 +- MRF 精化开关。 +- 加载图层。 +- 去套合分析。 + +### 套合分析 + +- 运行套合分析。 +- 加载影响点。 +- 导出影响清单。 +- 导出 GeoJSON。 + +### 结果与任务 + +- 刷新结果。 +- 加载全部图层。 +- 打开产品包。 +- 生成报告。 +- 导出成果。 + +## 9. 分阶段实施 + +### Phase 1:前端流水线壳层 + +- 合并“水体监测”和“洪涝灾害分析”为一个业务组。 +- 新工作台只保留水体提取、洪涝检测、套合分析、结果与任务。 +- 复用旧 `/water/*` 数据展示当前已有能力。 +- 明确 GF3/Sentinel-1 为待接入,不显示为可精密检测。 + +### Phase 2:LT-1 主线打通 + +- 把当前 `water_service.py` 中 LT-1 ENVI 链路整理成清晰的水体/洪涝任务。 +- 输出标准结果。 +- 完成地图预览和结果列表。 + +### Phase 3:套合分析 + +- 分类栅格转洪涝矢量。 +- 接入灾害点套合。 +- 接入行政区/AOI 统计。 +- 输出影响清单。 + +### Phase 4:多源扩展 + +- GF3 ENVI/SARscape 导入适配器或替代检测路线。 +- Sentinel-1 ENVI/SARscape 导入适配器或替代检测路线。 +- 多源配对策略和质量提示。 + +## 10. 设计原则 + +- 页面按业务问题组织,不按算法步骤堆控件。 +- 数据已入库不代表可检测,必须明确可用性状态。 +- 地图只保留一套,避免图层和状态重复。 +- 旧 `water` 模块保留迁移期入口,但新功能进入 `FloodAnalysisWorkspace`。 +- 套合分析是核心能力,不是结果页上的附属按钮。 diff --git a/docs/FRONTEND_NAVIGATION_ARCHITECTURE.md b/docs/FRONTEND_NAVIGATION_ARCHITECTURE.md index 624614c..ad77447 100644 --- a/docs/FRONTEND_NAVIGATION_ARCHITECTURE.md +++ b/docs/FRONTEND_NAVIGATION_ARCHITECTURE.md @@ -23,7 +23,7 @@ The current first-level menu groups are: - `production_management`: 生产管理 - `insar_analysis`: InSAR形变分析 - `ai_analysis`: AI分析 -- `water`: 水体监测 +- `flood_analysis`: 洪涝灾害分析 - `ops`: 运行维护 Definition files: @@ -125,8 +125,8 @@ The following groups do not define second-level sections: - `data` leaf tabs: `ingest`, `data`, `hazard` -- `water` - leaf tabs: `water` +- `flood_analysis` + leaf tabs: `flood_analysis` - `ops` leaf tabs: `health`, `users`, `audit` @@ -197,6 +197,7 @@ These aliases exist for compatibility, but they are not first-class left-nav ent Recommended future additions: +- Use `flood_analysis` as the combined first-level group for water extraction, flood detection, overlay analysis, and flood results. The legacy `water` route may remain in code for compatibility, but it is no longer a first-class left-nav entry. - Put new production execution or product-governance capability under `production_management` as an internal workspace view unless a separate first-level domain is clearly required. - Put planning, batching, pairing, and dispatch preparation capability under `production_planning`. - Put result browsing and analyst-facing deformation interpretation under `insar_analysis`. diff --git a/docs/INDEX.md b/docs/INDEX.md index 3c95ab6..f09b319 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -36,6 +36,9 @@ - [PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md](PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md) 多引擎结果目录、发布结构和 catalog 治理设计。 +- [FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md](FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md) + 洪涝灾害分析独立系统设计,定义多源 SAR 数据、ENVI/SARscape 洪涝流程、标准产品包和矢量套合分析边界。 + - [WSL_RUNTIME_REFACTOR_DESIGN_20260422.md](WSL_RUNTIME_REFACTOR_DESIGN_20260422.md) WSL 共享运行时和 Broker 设计。 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b4bf220..bbab7d2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1642,9 +1642,12 @@ function App() { onToggleVisibility: setShowHazardPoints, onScanComplete: fetchHazardPoints, }; - const waterPanel = { + const floodPanel = { + onShowSourceSceneOnMap: handleWaterSceneOnMap, + onShowReadyProductOnMap: handleWaterSceneOnMap, onShowOnMap: handleWaterSceneOnMap, onShowFloodOnMap: handleFloodEventOnMap, + onShowFloodRunOnMap: handleFloodEventOnMap, onToggleFloodLayer: toggleFloodEventLayer, }; const dinsarPanel = { @@ -1736,7 +1739,7 @@ function App() { pairingPanel={pairingPanel} taskPanel={taskPanel} hazardPanel={hazardPanel} - waterPanel={waterPanel} + floodPanel={floodPanel} dinsarPanel={dinsarPanel} aiPanel={aiPanel} pairsPanel={pairsPanel} diff --git a/frontend/src/FloodAnalysisWorkspace.jsx b/frontend/src/FloodAnalysisWorkspace.jsx new file mode 100644 index 0000000..3b6ccb8 --- /dev/null +++ b/frontend/src/FloodAnalysisWorkspace.jsx @@ -0,0 +1,1096 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import UnifiedDatePicker from './components/UnifiedDatePicker'; +import { getSearchOptions, searchRadarData } from './api/radar'; +import { + getFloodActiveRadarIds, + getFloodDetectionPreview, + getFloodDetections, + getFloodDoneRadarIds, + getFloodScenes, + getFloodWaterExtractionPreview, + getFloodWaterExtractions, + resetFloodScene, + searchFloodPairs, + submitFloodDetection, + submitFloodPreprocess, + submitFloodWaterExtraction, +} from './api/flood'; +import { + buildRadarSearchFormData, + formatYmd, + normalizeRadarSearchCriteria, +} from './utils/appUiHelpers'; +import { RADAR_SEARCH_DEFAULTS } from './config/appConstants'; + +const SEARCH_PAGE_SIZE = 30; +const LIST_PAGE_SIZE = 20; + +const VIEWS = [ + { key: 'extract', label: '水体提取' }, + { key: 'detect', label: '洪涝检测' }, + { key: 'impact', label: '套合分析' }, + { key: 'results', label: '结果与任务' }, +]; + +const SENSOR_ROWS = [ + { name: 'LT-1', status: '精密链路可用', tone: 'ok', capability: 'ENVI/SARscape' }, + { name: 'GF3', status: '快速路线可用', tone: 'warn', capability: 'Python/GDAL + Otsu' }, + { name: 'Sentinel-1', status: '待接入', tone: 'muted', capability: '导入适配器' }, +]; + +const STATUS_META = { + PENDING: { label: '等待中', tone: 'muted' }, + RUNNING: { label: '处理中', tone: 'info' }, + DONE: { label: '完成', tone: 'ok' }, + FAILED: { label: '失败', tone: 'bad' }, +}; + +const palette = { + bg: '#f6f8fb', + panel: '#ffffff', + panelSoft: '#f8fafc', + border: '#d8e0ea', + borderStrong: '#b8c4d4', + text: '#111827', + text2: '#334155', + muted: '#64748b', + subtle: '#94a3b8', + primary: '#2563eb', + primarySoft: '#dbeafe', + teal: '#0f766e', + tealSoft: '#ccfbf1', + red: '#dc2626', + redSoft: '#fee2e2', + amber: '#d97706', + amberSoft: '#ffedd5', + green: '#16a34a', + greenSoft: '#dcfce7', +}; + +const pageStyle = { + minHeight: '100%', + padding: 14, + boxSizing: 'border-box', + background: palette.bg, + color: palette.text, + fontSize: 13, +}; + +const panelStyle = { + background: palette.panel, + border: `1px solid ${palette.border}`, + borderRadius: 8, + padding: 12, + marginBottom: 12, +}; + +const sectionStyle = { + padding: '12px 0', + borderTop: `1px solid ${palette.border}`, +}; + +const rowStyle = { + background: palette.panel, + border: `1px solid ${palette.border}`, + borderRadius: 6, + padding: 10, +}; + +const inputStyle = { + width: '100%', + boxSizing: 'border-box', + border: `1px solid ${palette.border}`, + borderRadius: 6, + padding: '6px 8px', + background: '#fff', + color: palette.text, + fontSize: 12, +}; + +function asStatus(value) { + return String(value || '').toUpperCase(); +} + +function isActiveStatus(value) { + const status = asStatus(value); + return status === 'PENDING' || status === 'RUNNING'; +} + +function compactDate(value) { + return String(value || '').replaceAll('-', '').trim(); +} + +function formatArea(value) { + if (value === null || value === undefined || Number.isNaN(Number(value))) return '-'; + return `${Number(value).toFixed(2)} km²`; +} + +function getErrorText(error, fallback = '操作失败') { + return error?.response?.data?.detail || error?.message || fallback; +} + +function getToneStyle(tone) { + if (tone === 'ok') return { color: '#166534', background: palette.greenSoft, border: '#bbf7d0' }; + if (tone === 'info') return { color: '#1d4ed8', background: palette.primarySoft, border: '#bfdbfe' }; + if (tone === 'warn') return { color: '#b45309', background: palette.amberSoft, border: '#fed7aa' }; + if (tone === 'bad') return { color: '#b91c1c', background: palette.redSoft, border: '#fecaca' }; + return { color: palette.muted, background: palette.panelSoft, border: palette.border }; +} + +function StatusBadge({ status, tone, children }) { + const meta = status ? STATUS_META[asStatus(status)] : null; + const style = getToneStyle(tone || meta?.tone || 'muted'); + return ( + + {children || meta?.label || status || '-'} + + ); +} + +function buttonStyle(kind = 'secondary', disabled = false, extra = {}) { + const styles = { + primary: { color: '#fff', background: palette.primary, border: palette.primary }, + secondary: { color: palette.text2, background: '#fff', border: palette.borderStrong }, + quiet: { color: palette.muted, background: palette.panelSoft, border: palette.border }, + danger: { color: palette.red, background: '#fff', border: '#fecaca' }, + success: { color: palette.green, background: '#fff', border: '#bbf7d0' }, + }[kind] || {}; + return { + border: `1px solid ${styles.border}`, + background: styles.background, + color: styles.color, + borderRadius: 6, + padding: '6px 10px', + fontSize: 12, + fontWeight: 600, + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.55 : 1, + whiteSpace: 'nowrap', + ...extra, + }; +} + +function EmptyState({ children }) { + return ( +
+ {children} +
+ ); +} + +function SectionHeader({ title, actions }) { + return ( +
+

{title}

+ {actions &&
{actions}
} +
+ ); +} + +function KeyValue({ label, value, strong = false }) { + return ( +
+
{label}
+
+ {value || '-'} +
+
+ ); +} + +function normalizeMapPreview(preview) { + if (!preview) return null; + const image = preview.image_b64 || preview.png_base64; + let bounds = preview.bounds; + if (bounds && !Array.isArray(bounds)) { + bounds = [bounds.min_lat, bounds.min_lon, bounds.max_lat, bounds.max_lon]; + } + if (!image || !Array.isArray(bounds) || bounds.length !== 4) return null; + return { image_b64: image, bounds }; +} + +function SceneRow({ scene, readOnly, onShowMap, onExtractWater, onReset }) { + const status = asStatus(scene.status); + const canExtract = status === 'DONE'; + return ( +
+
+
+
+ 场景 #{scene.id} + + {scene.satellite || '-'} + {formatYmd(scene.imaging_date, 'zh')} +
+
+ {scene.geo_path ? scene.geo_path.split(/[\\/]/).pop() : scene.error_msg || `Radar ID ${scene.radar_data_id}`} +
+
+
+ + + {isActiveStatus(scene.status) && ( + + )} +
+
+
+ ); +} + +function WaterResultRow({ item, onShowMap }) { + return ( +
+
+
+
+ 水体 #{item.id} + + {item.scene_id && 场景 #{item.scene_id}} +
+
+ + + +
+ {item.error_msg &&
{item.error_msg}
} +
+ +
+
+ ); +} + +function FloodEventRow({ event, mapLayerVis, mapLoading, onShowMap, onToggleLayer, onSelectImpact }) { + const done = asStatus(event.status) === 'DONE'; + const preDate = formatYmd(event.pre_imaging_date, 'zh'); + const postDate = formatYmd(event.post_imaging_date, 'zh'); + const layers = mapLayerVis[event.id]; + return ( +
+
+
+ 洪涝 #{event.id} + + {event.pre_satellite || event.post_satellite || '-'} +
+ +
+
+ + + + +
+ {event.error_msg &&
{event.error_msg}
} + {layers && ( +
+ {[ + ['pre', '灾前影像'], + ['post', '灾后影像'], + ['classified', '分类结果'], + ].map(([key, label]) => ( + + ))} + {onSelectImpact && ( + + )} +
+ )} +
+ ); +} + +export default function FloodAnalysisWorkspace({ + readOnly = false, + onTaskStart, + floodPanel = {}, + language = 'zh', +}) { + const [activeView, setActiveView] = useState('extract'); + const [message, setMessage] = useState(null); + const [actionBusy, setActionBusy] = useState(''); + + const [searchOptions, setSearchOptions] = useState({ satellite: [], imaging_mode: [], product_level: [], polarization: [] }); + const [radarDraft, setRadarDraft] = useState({ ...RADAR_SEARCH_DEFAULTS }); + const [radarResults, setRadarResults] = useState([]); + const [radarTotal, setRadarTotal] = useState(0); + const [radarPage, setRadarPage] = useState(0); + const [radarLoading, setRadarLoading] = useState(false); + const [radarSearched, setRadarSearched] = useState(false); + const [selectedRadars, setSelectedRadars] = useState([]); + + const [scenes, setScenes] = useState([]); + const [scenesTotal, setScenesTotal] = useState(0); + const [scenesPage, setScenesPage] = useState(0); + const [scenesLoading, setScenesLoading] = useState(false); + const [doneRadarIds, setDoneRadarIds] = useState([]); + const [activeRadarIds, setActiveRadarIds] = useState([]); + + const [waterResults, setWaterResults] = useState([]); + const [waterTotal, setWaterTotal] = useState(0); + const [waterPage, setWaterPage] = useState(0); + const [waterLoading, setWaterLoading] = useState(false); + + const [pairPreStart, setPairPreStart] = useState(''); + const [pairPreEnd, setPairPreEnd] = useState(''); + const [pairPostStart, setPairPostStart] = useState(''); + const [pairPostEnd, setPairPostEnd] = useState(''); + const [overlapThreshold, setOverlapThreshold] = useState(0.3); + const [refine, setRefine] = useState(false); + const [pairSearching, setPairSearching] = useState(false); + const [candidatePairs, setCandidatePairs] = useState([]); + const [selectedPairIdx, setSelectedPairIdx] = useState(null); + + const [floodEvents, setFloodEvents] = useState([]); + const [floodLoading, setFloodLoading] = useState(false); + const [mapLoadingId, setMapLoadingId] = useState(null); + const [mapLayerVis, setMapLayerVis] = useState({}); + const [selectedImpactEventId, setSelectedImpactEventId] = useState(''); + const [resultMode, setResultMode] = useState('flood'); + + const readyScenes = useMemo(() => scenes.filter(item => asStatus(item.status) === 'DONE'), [scenes]); + const doneFloodEvents = useMemo(() => floodEvents.filter(item => asStatus(item.status) === 'DONE'), [floodEvents]); + const runningCount = useMemo(() => ( + scenes.filter(item => isActiveStatus(item.status)).length + + waterResults.filter(item => isActiveStatus(item.status)).length + + floodEvents.filter(item => isActiveStatus(item.status)).length + ), [scenes, waterResults, floodEvents]); + + const selectedPair = selectedPairIdx === null ? null : candidatePairs[selectedPairIdx]; + const selectedImpactEvent = useMemo( + () => doneFloodEvents.find(item => String(item.id) === String(selectedImpactEventId)) || doneFloodEvents[0] || null, + [doneFloodEvents, selectedImpactEventId], + ); + + const showMessage = useCallback((type, text) => { + setMessage({ type, text }); + }, []); + + const loadStatusIds = useCallback(async () => { + try { + const [done, active] = await Promise.all([getFloodDoneRadarIds(), getFloodActiveRadarIds()]); + setDoneRadarIds(done || []); + setActiveRadarIds(active || []); + } catch { + setDoneRadarIds([]); + setActiveRadarIds([]); + } + }, []); + + const loadScenes = useCallback(async (page = scenesPage) => { + setScenesLoading(true); + try { + const res = (await getFloodScenes(LIST_PAGE_SIZE, page * LIST_PAGE_SIZE)).data; + setScenes(res.items || []); + setScenesTotal(res.total || 0); + setScenesPage(page); + } catch (error) { + setScenes([]); + setScenesTotal(0); + showMessage('error', `水体场景加载失败:${getErrorText(error)}`); + } finally { + setScenesLoading(false); + } + }, [scenesPage, showMessage]); + + const loadWaterResults = useCallback(async (page = waterPage) => { + setWaterLoading(true); + try { + const res = (await getFloodWaterExtractions(LIST_PAGE_SIZE, page * LIST_PAGE_SIZE)).data; + setWaterResults(res.items || []); + setWaterTotal(res.total || 0); + setWaterPage(page); + } catch (error) { + setWaterResults([]); + setWaterTotal(0); + showMessage('error', `水体结果加载失败:${getErrorText(error)}`); + } finally { + setWaterLoading(false); + } + }, [waterPage, showMessage]); + + const loadFloodEvents = useCallback(async () => { + setFloodLoading(true); + try { + const res = (await getFloodDetections()).data; + setFloodEvents(res.items || []); + } catch (error) { + setFloodEvents([]); + showMessage('error', `洪涝结果加载失败:${getErrorText(error)}`); + } finally { + setFloodLoading(false); + } + }, [showMessage]); + + const refreshAll = useCallback(async () => { + await Promise.all([ + loadStatusIds(), + loadScenes(scenesPage), + loadWaterResults(waterPage), + loadFloodEvents(), + ]); + }, [loadFloodEvents, loadScenes, loadStatusIds, loadWaterResults, scenesPage, waterPage]); + + useEffect(() => { + getSearchOptions() + .then(data => setSearchOptions({ + satellite: data.satellite || [], + imaging_mode: data.imaging_mode || [], + product_level: data.product_level || [], + polarization: data.polarization || [], + })) + .catch(() => {}); + refreshAll(); + }, [refreshAll]); + + useEffect(() => { + if (!runningCount) return undefined; + const timer = window.setInterval(() => { + refreshAll(); + }, 6000); + return () => window.clearInterval(timer); + }, [refreshAll, runningCount]); + + useEffect(() => { + if (!selectedImpactEventId && doneFloodEvents[0]) { + setSelectedImpactEventId(String(doneFloodEvents[0].id)); + } + }, [doneFloodEvents, selectedImpactEventId]); + + const updateRadarDraft = (key, value) => { + setRadarDraft(prev => ({ ...prev, [key]: value })); + }; + + const runRadarSearch = async (page = 0) => { + setRadarLoading(true); + setRadarSearched(true); + try { + const criteria = normalizeRadarSearchCriteria(radarDraft, RADAR_SEARCH_DEFAULTS); + const formData = buildRadarSearchFormData({ + limit: SEARCH_PAGE_SIZE, + offset: page * SEARCH_PAGE_SIZE, + criteria, + aoiMode: 'none', + }); + const data = await searchRadarData(formData); + setRadarResults(data.items || []); + setRadarTotal(data.total || 0); + setRadarPage(page); + } catch (error) { + setRadarResults([]); + setRadarTotal(0); + showMessage('error', `雷达数据查询失败:${getErrorText(error)}`); + } finally { + setRadarLoading(false); + } + }; + + const resetRadarSearch = () => { + setRadarDraft({ ...RADAR_SEARCH_DEFAULTS }); + setRadarResults([]); + setRadarTotal(0); + setRadarPage(0); + setRadarSearched(false); + setSelectedRadars([]); + }; + + const toggleRadarSelection = (item) => { + setSelectedRadars(prev => ( + prev.some(row => row.id === item.id) + ? prev.filter(row => row.id !== item.id) + : [...prev, item] + )); + }; + + const handleSubmitPreprocess = async () => { + if (readOnly || selectedRadars.length === 0) return; + setActionBusy('geocode'); + showMessage('info', `正在提交 ${selectedRadars.length} 景水体预处理任务...`); + onTaskStart?.(null, '正在提交水体预处理任务...'); + let ok = 0; + let fail = 0; + for (const radar of selectedRadars) { + try { + const res = await submitFloodPreprocess({ radar_data_id: radar.id }); + ok += 1; + if (res.data?.task_id) onTaskStart?.(res.data.task_id, '水体预处理任务已启动'); + } catch { + fail += 1; + } + } + setActionBusy(''); + setSelectedRadars([]); + await refreshAll(); + showMessage(fail ? 'warn' : 'success', fail ? `提交完成:${ok} 成功,${fail} 失败。` : `已提交 ${ok} 个水体预处理任务。`); + }; + + const handleResetScene = async (scene) => { + if (readOnly) return; + setActionBusy(`reset_scene_${scene.id}`); + try { + await resetFloodScene(scene.id); + await refreshAll(); + showMessage('success', `场景 #${scene.id} 已重置为失败状态。`); + } catch (error) { + showMessage('error', `场景重置失败:${getErrorText(error)}`); + } finally { + setActionBusy(''); + } + }; + + const handleExtractWater = async (scene) => { + if (readOnly || asStatus(scene.status) !== 'DONE') return; + setActionBusy(`water_${scene.id}`); + showMessage('info', `正在提交场景 #${scene.id} 的水体提取任务...`); + onTaskStart?.(null, '正在提交水体提取任务...'); + try { + const res = await submitFloodWaterExtraction({ scene_id: scene.id }); + if (res.data?.task_id) onTaskStart?.(res.data.task_id, '水体提取任务已启动'); + await loadWaterResults(0); + showMessage('success', `场景 #${scene.id} 的水体提取任务已提交。`); + } catch (error) { + showMessage('error', `水体提取提交失败:${getErrorText(error)}`); + } finally { + setActionBusy(''); + } + }; + + const handleShowWaterResult = async (item) => { + setMapLoadingId(`water_${item.id}`); + try { + const preview = normalizeMapPreview(await getFloodWaterExtractionPreview(item.id)); + if (!preview) throw new Error('预览数据不完整'); + floodPanel.onShowFloodOnMap?.( + { id: `water_${item.id}`, label: `水体提取 #${item.id}` }, + { classified: preview }, + ); + showMessage('success', `水体提取 #${item.id} 已加载到地图。`); + } catch (error) { + showMessage('error', `水体图层加载失败:${getErrorText(error)}`); + } finally { + setMapLoadingId(null); + } + }; + + const handleFindPairs = async () => { + setPairSearching(true); + setCandidatePairs([]); + setSelectedPairIdx(null); + try { + const data = await searchFloodPairs({ + pre_start: compactDate(pairPreStart), + pre_end: compactDate(pairPreEnd), + post_start: compactDate(pairPostStart), + post_end: compactDate(pairPostEnd), + overlap_threshold: Number(overlapThreshold) || 0, + }); + setCandidatePairs(data.pairs || []); + setSelectedPairIdx(data.pairs?.length ? 0 : null); + showMessage(data.pairs?.length ? 'success' : 'warn', data.pairs?.length ? `找到 ${data.pairs.length} 组候选配对。` : '没有找到满足条件的配对。'); + } catch (error) { + showMessage('error', `配对推荐失败:${getErrorText(error)}`); + } finally { + setPairSearching(false); + } + }; + + const handleSubmitFloodDetection = async () => { + if (readOnly || !selectedPair) return; + setActionBusy('flood_detect'); + onTaskStart?.(null, '正在提交洪涝检测任务...'); + try { + const res = await submitFloodDetection({ + pre_scene_id: selectedPair.pre.id, + post_scene_id: selectedPair.post.id, + refine, + }); + if (res.data?.task_id) onTaskStart?.(res.data.task_id, '洪涝检测任务已启动'); + await loadFloodEvents(); + showMessage('success', `洪涝检测任务已提交:场景 #${selectedPair.pre.id} -> #${selectedPair.post.id}`); + setActiveView('results'); + setResultMode('flood'); + } catch (error) { + showMessage('error', `洪涝检测提交失败:${getErrorText(error)}`); + } finally { + setActionBusy(''); + } + }; + + const handleShowFloodEvent = async (event) => { + setMapLoadingId(event.id); + try { + const [pre, post, classified] = await Promise.all([ + getFloodDetectionPreview(event.id, 'pre').catch(() => null), + getFloodDetectionPreview(event.id, 'post').catch(() => null), + getFloodDetectionPreview(event.id, 'classified').catch(() => null), + ]); + const layers = { + pre: normalizeMapPreview(pre), + post: normalizeMapPreview(post), + classified: normalizeMapPreview(classified), + }; + floodPanel.onShowFloodOnMap?.(event, layers); + setMapLayerVis(prev => ({ + ...prev, + [event.id]: { + pre: !!layers.pre, + post: !!layers.post, + classified: !!layers.classified, + }, + })); + showMessage('success', `洪涝结果 #${event.id} 已加载到地图。`); + } catch (error) { + showMessage('error', `洪涝图层加载失败:${getErrorText(error)}`); + } finally { + setMapLoadingId(null); + } + }; + + const handleToggleFloodLayer = (eventId, key, visible) => { + setMapLayerVis(prev => ({ ...prev, [eventId]: { ...prev[eventId], [key]: visible } })); + floodPanel.onToggleFloodLayer?.(eventId, key, visible); + }; + + const handleSelectImpact = (event) => { + setSelectedImpactEventId(String(event.id)); + setActiveView('impact'); + }; + + const radarTotalPages = Math.ceil(radarTotal / SEARCH_PAGE_SIZE); + const scenesTotalPages = Math.ceil(scenesTotal / LIST_PAGE_SIZE); + const waterTotalPages = Math.ceil(waterTotal / LIST_PAGE_SIZE); + + return ( +
+
+
+
+

洪涝灾害分析

+
+ {SENSOR_ROWS.map(item => ( + {item.name} · {item.status} + ))} +
+
+ +
+
+ + + + +
+
+ + + + {message && ( +
+ {message.text} +
+ )} + + {activeView === 'extract' && ( +
+
+ + + + + )} + /> +
+ + + updateRadarDraft('imaging_date_from', value)} placeholder="成像时间起" language={language} /> + updateRadarDraft('imaging_date_to', value)} placeholder="成像时间止" language={language} /> + + +
+ +
+ {!radarSearched && 输入条件后查询入库雷达数据。} + {radarSearched && radarResults.length === 0 && !radarLoading && 未找到匹配数据。} + {radarResults.map(item => { + const selected = selectedRadars.some(row => row.id === item.id); + const done = doneRadarIds.includes(item.id); + const active = activeRadarIds.includes(item.id); + return ( +
+
+
+
+ {item.satellite || '-'} + {formatYmd(item.imaging_date, language)} + ID {item.id} + {done && 已预处理} + {active && 处理中} +
+
+ {[item.imaging_mode, item.product_level, item.polarization].filter(Boolean).join(' · ') || '-'} +
+
+ +
+
+ ); + })} +
+ {radarTotalPages > 1 && ( +
+ + {radarPage + 1} / {radarTotalPages} + +
+ )} + {selectedRadars.length > 0 && ( +
+ 已选 {selectedRadars.length} 景 + +
+ )} +
+ +
+ loadScenes(scenesPage)}>刷新场景} /> +
+ {scenesLoading && 场景加载中...} + {!scenesLoading && scenes.length === 0 && 暂无水体预处理场景。} + {!scenesLoading && scenes.map(scene => ( + {})} + onExtractWater={handleExtractWater} + onReset={handleResetScene} + /> + ))} +
+ {scenesTotalPages > 1 && ( +
+ + {scenesPage + 1} / {scenesTotalPages} + +
+ )} +
+ +
+ loadWaterResults(waterPage)}>刷新结果} /> +
+ {waterLoading && 水体结果加载中...} + {!waterLoading && waterResults.length === 0 && 暂无水体提取结果。} + {!waterLoading && waterResults.map(item => ( + + ))} +
+ {waterTotalPages > 1 && ( +
+ + {waterPage + 1} / {waterTotalPages} + +
+ )} +
+
+ )} + + {activeView === 'detect' && ( +
+
+ + + + + )} + /> +
+ + + + + + +
+
+ {candidatePairs.length === 0 && 暂无候选配对。} + {candidatePairs.map((pair, index) => { + const active = selectedPairIdx === index; + return ( + + ); + })} +
+
+ +
+ 刷新结果} /> +
+ {floodLoading && 洪涝结果加载中...} + {!floodLoading && floodEvents.length === 0 && 暂无洪涝检测结果。} + {!floodLoading && floodEvents.slice(0, 6).map(event => ( + + ))} +
+
+
+ )} + + {activeView === 'impact' && ( +
+
+ +
+ {[ + ['洪涝分类栅格', selectedImpactEvent ? `结果 #${selectedImpactEvent.id}` : '请选择结果', selectedImpactEvent ? 'ok' : 'muted'], + ['灾害点', '已有底库', 'ok'], + ['洪涝矢量化', '待接入', 'warn'], + ['行政区统计', '待接入', 'muted'], + ['当前 AOI', '待接入', 'muted'], + ['自定义矢量', '待接入', 'muted'], + ].map(([name, status, tone]) => ( +
+ {name} + {status} +
+ ))} +
+
+ +
+ +
+ {doneFloodEvents.length === 0 && 暂无已完成洪涝结果。} + {doneFloodEvents.map(event => { + const active = String(selectedImpactEvent?.id) === String(event.id); + return ( + + ); + })} +
+
+ + + +
+
+
+ )} + + {activeView === 'results' && ( +
+
+ + + + + + )} + /> + {resultMode === 'flood' && ( +
+ {floodEvents.length === 0 && 暂无洪涝检测结果。} + {floodEvents.map(event => ( + + ))} +
+ )} + {resultMode === 'water' && ( +
+ {waterResults.length === 0 && 暂无水体提取结果。} + {waterResults.map(item => ( + + ))} +
+ )} +
+ +
+ +
+ + + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/api/flood.js b/frontend/src/api/flood.js new file mode 100644 index 0000000..2d1b502 --- /dev/null +++ b/frontend/src/api/flood.js @@ -0,0 +1,91 @@ +import apiClient from './client'; + +export const getFloodSources = (params = {}) => + apiClient.get('/flood/sources', { params }); + +export const refreshFloodSources = () => + apiClient.post('/flood/sources/refresh'); + +export const getFloodSourceReadiness = (id) => + apiClient.get(`/flood/sources/${id}/readiness`); + +export const submitFloodPreprocess = (payload) => + apiClient.post('/flood/preprocess', payload); + +export const getFloodScenes = (limit = 20, offset = 0) => + apiClient.get('/flood/scenes', { params: { limit, offset } }); + +export const getFloodDoneRadarIds = () => + apiClient.get('/flood/scenes/done-radar-ids').then(r => r.data.ids); + +export const getFloodActiveRadarIds = () => + apiClient.get('/flood/scenes/active-radar-ids').then(r => r.data.ids); + +export const resetFloodScene = (sceneId) => + apiClient.post(`/flood/scenes/${sceneId}/reset`); + +export const submitFloodWaterExtraction = (payload) => + apiClient.post('/flood/water-extractions', payload); + +export const getFloodWaterExtractions = (limit = 20, offset = 0, status = null) => + apiClient.get('/flood/water-extractions', { params: { limit, offset, ...(status ? { status } : {}) } }); + +export const getFloodWaterExtractionPreview = (id) => + apiClient.get(`/flood/water-extractions/${id}/preview`).then(r => r.data); + +export const getFloodPreprocessRuns = (params = {}) => + apiClient.get('/flood/preprocess-runs', { params }); + +export const getFloodReadyProducts = (params = {}) => + apiClient.get('/flood/ready-products', { params }); + +export const getFloodReadyProductPreview = (id) => + apiClient.get(`/flood/ready-products/${id}/preview`).then(r => r.data); + +export const searchFloodPairs = (payload) => + apiClient.post('/flood/pairs/search', payload); + +export const saveFloodPair = (payload) => + apiClient.post('/flood/pairs', payload); + +export const getFloodPairs = (params = {}) => + apiClient.get('/flood/pairs', { params }); + +export const deleteFloodPair = (id) => + apiClient.delete(`/flood/pairs/${id}`); + +export const submitFloodDetection = (payload) => + apiClient.post('/flood/detections', payload); + +export const getFloodDetections = (params = {}) => + apiClient.get('/flood/detections', { params }); + +export const getFloodDetection = (id) => + apiClient.get(`/flood/detections/${id}`); + +export const getFloodDetectionPreview = (id, layer) => + apiClient.get(`/flood/detections/${id}/preview/${layer}`).then(r => r.data); + +export const vectorizeFloodDetection = (id, payload = {}) => + apiClient.post(`/flood/detections/${id}/vectorize`, payload); + +export const runFloodOverlay = (id, payload = {}) => + apiClient.post(`/flood/detections/${id}/overlay`, payload); + +export const getFloodImpact = (id) => + apiClient.get(`/flood/detections/${id}/impact`).then(r => r.data); + +export const getFloodResults = (params = {}) => + apiClient.get('/flood/results', { params }); + +export const getFloodResult = (id) => + apiClient.get(`/flood/results/${id}`); + +export const getFloodResultManifest = (id) => + apiClient.get(`/flood/results/${id}/manifest`).then(r => r.data); + +export const createFloodReport = (payload) => + apiClient.post('/flood/reports', payload); + +export const getFloodReport = (id) => + apiClient.get(`/flood/reports/${id}`).then(r => r.data); diff --git a/frontend/src/components/app/AppSidePanel.jsx b/frontend/src/components/app/AppSidePanel.jsx index 5bdb65a..d1b672f 100644 --- a/frontend/src/components/app/AppSidePanel.jsx +++ b/frontend/src/components/app/AppSidePanel.jsx @@ -19,7 +19,7 @@ const LazyDataCopierPanel = lazy(() => import('../../DataCopierPanel')); const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel')); const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel')); const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel')); -const LazyWaterMonitorPanel = lazy(() => import('../../WaterMonitorPanel')); +const LazyFloodAnalysisWorkspace = lazy(() => import('../../FloodAnalysisWorkspace')); const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel')); const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel')); const LazyAiQualityPanel = lazy(() => import('../../panels/AiQualityPanel')); @@ -57,7 +57,7 @@ export default function AppSidePanel({ pairingPanel, taskPanel, hazardPanel, - waterPanel, + floodPanel, dinsarPanel, aiPanel, pairsPanel, @@ -327,16 +327,13 @@ export default function AppSidePanel({ )} - {leftPanelTab === 'water' && ( + {leftPanelTab === 'flood_analysis' && (
- }> - }> +
diff --git a/frontend/src/config/appConstants.js b/frontend/src/config/appConstants.js index a580f3a..75353a5 100644 --- a/frontend/src/config/appConstants.js +++ b/frontend/src/config/appConstants.js @@ -101,7 +101,7 @@ export const LEFT_GROUP_LABELS = { production_management: '生产管理', insar_analysis: 'InSAR形变分析', ai_analysis: 'AI分析', - water: '水体监测', + flood_analysis: '洪涝灾害分析', ops: '运行维护', }; @@ -150,7 +150,7 @@ export const LEFT_GROUP_TABS = { production_management: [PRODUCTION_WORKSPACE_TAB], insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs), ai_analysis: LEFT_GROUP_SECTIONS.ai_analysis.flatMap(section => section.tabs), - water: ['water'], + flood_analysis: ['flood_analysis'], ops: ['health', 'users', 'audit'], }; @@ -188,7 +188,6 @@ export const ADMIN_ONLY_TABS = new Set([ 'copier', PRODUCTION_WORKSPACE_TAB, ...PRODUCTION_WORKSPACE_LEGACY_TABS, - 'water', 'users', 'audit', ]); diff --git a/frontend/src/i18n/translations.js b/frontend/src/i18n/translations.js index 8301cff..3a27f7f 100644 --- a/frontend/src/i18n/translations.js +++ b/frontend/src/i18n/translations.js @@ -17,7 +17,7 @@ { zh: '数据管理', en: 'Data' }, { zh: '生产规划', en: 'Production' }, { zh: 'D-InSAR 分析', en: 'D-InSAR Analysis' }, - { zh: '水体监测', en: 'Water Monitoring' }, + { zh: '水体监测(旧)', en: 'Water Monitoring (Legacy)' }, { zh: '运行维护', en: 'Operations' }, { zh: '入库监控', en: 'Ingest Monitor' }, diff --git a/frontend/src/utils/appUiHelpers.js b/frontend/src/utils/appUiHelpers.js index 41e586d..a2658fe 100644 --- a/frontend/src/utils/appUiHelpers.js +++ b/frontend/src/utils/appUiHelpers.js @@ -78,7 +78,9 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => { case 'uav_image_analysis': return '无人机影像分析'; case 'water': - return '水体监测'; + return '水体监测(旧)'; + case 'flood_analysis': + return '洪涝灾害分析'; case 'health': return '运维自检'; case 'users':