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':