docs(detail-view): 新增数据集详情视图架构与扩展指南

给同事无缝接手"新增一种 ds 类型详情页"的交接文档:端到端数据流、5 个核心
抽象(ViewKind/TabSpec/策略+注册表/IDetailView/payload+DetailLoad)、现有 5 种
类型对照表、分层职责(带 file:line)、扩展配方(5A 复用视图/5B 全新视图 + 代码
骨架)、关键约定与坑、触碰文件速查表、自测建议。基于精读全链路 + Explore 代理
交叉验证。
This commit is contained in:
gaozheng 2026-06-28 22:34:54 +08:00
parent eef8188bcb
commit e6fb087a7f
1 changed files with 231 additions and 0 deletions

View File

@ -0,0 +1,231 @@
# 数据集详情视图:架构详解 + 「新增一种 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 的写入点。_