Document result delivery workbench design

This commit is contained in:
2026-07-08 10:20:14 +08:00
parent 36a406ae49
commit e4459180ad
7 changed files with 1199 additions and 0 deletions
+2
View File
@@ -35,6 +35,8 @@
Result extraction and access-control audit: current D-InSAR export/registration boundaries, placeholder channels, admin/viewer limitations, and recommended exporter/operator/admin permission model.
- [RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md](RESULT_DELIVERY_DOWNLOAD_DESIGN_20260630.md)
Result delivery/download design: asynchronous delivery packages, user-owned downloads, temporary delivery root, DB maintenance, and placeholder boundaries for SBAS/LT-1/Sentinel-1/GF3 ortho.
- [RESULT_CATALOG_SEARCH_DELIVERY_WORKBENCH_DESIGN_20260708.md](RESULT_CATALOG_SEARCH_DELIVERY_WORKBENCH_DESIGN_20260708.md)
Result catalog search and delivery workbench design: server-side catalog search, AOI/map preview, unified delivery item schema, selection basket, and staged implementation plan.
- [DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md](DINSAR_TASK_POOL_THREE_ENGINE_REFACTOR_20260614.md)
D-InSAR 淇濈暀 ENVI/SARscape銆丩andSAR銆丟amma/PyINT 涓夊紩鎿庯紝閫€鍑?ISCE2锛岀粺涓€ Task_Pool銆佺粨鏋滆仛鍚堝拰涓棿鏂囦欢娓呯悊鐨勫綋鍓嶈璁°€?
- [LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md](LANDSAR_DEM_PREPARATION_CONTRACT_20260618.md)
@@ -0,0 +1,479 @@
# 成果目录检索与交付工作台设计
日期:2026-07-08
## 背景
当前“结果提取”页面已经完成了成果交付的基础链路:用户从已登记 catalog 中选择成果,后台创建交付任务,并在交付完成后下载到本地。这个方向是正确的,但当前页面仍然偏向“列表勾选 + 生成交付包”,缺少成果检索、空间预览和地图辅助选择能力。
从业务上看,用户真正需要完成的是:
1. 在成果目录中按时间、区域、传感器、生产引擎和成果状态检索。
2. 在地图上确认成果覆盖范围和预览图。
3. 选择需要交付的成果。
4. 生成后台交付包并下载到本地。
因此本设计将“结果提取”能力升级为“成果目录检索与交付工作台”。“生产结果入库”继续归属 D-InSAR 结果管理页;本页面只处理已登记成果的检索、预览、选择和交付。
## 现状问题
### 前端问题
- `frontend/src/ResultExtractionPanel.jsx` 启动时只加载固定数量结果,默认 `PAGE_SIZE = 100`
- D-InSAR 结果走旧接口 `/api/dinsar-results`LT-1/GF3 正射走 `/api/result-deliveries/catalog/{channel}`,通道数据源不统一。
- “结果检索”只是前端对已加载数组做字符串过滤,不能检索未加载分页,也不能按时间、区域、AOI、状态等业务条件查询。
- 页面没有地图窗口,用户无法判断成果空间覆盖。
- 页面没有成果预览图区域,不能像 D-InSAR 结果目录一样核对位移图或正射快视。
- 选择行为和检索行为耦合在当前可见列表中,缺少稳定的“已选成果篮”概念。
- SBAS、Sentinel-1 等未完全接入通道虽然有占位,但与可交付通道的工作流差异仍不够清楚。
### 后端问题
- `GET /api/result-deliveries/catalog/{channel}` 目前只支持 `limit``offset``query`
- D-InSAR 分支没有真正应用 `query` 条件。
- catalog 返回字段不统一,缺少地图和预览需要的标准字段,例如 `coverage_polygon``bbox``preview_url`
- LT-1/GF3 分支可从 `radar_data``sar_scene_geo` 找到部分空间和预览信息,但没有统一输出到交付 catalog。
- 当前接口语义更像“交付源列表”,不是完整“成果目录检索”。
## 目标
- 将结果提取页面升级为成果目录检索、地图预览、成果选择和交付下载的一体化工作台。
- 统一 D-InSAR、LT-1 正射、GF3 正射的可交付成果 catalog 查询入口。
- 保留 SBAS-InSAR、Sentinel-1 正射占位,但明确标注未开放交付或未接入生产。
- 支持服务端检索,不依赖前端只过滤已加载的前 100 条。
- 支持时间范围、行政区/AOI、成果通道、生产引擎、状态、关键词等查询。
- 地图窗口展示成果 footprint,列表与地图联动。
- 成果预览图和详情面板用于核对质量、日期、路径和交付状态。
- 创建交付包仍使用后台任务,避免 HTTP 请求复制大文件导致 504。
- 保持普通用户不输入服务器任意路径,所有交付都落在受控交付区。
## 非目标
- 本次不实现 Sentinel-1 正射生产。
- 本次不实现 SBAS-InSAR 交付打包,只保留目录展示和清晰占位。
- 本次不改变 D-InSAR 生产结果入库流程。
- 本次不做跨节点对象存储或外部网盘。
- 本次不让用户通过页面直接删除正式成果 catalog 原文件。
- 本次不重构整套用户权限模型,但接口需要保留后续 `exporter/operator/admin` 能力拆分空间。
## 术语边界
| 术语 | 定义 | 页面归属 |
| --- | --- | --- |
| 生产结果入库 | 从 LandSAR、ENVI/SARscape、Gamma 等生产目录提取成果并登记到 catalog | D-InSAR 结果管理 |
| 成果目录检索 | 从已登记 catalog 中按条件查询成果 | 成果目录检索与交付 |
| 成果预览 | 查看成果快视图、位移图、正射缩略图、空间 footprint | 成果目录检索与交付 |
| 成果交付 | 将用户选择的成果复制到受控交付区,生成 manifest/checksum/zip | 成果目录检索与交付 |
| 交付包 | 后台生成的用户可下载成果包 | 成果目录检索与交付 |
## 目标页面结构
页面名称建议从“结果提取”调整为:
```text
成果检索与交付
```
页面采用生产系统工作台结构,不做营销式 hero。建议结构如下:
```text
┌───────────────────────────────────────────────────────────────┐
│ 顶部状态区:当前通道 / 检索结果数 / 已选数量 / 最近交付状态 │
├───────────────┬───────────────────────────────┬───────────────┤
│ 检索条件区 │ 地图预览区 │ 详情与预览区 │
│ - 通道 │ - footprint │ - 快视图 │
│ - 时间 │ - AOI/行政区边界 │ - 元数据 │
│ - 行政区/AOI │ - 选中高亮 │ - 交付状态 │
│ - 引擎/状态 │ - 点击选择/定位 │ - 主文件路径 │
├───────────────┴───────────────────────────────┴───────────────┤
│ 成果列表:分页表格 / 批量选择 / 地图定位 / 预览 / 加入选择篮 │
├───────────────────────────────────────────────────────────────┤
│ 已选成果篮 + 交付选项 + 我的交付包 │
└───────────────────────────────────────────────────────────────┘
```
### 检索条件区
第一阶段需要支持:
- 成果通道:D-InSAR、LT-1 正射、GF3 正射、SBAS 占位、Sentinel-1 占位。
- 时间范围:
- D-InSAR:主影像日期、从影像日期或结果发布时间。
- 单景正射:成像日期。
- 行政区:复用现有省/市行政区选择能力。
- AOI:复用现有上传 SHP/GeoJSON 能力,第一阶段可以先支持行政区,第二阶段支持文件上传。
- 关键词:任务名、产品号、pair_key、路径片段。
- 生产引擎:SARscape、LandSAR、Gamma/PyINT、LT-1 正射链、GF3 SARscape。
- 成果状态:READY、DONE、PARTIAL、FAILED、健康状态。
- 是否有预览:全部、有预览、无预览。
### 地图预览区
地图窗口必须服务于选择,不只是展示。
功能要求:
- 绘制查询结果的 footprint 或 bbox。
- 绘制当前 AOI/行政区边界。
- 列表选中成果时,地图高亮对应 footprint 并定位。
- 地图点击成果时,右侧详情区展示预览和元数据。
- 已加入选择篮的成果使用独立样式标识。
- 没有空间范围的成果在列表中保留,但地图上不绘制,并显示“缺少空间范围”状态。
实现建议:
- 第一阶段可复用 `MiniCoverageMap` 的 bbox/polygon 绘制逻辑,但需要抽成支持交互的 `ResultCatalogMap`
- 地图底图复用现有系统 `getBaseLayerConfig` 和 tile 配置。
- 不在地图上直接叠大体量 GeoTIFF,预览图放到右侧详情区。
### 详情与预览区
选中成果后展示:
- 成果名称、通道、产品 ID。
- 预览图:
- D-InSAR 使用 `/api/dinsar-products/{id}/preview` 或兼容缩略图接口。
- LT-1/GF3 优先使用已生成 WebP/PNG 预览。
- 无预览时显示原因和可维护提示,不阻塞交付。
- 时空信息:
- D-InSARmaster/slave 日期、时间基线、pair_key、引擎。
- 单景正射:成像日期、极化、像素大小、卫星。
- 空间信息:bbox、行政区匹配、coverage_polygon 状态。
- 文件信息:主文件路径、大小、manifest、quality 文件。
- 交付信息:是否已被当前用户交付过、最近交付状态。
### 成果列表
列表需要支持服务端分页,不能只展示前 100 条。
列建议:
- 选择框。
- 成果名称。
- 通道。
- 日期。
- 空间范围状态。
- 生产引擎/配置。
- 文件大小。
- 预览状态。
- 交付状态。
- 操作:定位地图、预览、加入/移出选择篮。
列表和地图联动:
- 点击行:更新详情区。
- 点击“定位”:地图飞到 footprint。
- 勾选:加入选择篮,不应随翻页丢失。
- 批量选择只作用于当前查询结果或当前页,按钮文案必须明确。
### 已选成果篮
当前页面需要独立的选择篮状态,而不是只从当前 `filteredResults` 里计算。
要求:
- 跨分页保持选择。
- 切换通道时保留各通道选择,但创建交付时默认只对当前通道提交。
- 显示已选数量、总大小估算、缺失文件数。
- 支持清空当前通道、移除单项。
- 超过后端 `RESULT_DELIVERY_MAX_ITEMS` 时提前提示。
### 我的交付包
继续复用现有后台交付任务。
展示要求:
- 状态:PENDING、RUNNING、READY、FAILED、CANCELLED、EXPIRED。
- 文件数、总大小、已复制大小。
- 失败项数量和原因摘要。
- manifest、zip、逐文件下载入口。
- 过期时间。
- 刷新按钮和运行中自动轮询。
## 后端接口设计
### 统一成果目录检索
建议保留现有路径,增强为真正的 catalog search
```http
GET /api/result-deliveries/catalog/{channel}
```
查询参数:
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `limit` | int | 每页数量,默认 100,最大 500 |
| `offset` | int | 分页偏移 |
| `query` | string | 关键词 |
| `date_from` | string | 起始日期,`YYYY-MM-DD``YYYYMMDD` |
| `date_to` | string | 结束日期 |
| `date_mode` | string | `imaging``master``slave``published` |
| `region_tree_id` | string | 行政区树 ID |
| `aoi_token` | string | 已上传 AOI token,第二阶段接入 |
| `engine_code` | string | 生产引擎 |
| `profile_code` | string | 生产配置 |
| `status` | string | 成果状态 |
| `has_preview` | bool | 是否只查有预览成果 |
| `has_geometry` | bool | 是否只查有空间范围成果 |
| `sort` | string | `date_desc``published_desc``name_asc` |
返回:
```json
{
"items": [],
"total": 0,
"limit": 100,
"offset": 0,
"has_more": false,
"summary": {
"channel": "dinsar",
"matched": 0,
"with_geometry": 0,
"with_preview": 0,
"selected_hint": null
}
}
```
### 统一 catalog item 字段
所有通道返回尽量统一字段:
```json
{
"id": 1,
"item_id": 1,
"channel": "dinsar",
"source_kind": "dinsar_result",
"product_id": "xxx",
"display_name": "xxx",
"task_name": "xxx",
"status": "READY",
"health_status": "OK",
"engine_code": "landsar",
"profile_code": "lt1_dinsar",
"pair_key": "20260201_20260301",
"imaging_date": null,
"master_imaging_date": "2026-02-01",
"slave_imaging_date": "2026-03-01",
"published_at": "2026-07-08T10:00:00",
"primary_asset_path": "D:\\...",
"publish_dir": "D:\\...",
"file_size": 123456789,
"preview_url": "/api/dinsar-products/1/preview",
"preview_status": "READY",
"coverage_polygon": [[126.1, 45.1], [126.2, 45.1], [126.2, 45.2], [126.1, 45.2]],
"bbox": {
"min_lon": 126.1,
"min_lat": 45.1,
"max_lon": 126.2,
"max_lat": 45.2
},
"geometry_status": "READY",
"delivery_summary": {
"latest_delivery_id": null,
"latest_status": null
}
}
```
### D-InSAR 字段来源
D-InSAR catalog 应优先读取 `result_products`
- `product_id`
- `catalog_name`
- `product_family`
- `product_type`
- `engine_code`
- `profile_code`
- `pair_key`
- `task_name`
- `task_alias`
- `primary_asset_path`
- `publish_dir`
- `manifest_path`
- `coverage_polygon`
- `min_lon``min_lat``max_lon``max_lat`
- `produced_at`
- `published_at`
兼容旧 `dinsar_results` 的 ID 时,需要同时保留 `compat_result_id`,但前端不应感知两套 ID 规则。
### LT-1 正射字段来源
LT-1 正射 catalog 读取 `sar_scene_geo``radar_data`
- `sar_scene_geo.id` 作为 `item_id`
- `sar_scene_geo.analysis_tif_path` 作为主交付物
- `sar_scene_geo.analysis_dir` 作为发布目录
- `radar_data.imaging_date`
- `radar_data.polarization`
- `radar_data.coverage_polygon`
- `radar_data.min_lon/min_lat/max_lon/max_lat` 或由 polygon 计算 bbox
- `radar_data.preview`/WebP 缓存作为预览来源
### GF3 正射字段来源
GF3 正射 catalog 读取 `radar_data`,必要时左连接 `sar_scene_geo`
- `radar_data.id` 作为 `item_id`
- `radar_data.file_path``sar_scene_geo.analysis_tif_path` 作为主文件/目录
- `radar_data.source_format`
- `radar_data.geocoded_flag`
- `radar_data.imaging_date`
- `radar_data.polarization`
- `radar_data.coverage_polygon`
- WebP/quicklook 作为预览来源
## 空间检索设计
第一阶段:
- 支持行政区 `region_tree_id`
- 后端把行政区 geometry 转为 AOI。
- 对有 `coverage_polygon` 或 bbox 的成果做空间相交过滤。
- 对缺失 geometry 的成果默认不进入空间过滤结果,但在 summary 中统计缺失数量。
第二阶段:
- 支持上传 SHP/GeoJSON,复用现有 AOI upload/token 机制。
- 支持地图框选。
- 支持空间关系选项:
- `intersects`
- `contains_center`
- `overlap_ratio >= threshold`
## 预览设计
### D-InSAR
- 首选标准结果产品预览接口。
- 如果只有兼容旧 `dinsar_results` 缩略图,则 fallback 到旧 `/api/dinsar-results/{id}/thumb`
- 预览失败不影响交付,但列表显示“预览异常”。
### LT-1/GF3 正射
- 优先使用资产 WebP/quicklook。
- 若没有 WebP,但已有 GeoTIFF,可显示“未生成预览”,由运维/扫描任务补建。
- 不在本页面同步触发重建预览,避免打开检索页导致重任务。
## 权限与审计
第一阶段沿用当前登录用户模型:
- 登录用户可查看成果 catalog。
- 登录用户可创建自己的成果交付任务。
- 管理员可查看全部交付任务和执行清理。
- 生产结果入库、目录重建、正式成果删除仍属于管理员/运维能力。
所有交付创建需要记录:
- 用户。
- 查询通道。
- 提交的 item IDs。
- package mode。
- 任务 ID。
- 交付 ID。
后续拆角色时,建议能力名:
```text
result.catalog.read
result.delivery.create
result.delivery.read_own
result.delivery.read_all
result.delivery.cleanup
result.product.ingest
```
## 性能与可靠性
- catalog 查询必须服务端分页。
- `limit` 最大 500,默认 100。
- 空间查询需要优先使用数据库索引;如果当前表缺少 geometry 索引,应先用 bbox 粗筛,再做 polygon 精筛。
- 地图首屏最多绘制当前页或当前查询前 N 个 footprint,避免一次渲染数千 polygon。
- 预览图懒加载,只有选中或进入可视区域才请求。
- 交付包创建只提交 ID 列表,不提交文件路径。
- 后台交付任务必须记录单项失败,不应一个文件失败导致整批不可用。
## 实施阶段
### 阶段 1:文档和接口契约
- 完成本设计文档。
- 明确“成果检索与交付”和“生产结果入库”边界。
- 梳理 D-InSAR、LT-1、GF3 catalog 字段来源。
### 阶段 2:后端 catalog 查询增强
- 增强 `/api/result-deliveries/catalog/{channel}` 参数。
- D-InSAR 分支应用 `query`、分页、日期、引擎和状态过滤。
- LT-1/GF3 分支补充日期、行政区/AOI、状态、预览和 geometry 字段。
- 返回统一 item schema。
- 增加必要的单元测试或接口级测试。
### 阶段 3:前端工作台改造
- 重构 `ResultExtractionPanel.jsx` 为成果检索工作台。
- 新增检索条件区。
- 新增地图预览区。
- 新增详情与预览区。
- 改造列表为服务端分页。
- 新增跨分页选择篮。
- 保留我的交付包列表和后台轮询。
### 阶段 4:联动和质量收口
- 列表、地图、详情三者联动。
- 交付创建前校验最大数量和缺失文件。
- 未接入通道显示明确占位,不可误触发交付。
- 补充空状态、错误状态、加载骨架和响应式布局。
## 验收标准
完成后应满足:
1. 用户可以按通道、时间、关键词、引擎、状态检索成果。
2. 用户可以选择行政区并只查看相交成果。
3. 检索结果不是前端过滤前 100 条,而是服务端分页结果。
4. 地图能显示当前检索结果的 footprint 或 bbox。
5. 点击列表能定位地图并展示预览详情。
6. 点击地图能选中对应成果并展示详情。
7. 用户跨分页选择成果后,选择篮不丢失。
8. 创建交付包仍是后台任务,不出现同步复制导致的 504。
9. D-InSAR、LT-1 正射、GF3 正射三类可交付成果字段一致。
10. SBAS、Sentinel-1 未接入能力不会被用户误认为可执行。
11. 缺失预览或缺失空间范围的成果有明确状态,不阻断其他成果交付。
12. 普通用户不需要输入服务器路径。
## 风险与注意事项
- D-InSAR 旧结果兼容 ID 和新 `result_products.id` 不能混淆,交付创建接口需要继续兼容 `compat_result_ids`
- 某些历史成果可能没有 `coverage_polygon`,空间检索时要给出统计和提示。
- LT-1/GF3 的预览来源可能不一致,第一阶段允许 `preview_status = MISSING`
- 地图渲染大量 polygon 可能卡顿,需要限制首屏绘制数量或做简化。
- 结果提取页面改名后,导航和用户手册需要同步调整。
## 建议代码入口
后端:
- `backend/app/routers/result_deliveries.py`
- `backend/app/services/result_delivery_service.py`
- `backend/app/services/dinsar_read_service.py`
- `backend/app/routers/radar.py` 中已有 AOI/搜索逻辑可参考
前端:
- `frontend/src/ResultExtractionPanel.jsx`
- `frontend/src/api/resultDeliveries.js`
- `frontend/src/components/MiniCoverageMap.jsx`
- `frontend/src/components/DinsarCatalogPanel.jsx`
- `frontend/src/panels/PairPlanningPanel.jsx`
- `frontend/src/hooks/useRadarSearch.js`
+384
View File
@@ -1700,6 +1700,371 @@ input[type="checkbox"] {
word-break: break-all;
}
.manual-workspace {
min-height: 100%;
background: #eef2f7;
padding: 16px;
}
.manual-shell {
width: min(100%, 1480px);
margin: 0 auto;
display: grid;
gap: 12px;
}
.manual-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
gap: 18px;
align-items: end;
background: #ffffff;
border: 1px solid #d5dfeb;
border-radius: 8px;
padding: 16px 18px;
}
.manual-hero h1 {
margin: 0;
color: #0f172a;
font-size: 23px;
line-height: 1.25;
letter-spacing: 0;
}
.manual-hero p {
margin: 6px 0 0;
max-width: 76ch;
color: #475569;
font-size: 13px;
line-height: 1.6;
}
.manual-search {
display: grid;
gap: 6px;
}
.manual-search label {
color: #475569;
font-size: 12px;
font-weight: 800;
}
.manual-search input {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #f8fafc;
color: #0f172a;
font-size: 13px;
padding: 9px 10px;
}
.manual-search input:focus {
outline: 2px solid rgba(37, 99, 235, 0.2);
border-color: #2563eb;
background: #ffffff;
}
.manual-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.manual-nav,
.manual-section,
.manual-faq {
background: #ffffff;
border: 1px solid #d5dfeb;
border-radius: 8px;
}
.manual-nav {
position: sticky;
top: 0;
overflow: hidden;
}
.manual-nav-title {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
padding: 12px;
border-bottom: 1px solid #e2e8f0;
}
.manual-nav-title strong {
color: #0f172a;
font-size: 14px;
}
.manual-nav-title span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.manual-nav-list {
display: grid;
gap: 4px;
max-height: calc(100vh - 210px);
overflow: auto;
padding: 8px;
}
.manual-nav-list button {
display: grid;
gap: 4px;
width: 100%;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: #334155;
cursor: pointer;
padding: 9px 10px;
text-align: left;
}
.manual-nav-list button:hover {
border-color: #d5dfeb;
background: #f8fafc;
}
.manual-nav-list button.is-active {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.manual-nav-list button strong {
font-size: 13px;
line-height: 1.35;
}
.manual-nav-list button span {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.manual-nav-list button.is-active span {
color: #1e40af;
}
.manual-empty {
color: #64748b;
font-size: 13px;
padding: 12px;
text-align: center;
}
.manual-content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 340px);
gap: 12px;
align-items: start;
}
.manual-section {
padding: 16px;
}
.manual-section-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
}
.manual-section-header h2,
.manual-faq-header h2 {
margin: 0;
color: #0f172a;
font-size: 18px;
line-height: 1.35;
}
.manual-section-header p {
margin: 5px 0 0;
color: #475569;
font-size: 13px;
line-height: 1.6;
max-width: 70ch;
}
.manual-tag-row {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
flex: 0 0 auto;
}
.manual-tag-row span {
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #f8fafc;
color: #334155;
font-size: 12px;
font-weight: 800;
padding: 5px 8px;
}
.manual-block {
margin-top: 14px;
}
.manual-block h3 {
margin: 0 0 8px;
color: #0f172a;
font-size: 14px;
line-height: 1.35;
}
.manual-check-list {
display: grid;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.manual-check-list li {
position: relative;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
color: #334155;
font-size: 13px;
line-height: 1.55;
padding: 8px 10px 8px 28px;
}
.manual-check-list li::before {
content: "";
position: absolute;
left: 11px;
top: 15px;
width: 7px;
height: 7px;
border-radius: 999px;
background: #2563eb;
}
.manual-flow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 8px;
}
.manual-flow-step {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
gap: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
padding: 9px;
}
.manual-flow-step > span {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 900;
}
.manual-flow-step strong {
display: block;
color: #0f172a;
font-size: 13px;
line-height: 1.35;
}
.manual-flow-step p {
margin: 3px 0 0;
color: #475569;
font-size: 12px;
line-height: 1.5;
}
.manual-tip-list {
display: grid;
gap: 8px;
}
.manual-tip-list p {
margin: 0;
border: 1px solid #fde68a;
border-radius: 6px;
background: #fffbeb;
color: #78350f;
font-size: 13px;
line-height: 1.55;
padding: 8px 10px;
}
.manual-faq {
padding: 12px;
}
.manual-faq-header {
border-bottom: 1px solid #e2e8f0;
padding-bottom: 10px;
}
.manual-faq-header span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.manual-faq-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.manual-faq-list details {
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
.manual-faq-list summary {
cursor: pointer;
color: #0f172a;
font-size: 13px;
font-weight: 800;
line-height: 1.45;
padding: 9px 10px;
}
.manual-faq-list details[open] summary {
border-bottom: 1px solid #e2e8f0;
}
.manual-faq-list p {
margin: 0;
color: #475569;
font-size: 12px;
line-height: 1.55;
padding: 9px 10px;
}
.statistics-workspace {
min-height: 100%;
background: #eef2f7;
@@ -2691,6 +3056,9 @@ input[type="checkbox"] {
}
@media (max-width: 980px) {
.manual-hero,
.manual-layout,
.manual-content,
.statistics-kpi-grid,
.statistics-command-grid,
.statistics-dashboard-grid,
@@ -2699,6 +3067,22 @@ input[type="checkbox"] {
grid-template-columns: 1fr;
}
.manual-nav {
position: static;
}
.manual-nav-list {
max-height: none;
}
.manual-section-header {
flex-direction: column;
}
.manual-tag-row {
justify-content: flex-start;
}
.statistics-section--orbit {
grid-column: auto;
}
+318
View File
@@ -0,0 +1,318 @@
import { useMemo, useState } from 'react';
const MANUAL_SECTIONS = [
{
id: 'start',
title: '快速入门',
summary: '按生产链路理解系统:先接入数据,再规划任务,最后生产、登记和交付成果。',
tags: ['流程', '新用户'],
highlights: [
'影像检索用于确认数据是否覆盖目标区域和时间。',
'资产台账用于确认源产品、精密轨道、元数据和几何画像是否可用于生产。',
'生产管理集中处理 D-InSAR、SBAS-InSAR 和 LT-1 单景生产。',
'结果提取面向成果登记和交付下载,不负责重新生产数据。',
],
workflow: [
{ name: '数据接入', detail: '扫描源数据、精密轨道和预览缓存,形成可查询资产。' },
{ name: '任务规划', detail: '在影像池中按时间、空间、AOI 和精轨条件生成候选任务。' },
{ name: '生产运行', detail: '按批次准备 Task_Pool,选择生产引擎并跟踪运行状态。' },
{ name: '结果管理', detail: '提取标准成果、登记 catalog,并为用户交付提供入口。' },
],
tips: [
'首次使用时先看“综合统计”,确认元数据、几何画像、精轨和预览是否完整。',
'生产前优先使用影像检索或省域覆盖配对检查空间覆盖,不要只看候选对数量。',
],
},
{
id: 'data',
title: '数据资产',
summary: '数据资产页负责把磁盘上的源数据和精密轨道变成系统可审计的生产资产。',
tags: ['扫描', '资产台账', '精轨'],
highlights: [
'源数据扫描是增量扫描:未变化的压缩包会在解析元数据前跳过。',
'精密轨道扫描也是增量扫描,LT-1 TXT 和 Sentinel-1 EOF 会登记为 orbit assets。',
'源数据扫描和精轨扫描结束后都会触发一次轨道绑定,因此先扫哪一类都能最终匹配。',
'预览缓存是辅助查看能力,不等同于生产就绪状态。',
],
workflow: [
{ name: '数据接入', detail: '选择源数据或精轨目录,启动资产索引任务。' },
{ name: '资产台账', detail: '查看源产品、精轨资产、绑定状态和开放问题。' },
{ name: '影像检索', detail: '按卫星、时间、轨道、AOI 和精轨状态筛选生产候选影像。' },
],
tips: [
'“缺精轨”按受影响影像景数统计,不等于缺失文件数量;统计页会展示实际缺失的卫星日期。',
'扫描日志中的 skipped 表示缓存命中,changed/new 表示进入解析队列的新增或变化文件。',
],
},
{
id: 'pairing',
title: '配对规划',
summary: 'D-InSAR 配对规划用于从影像池中生成标准候选对,并支持省域覆盖导向的贪心规划。',
tags: ['D-InSAR', '省域覆盖', '批次'],
highlights: [
'普通配对按时间基线、空间关系、重叠率、卫星模式、极化和精轨可用性筛选。',
'省域覆盖配对以 AOI 空间覆盖最大化为目标,时间范围是影像池约束。',
'保存后的批次可以分批生产,批次数量由用户指定,不需要一次性生产全部候选对。',
'已完成且已登记的结果应在候选列表中被识别,避免重复生产。',
],
workflow: [
{ name: '设置影像池', detail: '输入目标日期范围,必要时设置扩展天数补足覆盖。' },
{ name: '设置 AOI', detail: '选择行政区或上传 AOI 文件;省域任务应明确选择省级范围。' },
{ name: '生成候选对', detail: '检查覆盖、精轨、时间基线和质量指标。' },
{ name: '保存批次', detail: '按用户输入的数量分批保存,后续在生产准备中逐批处理。' },
],
tips: [
'省域覆盖的核心指标是空间覆盖,不是生成候选对数量。',
'如果候选很多但覆盖不足,优先检查 AOI、轨道方向、时间范围和精轨条件。',
],
},
{
id: 'production',
title: '生产管理',
summary: '生产管理把配对、数据准备、引擎运行和结果目录组织在同一个工作台内。',
tags: ['D-InSAR', 'SBAS', 'LT-1'],
highlights: [
'D-InSAR 工作台包含配对规划、候选对与批次、生产准备、生产运行和结果管理。',
'D-InSAR 当前支持 ENVI/SARscape、LandSAR 和 Gamma/PyINT 等生产路线。',
'LT-1 单景生产由服务器侧流水线输出 analysis_ready.tif,并登记为可交付正射成果。',
'SBAS-InSAR 当前以 Gamma 工作流为主,生产和成果目录与 D-InSAR 分开管理。',
],
workflow: [
{ name: '生产准备', detail: '把批次材料化到 Task_Pool,并复制源数据和精密轨道。' },
{ name: '生产运行', detail: '选择生产引擎,提交运行并观察成功、失败、部分成功状态。' },
{ name: '结果管理', detail: '从生产目录提取成果,登记标准 catalog 和预览资产。' },
],
tips: [
'“部分成功”表示批次内有成功结果也有失败任务;只有全部失败才应视为失败。',
'LandSAR 访问冲突、基线精估计失败、DEM/地理编码失败通常需要结合日志判断是数据质量、参数还是软件稳定性问题。',
],
},
{
id: 'results',
title: '结果管理与交付',
summary: '结果页面区分生产结果入库和用户成果交付,避免大文件下载阻塞页面请求。',
tags: ['成果', '下载', '登记'],
highlights: [
'D-InSAR 结果提取用于把生产目录中的位移结果登记为标准成果。',
'LT-1 正射和 GF3 正射属于可交付影像成果;Sentinel-1 正射仍是占位能力。',
'大体量成果建议通过后台交付任务生成目录或压缩包,再下载到本地。',
'服务器交付区只是临时缓存,正式成果仍以登记 catalog 和源生产目录为准。',
],
workflow: [
{ name: '登记成果', detail: '确认生产目录、主成果文件、预览和 manifest。' },
{ name: '选择交付内容', detail: '按 D-InSAR、LT-1 正射、GF3 正射等通道选择可交付成果。' },
{ name: '生成交付包', detail: '后台复制文件并生成 manifest 和校验信息。' },
{ name: '下载本地', detail: '用户从交付目录或下载链接获取成果。' },
],
tips: [
'结果提取失败时先看任务日志和结果目录,不要直接删除生产源目录。',
'SBAS 和 Sentinel-1 正射如果显示为规划中,表示页面保留入口但尚未接入真实交付。',
],
},
{
id: 'statistics',
title: '综合统计',
summary: '综合统计用于快速判断系统的数据、轨道、生产和成果健康状态。',
tags: ['统计', '健康状态'],
highlights: [
'元数据入库率只统计系统当前管理的 LT-1 和 Sentinel-1 源资产。',
'几何画像可用率表示可用于空间覆盖统计的影像画像是否准备好。',
'精轨绑定率按可生产影像场景计算,缺轨会进一步拆成受影响景数和实际缺失卫星日期。',
'待关注项是生产管理指标,不应把历史 XML 旧口径或重复问题混入统计。',
],
workflow: [
{ name: '看总览', detail: '先检查 KPI 是否有 warning 或 danger。' },
{ name: '看覆盖', detail: '查看源数据和成果在行政区上的覆盖密度。' },
{ name: '看精轨', detail: '确认缺口是文件日期缺失还是受影响景数较多。' },
{ name: '看问题闭环', detail: '按问题代码判断是否需要重新扫描、补轨或清理任务。' },
],
tips: [
'刷新统计会触发较重的聚合查询,生产高峰期不要频繁刷新。',
'精轨缺口显示 1000 多景时,实际可能只缺几十个卫星日期。',
],
},
{
id: 'ops',
title: '运行维护',
summary: '运行维护用于发现失败任务、残留目录、扫描异常和服务环境问题。',
tags: ['任务维护', '清理', '自检'],
highlights: [
'任务维护面板只负责诊断和清理,不提供自动重跑。',
'清理前会预览数据库记录和磁盘目录影响范围,禁止删除源数据、DEM、精轨和代码目录。',
'扫描任务如果因为服务器断电或进程退出失败,应先确认 worker 是否仍在运行。',
'配对基础重建、数据分发、生产运行等长任务都应通过任务中心和维护面板观察。',
],
workflow: [
{ name: '运行自检', detail: '查看数据库、目录、任务、缓存和生产环境状态。' },
{ name: '任务维护', detail: '筛选失败、部分成功、取消或疑似 stale 的任务。' },
{ name: '诊断详情', detail: '查看 job、run、item、execution、日志和磁盘路径。' },
{ name: '清理后重提', detail: '管理员清理失败记录和残留目录后,用户回业务页面重新提交。' },
],
tips: [
'正在运行的生产任务不要直接清理,应等待结束或先确认没有真实进程。',
'LandSAR_Work 当前只清理运行工作目录,不清理正式成果、源数据、DEM 或精轨。',
],
},
];
const FAQ_ITEMS = [
{
question: '为什么缺精轨数量看起来很多?',
answer: '统计首页的缺精轨数量是受影响影像景数。LT-1 一个缺失卫星日期可能影响几十景,因此要同时查看“缺失卫星日期”清单。',
},
{
question: '扫描日志里的 skipped 是不是还在解析?',
answer: '不是。skipped 表示缓存命中,文件在元数据解析前已经被过滤;changed/new 才表示进入解析队列。',
},
{
question: '生产任务部分成功是不是失败?',
answer: '不是。部分成功表示同一批任务中有成功也有失败,成功结果应保留和登记,失败项需要结合日志判断是否重跑。',
},
{
question: '为什么省域覆盖配对只看见少量绿色框?',
answer: '省域覆盖规划的目标是尽量覆盖 AOI 空间范围。需要确认 AOI 是否正确选择、候选是否全量显示,以及 max_pairs 是否限制过低。',
},
{
question: '结果提取和成果下载有什么区别?',
answer: '结果提取是把生产目录中的成果登记到系统 catalog;成果下载是用户从已登记成果中申请交付包并下载到本地。',
},
];
function filterSections(sections, query) {
const keyword = query.trim().toLowerCase();
if (!keyword) return sections;
return sections.filter((section) => {
const haystack = [
section.title,
section.summary,
...(section.tags || []),
...(section.highlights || []),
...(section.tips || []),
...(section.workflow || []).flatMap((item) => [item.name, item.detail]),
].join(' ').toLowerCase();
return haystack.includes(keyword);
});
}
export default function UserManualPanel() {
const [activeSectionId, setActiveSectionId] = useState(MANUAL_SECTIONS[0].id);
const [query, setQuery] = useState('');
const visibleSections = useMemo(() => filterSections(MANUAL_SECTIONS, query), [query]);
const activeSection = visibleSections.find((section) => section.id === activeSectionId)
|| visibleSections[0]
|| MANUAL_SECTIONS[0];
const handleSelectSection = (sectionId) => {
setActiveSectionId(sectionId);
};
return (
<div className="manual-workspace">
<div className="manual-shell">
<header className="manual-hero">
<div>
<h1>用户手册</h1>
<p>面向日常生产结果管理和运行维护的操作说明内容来自当前有效工程文档并按用户工作流重新整理</p>
</div>
<div className="manual-search">
<label htmlFor="manual-search-input">搜索手册</label>
<input
id="manual-search-input"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="输入:精轨、配对、交付、任务维护..."
/>
</div>
</header>
<div className="manual-layout">
<nav className="manual-nav" aria-label="用户手册目录">
<div className="manual-nav-title">
<strong>目录</strong>
<span>{visibleSections.length}/{MANUAL_SECTIONS.length}</span>
</div>
<div className="manual-nav-list">
{visibleSections.map((section) => (
<button
key={section.id}
type="button"
className={section.id === activeSection.id ? 'is-active' : ''}
onClick={() => handleSelectSection(section.id)}
>
<strong>{section.title}</strong>
<span>{section.summary}</span>
</button>
))}
{!visibleSections.length && (
<div className="manual-empty">没有匹配的章节</div>
)}
</div>
</nav>
<main className="manual-content">
<section className="manual-section">
<div className="manual-section-header">
<div>
<h2>{activeSection.title}</h2>
<p>{activeSection.summary}</p>
</div>
<div className="manual-tag-row">
{(activeSection.tags || []).map((tag) => <span key={tag}>{tag}</span>)}
</div>
</div>
<div className="manual-block">
<h3>关键判断</h3>
<ul className="manual-check-list">
{activeSection.highlights.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="manual-block">
<h3>推荐流程</h3>
<div className="manual-flow">
{activeSection.workflow.map((item, index) => (
<div key={item.name} className="manual-flow-step">
<span>{index + 1}</span>
<div>
<strong>{item.name}</strong>
<p>{item.detail}</p>
</div>
</div>
))}
</div>
</div>
<div className="manual-block">
<h3>操作提示</h3>
<div className="manual-tip-list">
{activeSection.tips.map((item) => (
<p key={item}>{item}</p>
))}
</div>
</div>
</section>
<aside className="manual-faq">
<div className="manual-faq-header">
<h2>常见问题</h2>
<span>生产现场高频问题</span>
</div>
<div className="manual-faq-list">
{FAQ_ITEMS.map((item) => (
<details key={item.question}>
<summary>{item.question}</summary>
<p>{item.answer}</p>
</details>
))}
</div>
</aside>
</main>
</div>
</div>
</div>
);
}
@@ -31,6 +31,7 @@ const LazySbasInsarMapAnalysisPanel = lazy(() => import('../../panels/SbasInsarM
const LazyProductionWorkspace = lazy(() => import('../../ProductionWorkspace'));
const LazyStatisticsDashboard = lazy(() => import('../../StatisticsDashboard'));
const LazyResultExtractionPanel = lazy(() => import('../../ResultExtractionPanel'));
const LazyUserManualPanel = lazy(() => import('../../UserManualPanel'));
export default function AppSidePanel({
leftPanelWidth,
@@ -229,6 +230,14 @@ export default function AppSidePanel({
</div>
)}
{leftPanelTab === 'user_manual' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载用户手册..." />}>
<LazyUserManualPanel />
</Suspense>
</div>
)}
{leftPanelTab === 'ingest' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
<Suspense fallback={<PanelLoadingBody message="正在加载数据接入面板..." />}>
+3
View File
@@ -197,6 +197,7 @@ export const LEFT_GROUP_LABELS = {
statistics: '综合统计',
flood_analysis: '灾害分析',
ops: '运行维护',
help: '帮助文档',
};
export const LEFT_GROUP_SECTIONS = {
@@ -222,6 +223,7 @@ export const LEFT_GROUP_TABS = {
statistics: ['statistics'],
flood_analysis: ['flood_analysis'],
ops: ['health', 'ops_tasks', 'users', 'audit'],
help: ['user_manual'],
};
export const LEFT_TAB_GROUP = Object.entries(LEFT_GROUP_TABS).reduce((acc, [group, tabs]) => {
@@ -248,6 +250,7 @@ export const FULL_WIDTH_LEFT_TABS = new Set([
...PRODUCTION_WORKSPACE_ROUTE_TABS,
'statistics',
'result_extraction',
'user_manual',
]);
export const ADMIN_ONLY_TABS = new Set([
+4
View File
@@ -93,6 +93,8 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
return '用户管理';
case 'audit':
return '审计日志';
case 'user_manual':
return '用户手册';
default:
return tabKey;
}
@@ -128,6 +130,8 @@ export const getLeftTabDescription = (tabKey) => {
return '维护系统用户、角色与访问权限。';
case 'audit':
return '查看关键操作和生产任务的审计记录。';
case 'user_manual':
return '查看系统主要工作流、生产口径、统计解释和常见问题,辅助用户快速定位正确操作入口。';
default:
return '当前模块用于支撑科研工程生产流程。';
}