geopro/docs/superpowers/specs/2026-06-28-dataset-detail-v...

232 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 数据集详情视图:架构详解 + 「新增一种 ds 类型详情页」扩展指南 — 2026-06-28
> 读者:将为**一种新数据集类型**开发详情页的同事。读完本文你应能**独立按现有架构扩展,无需口头交接**。
> 所有引用均为 `文件:行号`(可点击)。代码以 2026-06-28 `fix/3d-volume-blanking-mask` 分支为准。
---
## 0. 一句话架构
**详情页 = 数据驱动的"策略 + 页签引擎"**:每种 ds 类型(由 **`ddCode`** 标识)有一个**策略**声明它有哪些页签(每页签 = 一种视图 + 一个加载键);控制器按 `ddCode` 找策略 → 建页签 → 按加载键**异步**拉数据 → 解析成**类型擦除的 payload(`QVariant`)** → **工厂**按视图种类造视图 → 视图 `setPayload` 自解包渲染。**新增类型 = 加一个策略 + 一条加载键分发 + (必要时)一个视图****不动**列表/控制器/壳层。
---
## 1. 端到端数据流(双击一个数据集会发生什么)
```
[列表 QTreeWidget] 双击 item
main.cpp:1501 itemDoubleClicked → 读 item 的 data role
kDsIdRole / kDsDdCodeRole / kDsNameRole / kDsTmObjectIdRole
main.cpp:1509 detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId)
[编排层 DatasetDetailController]
DatasetDetailController.cpp:21 openDataset()
registry_.find(ddCode) → 找策略(找不到 → emit loadFailed "暂不支持该数据类型的预览",优雅降级)
strategy->tabs() → std::vector<TabSpec>
emit datasetOpened(dsId, ddCode, dsName, tmObjectId, tabs) ──┐ 建页
对每个【非 lazy】页签 loadTab(dsId, ddCode, i) │
│ │
▼ DatasetDetailController.cpp:46 loadTabImpl() │
repo_.loadAsync(spec.loaderKey, dsId, pageNo, pageSize) │ 按【loaderKey】异步加载
→ DetailLoad*(在飞句柄,存 inflight_[tabIndex]abort-and-replace
emit tabLoadStarted(dsId, i) → 页上盖「加载中」遮罩 │
│ DetailLoad::done(QVariant payload) │
▼ │
emit tabReady(dsId, i, payload) │
│ │
▼ ▼
[壳层 DatasetDetailPanel(QTabWidget每个 ds 一页) / DatasetDetailPage(单 ds多页签)]
Panel.onDatasetOpened (DatasetDetailPanel.cpp:38) ← datasetOpened
new DatasetDetailPage → 注入(repo/viewState/tmObjectId) → page.build(tabs)
Page.build (DatasetDetailPage.cpp:33)
对每个 TabSpecmakeDetailView(spec.kind, ...) 造视图 → 组装页签
Panel.onTabReady (DatasetDetailPanel.cpp:61) ← tabReady
page.setTabPayload(i, payload) (DatasetDetailPage.cpp:92)
→ views_[i]->setPayload(payload) ← 视图自解包 payload 渲染
```
懒加载 / 分页是**反向**信号(页 → 面板 → 控制器):
- **lazy 页签**首次点开 → `Page::tabNeeded``Panel::tabNeeded``detailCtrl.loadTab`main.cpp:1522
- **paginated 页签**翻页 → `DataTableView::pageRequested``Page::tabPageNeeded``Panel::tabPageNeeded``detailCtrl.loadTabPaged`main.cpp:1525
---
## 2. 五个核心抽象(必须先理解)
| 抽象 | 定义处 | 作用 |
|---|---|---|
| **`ViewKind`**(枚举) | `src/controller/DatasetDetailTab.hpp:10` | 视图渲染种类全集:`Scatter / FilledContour / Bar / LineProfile / PolylineMap / Table / WebMap` |
| **`TabSpec`** | `src/controller/DatasetDetailTab.hpp:13` | 页签描述符 `{QString title; ViewKind kind; QString loaderKey; bool lazy; bool paginated;}` |
| **`IDatasetChartStrategy`** + **`ChartStrategyRegistry`** | `src/controller/IDatasetChartStrategy.hpp` | 策略:`ddCode()` + `tabs()`;注册表按 `ddCode` 索引策略 |
| **`IDetailView`** | `src/app/panels/chart/IDetailView.hpp` | 视图统一接口:`QWidget* widget()` + `void setPayload(const QVariant&)` |
| **Payload 结构体**6 种)+ **`DetailLoad`/`ApiDetailLoad`** | `src/core/model/detail/DetailPayloads.hpp`、`src/data/api/DatasetLoadHandles.hpp` | 纯数据载荷(`QVariant` 类型擦除)+ 异步加载句柄 |
**关键契约**
- **`ddCode`** = 分发主键(后端给每个 ds 带的类型码,如 `dd_inversion_data`)。列表把它存进 item 的 `kDsDdCodeRole`,双击时透传给 `openDataset`
- **`loaderKey`** = 控制器与仓储之间的字符串契约(如 `"inversion.scatter"`)。控制器只认 `TabSpec.loaderKey`,仓储 `loadAsync` 按它分发到具体加载函数。
- **payload** 必须 `Q_DECLARE_METATYPE` + 用 `QVariant::fromValue(...)` 装、`qvariant_cast<T>(...)`(或 `.value<T>()`)解。
---
## 3. 现有 5 种 ds 类型对照表(照抄即模板)
| ddCode | 策略(`src/app/panels/chart/*Strategy.hpp` | 页签title / ViewKind / loaderKey / lazy / paginated | payload | 视图 |
|---|---|---|---|---|
| `dd_inversion_data` | `ErtInversionStrategy.hpp` | 原数据 / Scatter / `inversion.scatter` / 否 / 否;<br>网格数据 / FilledContour / `inversion.grid` / **lazy** / 否 | `ScatterPayload` / `ContourPayload` | RawDataChartView / GridDataChartView |
| `dd_ert_measurement_data` | `MeasurementStrategy.hpp` | 散点 / Scatter / `ert_measurement.scatter`;列表 / Table / `ert_measurement.rows` | `ScatterPayload` / `TablePayload` | RawDataChartView / DataTableView |
| `dd_ert_measurement_gr_data` | `GrMeasurementStrategy.hpp` | 柱状 / Bar / `gr.bar`;列表 / Table / `gr.rows` | `BarPayload` / `TablePayload` | BarChartView / DataTableView |
| `dd_trajectory_data` | `TrajectoryStrategy.hpp` | 列表 / Table / `traj.rows`;高程 / LineProfile / `traj.elev`;地图 / WebMap / `traj.map` | `TablePayload` / `LinePayload` / `MapPayload` | DataTableView / LineChartView / TrajectoryMapView |
| `dd_grid` | `GridStrategy.hpp` | 列表 / Table / `grid.rows` / 否 / **paginated** | `TablePayload`(分页) | DataTableView |
注册处(**唯一**集中点):`src/app/main.cpp:2378-2383`
```cpp
geopro::controller::ChartStrategyRegistry chartRegistry;
chartRegistry.add(std::make_unique<geopro::app::ErtInversionStrategy>());
chartRegistry.add(std::make_unique<geopro::app::MeasurementStrategy>());
// ... 共 5 个
geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry); // main.cpp:2384
```
---
## 4. 分层职责与关键文件(按数据流顺序)
1. **列表层** —— `QTreeWidget``DatasetListPanel::populateDatasetList``src/app/panels/DatasetListPanel.cpp:33`)。**通用、与类型无关**:从后端 ds 列表(`std::vector<data::DsRow>`)填树,每行把 `ddCode` 等写进 data role。role 常量定义在 `src/app/panels/DatasetListPanel.hpp:16-26`
`kDsIdRole=0x0100` / `kDsDdCodeRole=0x0104` / `kDsNameRole=0x0105` / `kDsTmObjectIdRole=0x0108`
**两个详情入口**(都按 `kDsDdCodeRole` 路由):
- 左下数据列表双击 → `main.cpp:1501-1510` 直接 `detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId)`
- 数据列分类区双击 → `CategorySection::detailRequested(dsId, ddCode, name)``src/app/panels/columns/CategorySection.cpp:124`)→ `CategoryAnalysisTab``columns/CategoryAnalysisTab.cpp:50`)→ `buildWorkbench``openDataset``main.cpp:1967`)。⚠️ 此入口只带 dsId/ddCode/name、**不带 tmObjectId**(白化模板列表可能为空——新类型若依赖 tmObjectId 需留意)。
**新增类型通常【不用动列表】**:只要后端把新 ds 带它的 `ddCode` 返回,它就会出现在树里、双击即按 `ddCode` 路由到你的策略。
2. **编排层** —— `DatasetDetailController``src/controller/DatasetDetailController.cpp``openDataset:21` / `loadTab:37` / `loadTabPaged:41` / `loadTabImpl:46`。只依赖 `IAsyncDatasetRepository` + `ChartStrategyRegistry`**不认识任何具体类型**。
3. **策略层** —— `src/app/panels/chart/*Strategy.hpp`(每个 ~15 行)。**新增类型主要在这里加一个文件**。
4. **数据层** —— `src/data/api/ApiDatasetRepository.cpp`
- 分发 `loadAsync:160``if (loaderKey == "...") return makeXxx(dsId);`,未知 key 抛 `runtime_error`→ 控制器兜成 `loadFailed`)。
- 加载函数 `makeXxx:175-247``new ApiDetailLoad(xxxBatch(api_, dsId), [](r){ return QVariant::fromValue(payload); })`。
- 批次 `xxxBatch:52-154`(匿名命名空间):`new net::ApiBatch({api.getAsync(端点), api.postJsonAsync(端点, body)...}, &isFailure)`——**唯一端点定义处**。
- 解析器 `parseXxxParts`(本文件匿名 ns`dto::parseXxx``src/data/dto/*Dto.cpp``QList<ApiResponse>` → payload。
5. **视图层** —— 工厂 `src/app/panels/chart/DetailViewFactory.cpp:15``ViewKind` → `IDetailView`,并注入 cmdRepo/colorTplRepo/viewState/getter各视图 `RawDataChartView/GridDataChartView/DataTableView/BarChartView/LineChartView/TrajectoryMapView`
6. **壳层** —— `DatasetDetailPage`(单 ds 多页签:`build:33`、`setTabPayload:92`、lazy 遮罩、分页冒泡);`DatasetDetailPanel`QTabWidget每 ds 一页,路由控制器信号:`onDatasetOpened:38` / `onTabReady:61`)。
7. **接线** —— `main.cpp`:面板创建+注入 `1390-1395`;控制器↔面板信号 `1513-1531`;策略注册+控制器 `2378-2384`
---
## 5. 扩展配方:新增一种 ds 类型的详情页
### 前置:先判断走 5A 还是 5B
- 你的页签能否**复用现有 `ViewKind`**(散点 / 等值面 / 表格 / 柱状 / 折线 / 地图)?
- **能** → 走 **5A**(最常见,多数"又一种表格/散点"属于此)。
- **不能**(要全新的图) → 走 **5B**5A + 新视图)。
### 5A. 复用现有视图4 步,全在已有文件里加)
**① 写策略** —— 新建 `src/app/panels/chart/FooStrategy.hpp`(照抄 `GridStrategy.hpp`
```cpp
#pragma once
#include <vector>
#include "IDatasetChartStrategy.hpp"
namespace geopro::app {
struct FooStrategy : controller::IDatasetChartStrategy {
std::string ddCode() const override { return "dd_foo"; } // ← 后端给的类型码
std::vector<controller::TabSpec> tabs() const override {
return {
{QStringLiteral("列表"), controller::ViewKind::Table,
QStringLiteral("foo.rows"), /*lazy*/ false, /*paginated*/ false},
// 慢的页签设 lazy=true服务端分页设 paginated=true视图须是 DataTableView
};
}
};
} // namespace geopro::app
```
**② 注册策略** —— `src/app/main.cpp:2383` 后加一行:
```cpp
chartRegistry.add(std::make_unique<geopro::app::FooStrategy>());
```
并在 main.cpp 顶部 include `panels/chart/FooStrategy.hpp`
**③ 加 loaderKey 分发 + 加载函数** —— `src/data/api/ApiDatasetRepository.cpp`
- 分发表 `loadAsync`:171 后):`if (loaderKey == "foo.rows") return makeFooRows(dsId);`
- 批次(匿名 ns:154 附近):
```cpp
net::ApiBatch* fooRowsBatch(net::ApiClient& api, const std::string& dsId) {
QList<net::IApiCall*> calls{
api.getAsync(QStringLiteral("/business/dd/foo/rows?dsObjectId=%1").arg(enc(dsId))),
};
return new net::ApiBatch(calls, &isFailure);
}
```
- 加载函数(:247 附近):
```cpp
DetailLoad* ApiDatasetRepository::makeFooRows(const std::string& dsId) {
return new ApiDetailLoad(fooRowsBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
return QVariant::fromValue(dto::parseFooTable(r[0].data)); // → 复用的 payload如 TablePayload
});
}
```
-`src/data/api/ApiDatasetRepository.hpp` 声明 `makeFooRows`
**④ 写解析器** —— `src/data/dto/FooDto.{hpp,cpp}`(照抄 `MeasurementDto`/`GridDto``parseFooTable(const QJsonObject&) → core::TablePayload`。新增 .cpp 记得加进 `src/data/CMakeLists.txt`(或对应子目录的 CMakeLists
> 复用视图时**视图层、工厂、壳层、控制器、列表全不动**。
### 5B. 需要全新视图(在 5A 之上多 5 步)
**① 加 ViewKind** —— `src/controller/DatasetDetailTab.hpp:10` 枚举追加 `Foo`
**② 加 payload** —— `src/core/model/detail/DetailPayloads.hpp`:定义 `struct FooPayload {...};`,文件末尾加 `Q_DECLARE_METATYPE(geopro::core::FooPayload)`
**③ 写视图** —— `src/app/panels/chart/FooChartView.{hpp,cpp}`,实现 `IDetailView`
```cpp
class FooChartView : public QWidget, public IDetailView {
public:
QWidget* widget() override { return this; }
void setPayload(const QVariant& v) override {
const auto p = v.value<geopro::core::FooPayload>(); // 解包
/* 用 p 渲染QwtPlot / ECharts-via-QWebEngine / 自绘) */
}
};
```
**④ 加工厂分支** —— `src/app/panels/chart/DetailViewFactory.cpp:22``switch` 里加 `case controller::ViewKind::Foo: return std::unique_ptr<IDetailView>(new FooChartView(parent));`(需注入仓储就照 Scatter/Table 分支注入)。
**⑤ 注册 .cpp** —— `src/app/CMakeLists.txt``FooChartView.cpp`
然后照 **5A 的 ①②③④** 接策略/loaderKey/加载/解析。
---
## 6. 关键约定与坑(务必知道)
- **优雅降级**`ddCode` 未注册策略 → 控制器 `emit loadFailed "暂不支持该数据类型的预览"``DatasetDetailController.cpp:26`)。所以"详情打不开"先查**策略是否注册**。
- **未知 loaderKey** → 仓储 `loadAsync``std::runtime_error`,控制器 `loadTabImpl` 就地兜成 `loadFailed``:59-63`**不会**让遮罩永久悬挂。策略 loaderKey 必须与仓储分发表**字符串完全一致**。
- **payload 类型擦除**:忘了 `Q_DECLARE_METATYPE``QVariant::fromValue`/`.value<T>()` 不匹配 → 视图拿到空 payload、静默不渲染。
- **lazy**:数据慢的页签设 `lazy=true`——开页**不**加载,首次点开才发 `tabNeeded``DatasetDetailPage.cpp:80`),其间盖 `LoadingOverlay`
- **paginated**:服务端分页**只支持 `DataTableView`**`Page::build:61` 用 `qobject_cast<DataTableView*>``pageRequested``makeXxxRows` 要透传 `pageNo/pageSize`(见 `makeGridRows:238`)。
- **注入链**:视图所需仓储/getter`cmdRepo`/`colorTplRepo`/`projectIdGetter`/`tmObjectId`/`viewState`)由工厂在造视图时注入(`DetailViewFactory.cpp:15-65`),而面板/页**必须在 `build()` 之前**完成注入(`DatasetDetailPanel.cpp:45-48`)。新视图若要新仓储,沿 `makeDetailView` 形参 → `Page::build``Panel::onDatasetOpened``main.cpp` 注入这条链透传。
- **跨视图色阶联动(可选)**:若新视图涉及色阶且要和 2D/3D 联动,注入 `DatasetViewState* viewState`(单一真源,按 dsId不涉及就忽略传 nullptr 无副作用。详见 `src/controller/DatasetViewState.hpp`
- **并发正确性**:同一页签重复加载用 **abort-and-replace** + **句柄身份比对丢弃迟到信号**`DatasetDetailController.cpp:66,72`);退出时 abort 全部在飞句柄(`~DatasetDetailController:17`)。你写 `makeXxx` 不用操心,沿用 `ApiDetailLoad` 即可。
---
## 7. 你需要触碰的文件清单(速查)
| 场景 | 必改文件 |
|---|---|
| **5A 复用视图** | ① `FooStrategy.hpp`(新) ② `main.cpp`(注册+include) ③ `ApiDatasetRepository.{cpp,hpp}`(分发+批次+make) ④ `FooDto.{hpp,cpp}`(新解析) + 对应 `CMakeLists.txt` |
| **5B 全新视图** | 5A 全部 + `DatasetDetailTab.hpp`(ViewKind) + `DetailPayloads.hpp`(payload) + `FooChartView.{hpp,cpp}`(新) + `DetailViewFactory.cpp`(分支) + `src/app/CMakeLists.txt` |
| **永远不用动** | `DatasetDetailController`、`DatasetDetailPanel/Page`、列表 `QTreeWidget`、`IDetailView`、`DetailLoad` |
---
## 8. 自测建议
- **策略层单测**`FooStrategy{}.ddCode()` / `.tabs()` 返回符合预期(纯函数,易测)。
- **分发单测**`ApiDatasetRepository::loadAsync("foo.rows", ...)` 不抛、返回非空 `DetailLoad`
- **解析器单测**:喂一份真实后端 JSON 给 `dto::parseFooTable`,断言 payload 字段(参考现有 `tests/` 下 DTO 测试)。
- **联调**:构建 `build.bat app`,登录后在数据集树双击该类型 ds看页签/数据/遮罩是否正常;打不开先看桌面日志 `%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_*.log``[detail] openDataset ...``[detail] 未注册策略/loadAsync 失败`
---
_本文覆盖详情视图全链路列表视图为通用容器按后端 ds 列表 + ddCode 驱动),新增类型一般无需改它。如新类型在树中的归类/图标有特殊需求,再看列表填充处(`kDsDdCodeRole` 等 role 的写入点。_