docs(detail-view): 新增数据集详情视图架构与扩展指南
给同事无缝接手"新增一种 ds 类型详情页"的交接文档:端到端数据流、5 个核心 抽象(ViewKind/TabSpec/策略+注册表/IDetailView/payload+DetailLoad)、现有 5 种 类型对照表、分层职责(带 file:line)、扩展配方(5A 复用视图/5B 全新视图 + 代码 骨架)、关键约定与坑、触碰文件速查表、自测建议。基于精读全链路 + Explore 代理 交叉验证。
This commit is contained in:
parent
eef8188bcb
commit
e6fb087a7f
|
|
@ -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)
|
||||
对每个 TabSpec:makeDetailView(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 的写入点)。_
|
||||
Loading…
Reference in New Issue