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

17 KiB
Raw Permalink Blame History

数据集详情视图:架构详解 + 「新增一种 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::tabNeededPanel::tabNeededdetailCtrl.loadTabmain.cpp:1522
  • paginated 页签翻页 → DataTableView::pageRequestedPage::tabPageNeededPanel::tabPageNeededdetailCtrl.loadTabPagedmain.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.hppsrc/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. 分层职责与关键文件(按数据流顺序)

  1. 列表层 —— QTreeWidgetDatasetListPanel::populateDatasetListsrc/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)→ CategoryAnalysisTabcolumns/CategoryAnalysisTab.cpp:50)→ buildWorkbenchopenDatasetmain.cpp:1967)。⚠️ 此入口只带 dsId/ddCode/name、不带 tmObjectId(白化模板列表可能为空——新类型若依赖 tmObjectId 需留意)。 → 新增类型通常【不用动列表】:只要后端把新 ds 带它的 ddCode 返回,它就会出现在树里、双击即按 ddCode 路由到你的策略。
  2. 编排层 —— DatasetDetailControllersrc/controller/DatasetDetailController.cppopenDataset:21 / loadTab:37 / loadTabPaged:41 / loadTabImpl:46。只依赖 IAsyncDatasetRepository + ChartStrategyRegistry不认识任何具体类型
  3. 策略层 —— src/app/panels/chart/*Strategy.hpp(每个 ~15 行)。新增类型主要在这里加一个文件
  4. 数据层 —— src/data/api/ApiDatasetRepository.cpp
    • 分发 loadAsync:160if (loaderKey == "...") return makeXxx(dsId);,未知 key 抛 runtime_error→ 控制器兜成 loadFailed)。
    • 加载函数 makeXxx:175-247new ApiDetailLoad(xxxBatch(api_, dsId), [](r){ return QVariant::fromValue(payload); })
    • 批次 xxxBatch:52-154(匿名命名空间):new net::ApiBatch({api.getAsync(端点), api.postJsonAsync(端点, body)...}, &isFailure)——唯一端点定义处
    • 解析器 parseXxxParts(本文件匿名 nsdto::parseXxxsrc/data/dto/*Dto.cppQList<ApiResponse> → payload。
  5. 视图层 —— 工厂 src/app/panels/chart/DetailViewFactory.cpp:15ViewKindIDetailView,并注入 cmdRepo/colorTplRepo/viewState/getter各视图 RawDataChartView/GridDataChartView/DataTableView/BarChartView/LineChartView/TrajectoryMapView
  6. 壳层 —— DatasetDetailPage(单 ds 多页签:build:33setTabPayload:92、lazy 遮罩、分页冒泡);DatasetDetailPanelQTabWidget每 ds 一页,路由控制器信号:onDatasetOpened:38 / onTabReady:61)。
  7. 接线 —— main.cpp:面板创建+注入 1390-1395;控制器↔面板信号 1513-1531;策略注册+控制器 2378-2384

5. 扩展配方:新增一种 ds 类型的详情页

前置:先判断走 5A 还是 5B

  • 你的页签能否复用现有 ViewKind(散点 / 等值面 / 表格 / 柱状 / 折线 / 地图)?
    • → 走 5A(最常见,多数"又一种表格/散点"属于此)。
    • 不能(要全新的图) → 走 5B5A + 新视图)。

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/GridDtoparseFooTable(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:22switch 里加 case controller::ViewKind::Foo: return std::unique_ptr<IDetailView>(new FooChartView(parent));(需注入仓储就照 Scatter/Table 分支注入)。

⑤ 注册 .cpp —— src/app/CMakeLists.txtFooChartView.cpp

然后照 5A 的 ①②③④ 接策略/loaderKey/加载/解析。


6. 关键约定与坑(务必知道)

  • 优雅降级ddCode 未注册策略 → 控制器 emit loadFailed "暂不支持该数据类型的预览"DatasetDetailController.cpp:26)。所以"详情打不开"先查策略是否注册
  • 未知 loaderKey → 仓储 loadAsyncstd::runtime_error,控制器 loadTabImpl 就地兜成 loadFailed:59-63不会让遮罩永久悬挂。策略 loaderKey 必须与仓储分发表字符串完全一致
  • payload 类型擦除:忘了 Q_DECLARE_METATYPEQVariant::fromValue/.value<T>() 不匹配 → 视图拿到空 payload、静默不渲染。
  • lazy:数据慢的页签设 lazy=true——开页加载,首次点开才发 tabNeededDatasetDetailPage.cpp:80),其间盖 LoadingOverlay
  • paginated:服务端分页只支持 DataTableViewPage::build:61qobject_cast<DataTableView*>pageRequestedmakeXxxRows 要透传 pageNo/pageSize(见 makeGridRows:238)。
  • 注入链:视图所需仓储/gettercmdRepo/colorTplRepo/projectIdGetter/tmObjectId/viewState)由工厂在造视图时注入(DetailViewFactory.cpp:15-65),而面板/页必须在 build() 之前完成注入(DatasetDetailPanel.cpp:45-48)。新视图若要新仓储,沿 makeDetailView 形参 → Page::buildPanel::onDatasetOpenedmain.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
永远不用动 DatasetDetailControllerDatasetDetailPanel/Page、列表 QTreeWidgetIDetailViewDetailLoad

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 的写入点)。