17 KiB
数据集详情视图:架构详解 + 「新增一种 ds 类型详情页」扩展指南 — 2026-06-28
读者:将为一种新数据集类型开发详情页的同事。读完本文你应能独立按现有架构扩展,无需口头交接。 所有引用均为
文件:行号(可点击)。代码以 2026-06-28fix/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 / 否 / 否;网格数据 / 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
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. 分层职责与关键文件(按数据流顺序)
- 列表层 ——
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路由到你的策略。
- 左下数据列表双击 →
- 编排层 ——
DatasetDetailController(src/controller/DatasetDetailController.cpp):openDataset:21/loadTab:37/loadTabPaged:41/loadTabImpl:46。只依赖IAsyncDatasetRepository+ChartStrategyRegistry,不认识任何具体类型。 - 策略层 ——
src/app/panels/chart/*Strategy.hpp(每个 ~15 行)。新增类型主要在这里加一个文件。 - 数据层 ——
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。
- 分发
- 视图层 —— 工厂
src/app/panels/chart/DetailViewFactory.cpp:15(ViewKind→IDetailView,并注入 cmdRepo/colorTplRepo/viewState/getter);各视图RawDataChartView/GridDataChartView/DataTableView/BarChartView/LineChartView/TrajectoryMapView。 - 壳层 ——
DatasetDetailPage(单 ds 多页签:build:33、setTabPayload:92、lazy 遮罩、分页冒泡);DatasetDetailPanel(QTabWidget,每 ds 一页,路由控制器信号:onDatasetOpened:38/onTabReady:61)。 - 接线 ——
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):
#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 后加一行:
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 附近):
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 附近):
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:
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 的写入点)。