# VTK 三维分类视图重构 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 把 VTK 视图左侧从「三维数据集/二维数据集/三维分析」三 tab 重构为「按数据类型大类分组」的两 tab 视图,并改造对象树联动、装置类型筛选、VTK 画布工具条、请求体 DTO 组装。 **Architecture:** 自底向上分层:先建纯逻辑/服务层(DsRow 扩展、分类映射表 splitByCategory、请求体 DTO、装置字典服务、对象树三态状态机),全部 GoogleTest 单测;再建 UI 层(CategorySection 段组件、QScrollArea 容器、VtkViewToolbar 工具条),靠 cmake build + 手动验证;最后在 main.cpp 总成接线。沿用现有信号槽 + 仓储抽象 + DatasetListPanel 复用,退役 Column3DDataset/Column3DAnalysis。 **Tech Stack:** C++17 / Qt6(Widgets)/ VTK / CMake + CTest + GoogleTest。 设计依据:`docs/superpowers/specs/2026-06-24-vtk-category-view-refactor-design.md`(spec 各节在任务中以「spec §N」引用)。 ## Global Constraints - C++17;类型 PascalCase、方法 camelCase、成员 `snake_case_` 尾下划线(随现有 `src/` 风格)。 - 不可变优先;UI 组件单一职责、文件 <800 行。 - 所有新增逻辑/服务必须有 GoogleTest 单测(`tests/` 下,CMake 注册);UI 组件以「cmake build 通过 + 现有 ctest 全绿 + 手动验证清单」为验收。 - 后端基址 `http://tenant.geomative.cn/pop-api`;响应信封 `{code,msg,data}`,`code==200` 成功,列表在 `data.value`/`data.list`。 - 大类分类键:电阻率=`ERT platform inversion data`、视电阻率=`visual resistivity data`、瞬变电磁=`DD TRANSIENT ELECTROMAGNETIC INVERSION`(dsTypeCode,三者 ddCode 同为 `dd_inversion_data`);三维体=`dd_voxel`、切片=`dd_slice`(ddCode)。 - 层级 `structParentConfType`:1=GS/项目根,2=TM。 - 装置类型只电阻率/视电阻率段有,瞬变电磁/三维体/切片无。 - 三维体/切片/异常仍走 `Api3dRepository` mock(不切真实后端)。 - 提交信息用 conventional commits(feat/refactor/test/docs);署名全局已禁用,勿加。 - 构建/测试命令(Windows):配置 `cmake --preset ` 后,`cmake --build build` 构建、`ctest --test-dir build --output-on-failure -R ` 跑指定测试。若仓库用 `build.bat`,按其 rebuild。先确认 `tests/` 现有用例如何注册(参考 `tests/data/test_nav_dto.cpp` 与对应 `CMakeLists.txt`)。 --- ## 文件结构(决策锁定) **新建:** - `src/data/repo/CategoryConfig.hpp` — 大类映射表 + 段元数据(纯数据/纯函数)。 - `src/app/DatasetCategory.hpp` / `.cpp` — `splitByCategory`(替代 `DatasetDimension` 的 `splitByDimension`)。 - `src/data/dto/Vtk3dRequests.hpp` / `.cpp` — `VoxelGenerateRequest` / `SliceGenerateRequest` + `toJson`。 - `src/data/repo/DatasetFieldDictionary.hpp` / `.cpp` — 按 dsType 缓存 `dynamicForm` 的 confFieldId↔fieldCode 映射 + 装置类型 value→中文字典。 - `src/app/panels/columns/CategorySection.hpp` / `.cpp` — 单个类型段(段头筛选/操作 + 段体树)。 - `src/app/panels/columns/CategoryAnalysisTab.hpp` / `.cpp` — 「三维分析」tab 容器(QScrollArea 堆叠 5 段)。 - `src/app/VtkViewToolbar.hpp` / `.cpp` — VTK 画布竖排工具条。 - `src/app/AxesSettingsDialog.hpp` / `.cpp` — 坐标轴设置对话框。 - 测试:`tests/data/test_dataset_category.cpp`、`tests/data/test_vtk3d_requests.cpp`、`tests/data/test_dataset_field_dictionary.cpp`;扩 `tests/data/test_nav_dto.cpp`。 **修改:** - `src/data/repo/RepoTypes.hpp` — `DsRow` 加 `dsTypeCode` + `properties`(原始 KV)。 - `src/data/dto/NavDto.cpp` — `parseDsRows` 解析新字段。 - `src/app/panels/ObjectTreePanel.{hpp,cpp}` — GS 三态状态机(停 AutoTristate)+ 右键 ds/tm + `checkedSourcesChanged` 信号。 - `src/data/repo/RepoTypes.hpp` 或新文件 — `DataSource{id,confType}` 类型。 - `src/app/panels/columns/ColumnDrawer.{hpp,cpp}` — 三 tab → 两 tab。 - `src/data/api/Api3dRepository.{hpp,cpp}` — `createVolume/createSlice` 扩参 + DTO 组装。 - `src/app/main.cpp` — 数据流接线(分流拉取/分类分发/勾选并集/生成入口归属)。 - 相关 `CMakeLists.txt`(app 与 tests)。 **退役(功能迁出后删除引用):** `Column3DDataset`、`Column3DAnalysis`(拆分到 `CategorySection` / `VtkViewToolbar` / `AxesSettingsDialog` / 三维体段)。 **依赖顺序:** Phase 1(数据模型+分类)→ Phase 2(DTO)→ Phase 3(对象树)→ Phase 4(字典服务)→ Phase 5(段+容器)→ Phase 6(工具条)→ Phase 7(Api3d 扩参+段重组)→ Phase 8(main 接线总成)。 --- ## Phase 1 — 数据模型与分类层 ### Task 1: DsRow 扩展 + parseDsRows 解析 **Files:** - Modify: `src/data/repo/RepoTypes.hpp`(`DsRow` 结构) - Modify: `src/data/dto/NavDto.cpp:116-137`(`parseDsRows`) - Test: `tests/data/test_nav_dto.cpp`(新增用例) **Interfaces:** - Produces: - `struct DsPropKV { std::string confFieldId, value; };` - `DsRow` 新成员:`std::string dsTypeCode;`、`std::vector properties;` - `parseDsRows` 填充上述字段(其余字段不变)。 - [ ] **Step 1: 写失败测试** — 在 `tests/data/test_nav_dto.cpp` 增用例(喂带 `dsTypeCode` + `properties` 数组的 ds 行 JSON): ```cpp TEST(NavDtoTest, ParseDsRowsExtractsTypeCodeAndProperties) { const QString json = R"({"list":[{ "id":"d1","dsName":"ERT1-WS","name":"电阻率数据", "ddCode":"dd_inversion_data","dsTypeCode":"ERT platform inversion data", "createTime":"2026-03-25 16:48:57","structParentId":"tm1","structParentConfType":2, "properties":[ {"confFieldId":"1450495001706500","value":"1429468249448449"}, {"confFieldId":"1455083478786048","value":"2026-03-25 16:48:57"} ] }]})"; const QJsonObject data = QJsonDocument::fromJson(json.toUtf8()).object(); const auto page = geopro::data::dto::parseDsPage(data); ASSERT_EQ(page.rows.size(), 1u); const auto& r = page.rows[0]; EXPECT_EQ(r.dsTypeCode, "ERT platform inversion data"); EXPECT_EQ(r.structParentId, "tm1"); EXPECT_EQ(r.structParentConfType, 2); EXPECT_EQ(r.ddCode, "dd_inversion_data"); ASSERT_EQ(r.properties.size(), 2u); EXPECT_EQ(r.properties[0].confFieldId, "1450495001706500"); EXPECT_EQ(r.properties[0].value, "1429468249448449"); } ``` - [ ] **Step 2: 跑测试确认失败** Run: `ctest --test-dir build --output-on-failure -R NavDtoTest` Expected: 编译失败(`dsTypeCode`/`properties` 成员不存在)。 - [ ] **Step 3: 扩 DsRow** — `src/data/repo/RepoTypes.hpp` 在 `DsRow` 上方加 KV 结构、`DsRow` 内加两成员: ```cpp // ds 属性键值(data/page 的 properties[] 项:confFieldId→value 原始对)。 struct DsPropKV { std::string confFieldId, value; }; struct DsRow { std::string id, dsName, typeName, ddCode, createTime; std::string parentId; std::string fileName, fileUrl; long long fileSize = 0; std::string dsTypeCode; // 大类分类主键(spec §5;ddCode 粒度不足以区分电阻率/视电阻率) std::vector properties; // 原始 confFieldId→value;装置类型/采集时间经 DatasetFieldDictionary 解析 std::string structParentId; // 上级节点 id(段体容器分组 + 生成三维体归属用) int structParentConfType = 0; // 1=GS/项目根 2=TM }; ``` - [ ] **Step 4: 扩 parseDsRows** — `src/data/dto/NavDto.cpp` 在 `d.ddCode = str(o, "ddCode");` 后加: ```cpp d.dsTypeCode = str(o, "dsTypeCode"); d.structParentId = str(o, "structParentId"); d.structParentConfType = o.value(QStringLiteral("structParentConfType")).toInt(); const QJsonArray props = o.value(QStringLiteral("properties")).toArray(); d.properties.reserve(static_cast(props.size())); for (const QJsonValue& pv : props) { const QJsonObject po = pv.toObject(); d.properties.push_back( {str(po, "confFieldId"), po.value(QStringLiteral("value")).toVariant().toString().toStdString()}); } ``` > 注:`value` 用 `toVariant().toString()` 兼容字符串/数值/时间(与 `parseDynamicForm:208` 同口径)。`properties` 也可能是对象(接地电阻/文件型 ds,§3.2 实测),此处 `.toArray()` 对非数组安全返回空——分类不依赖这些类型的 properties,可接受。 - [ ] **Step 5: 跑测试确认通过** Run: `ctest --test-dir build --output-on-failure -R NavDtoTest` Expected: PASS(全部 NavDtoTest 用例)。 - [ ] **Step 6: 提交** ```bash git add src/data/repo/RepoTypes.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp git commit -m "feat(data): DsRow 加 dsTypeCode/properties + parseDsRows 解析" ``` --- ### Task 2: CategoryConfig 映射表 + splitByCategory **Files:** - Create: `src/data/repo/CategoryConfig.hpp` - Create: `src/app/DatasetCategory.hpp` / `src/app/DatasetCategory.cpp` - Test: `tests/data/test_dataset_category.cpp` - Modify: `src/app/CMakeLists.txt`(加 DatasetCategory.cpp)、`tests/.../CMakeLists.txt`(注册测试) **Interfaces:** - Consumes: `DsRow`(Task 1)。 - Produces: - `struct CategorySpec { std::string id, title, dsTypeCode, ddCode; bool canGenerateVolume; bool hasArrayTypeFilter; };` - `const std::vector& categoryConfigs();`(5 段有序) - `struct CategoryBuckets { std::vector> segments; };`(与 configs 同序、同长) - `CategoryBuckets splitByCategory(const std::vector& rows);` - [ ] **Step 1: 写失败测试** — `tests/data/test_dataset_category.cpp`: ```cpp #include #include "DatasetCategory.hpp" using geopro::data::DsRow; using namespace geopro::app; static DsRow row(const std::string& id, const std::string& ddCode, const std::string& dsTypeCode) { DsRow r; r.id = id; r.ddCode = ddCode; r.dsTypeCode = dsTypeCode; return r; } TEST(SplitByCategory, RoutesByDsTypeCodeAndDdCode) { std::vector rows = { row("a", "dd_inversion_data", "ERT platform inversion data"), // 电阻率 row("b", "dd_inversion_data", "visual resistivity data"), // 视电阻率 row("c", "dd_inversion_data", "DD TRANSIENT ELECTROMAGNETIC INVERSION"), // 瞬变 row("v", "dd_voxel", ""), // 三维体(按 ddCode) row("s", "dd_slice", ""), // 切片(按 ddCode) row("x", "dd_ert_measurement_gr_data", "earth resistance"), // 接地电阻 → 丢弃 }; const CategoryBuckets b = splitByCategory(rows); ASSERT_EQ(b.segments.size(), categoryConfigs().size()); EXPECT_EQ(b.segments[0].size(), 1u); EXPECT_EQ(b.segments[0][0].id, "a"); EXPECT_EQ(b.segments[1].size(), 1u); EXPECT_EQ(b.segments[1][0].id, "b"); EXPECT_EQ(b.segments[2].size(), 1u); EXPECT_EQ(b.segments[2][0].id, "c"); EXPECT_EQ(b.segments[3].size(), 1u); EXPECT_EQ(b.segments[3][0].id, "v"); EXPECT_EQ(b.segments[4].size(), 1u); EXPECT_EQ(b.segments[4][0].id, "s"); // 接地电阻不进任何段 std::size_t total = 0; for (auto& s : b.segments) total += s.size(); EXPECT_EQ(total, 5u); } ``` - [ ] **Step 2: 跑测试确认失败** Run: `ctest --test-dir build --output-on-failure -R SplitByCategory` Expected: 编译失败(头文件/符号缺失)。 - [ ] **Step 3: 写 CategoryConfig.hpp** ```cpp #pragma once #include #include namespace geopro::app { // 一个数据类型大类段的配置(spec §5)。识别键二选一:dsTypeCode 优先;ddCode 用于三维体/切片。 struct CategorySpec { std::string id; // 段稳定 id std::string title; // 段标题(UI 显示) std::string dsTypeCode; // 主识别键(空=不按 dsTypeCode) std::string ddCode; // 次识别键(dd_voxel/dd_slice;空=不按 ddCode) bool canGenerateVolume; // 段内 GS/项目根是否提供「生成三维体」 bool hasArrayTypeFilter; // 段头是否显示装置类型筛选(仅 ERT 类) }; // 5 段固定有序(spec §5 表)。 inline const std::vector& categoryConfigs() { static const std::vector kCfg = { {"resistivity", "电阻率数据", "ERT platform inversion data", "", true, true}, {"apparent", "视电阻率数据", "visual resistivity data", "", true, true}, {"transient", "瞬变电磁数据", "DD TRANSIENT ELECTROMAGNETIC INVERSION", "", true, false}, {"voxel", "三维体", "", "dd_voxel", false, false}, {"slice", "切片", "", "dd_slice", false, false}, }; return kCfg; } } // namespace geopro::app ``` - [ ] **Step 4: 写 DatasetCategory.hpp / .cpp** `DatasetCategory.hpp`: ```cpp #pragma once #include #include "repo/RepoTypes.hpp" #include "CategoryConfig.hpp" namespace geopro::app { struct CategoryBuckets { std::vector> segments; // 与 categoryConfigs() 同序同长 }; // 按 CategoryConfig 把 ds 分入大类段:先判 ddCode 白名单(三维体/切片),否则按 dsTypeCode 匹配; // 不在表内的丢弃(接地电阻/原始数据/白化/坐标等)。保留原顺序。 CategoryBuckets splitByCategory(const std::vector& rows); } // namespace geopro::app ``` `DatasetCategory.cpp`: ```cpp #include "DatasetCategory.hpp" namespace geopro::app { CategoryBuckets splitByCategory(const std::vector& rows) { const auto& cfg = categoryConfigs(); CategoryBuckets b; b.segments.resize(cfg.size()); for (const auto& r : rows) { int hit = -1; // 先按 ddCode(三维体/切片)——它们无 dsTypeCode(来自 Api3dRepository mock 行)。 for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i) if (!cfg[i].ddCode.empty() && r.ddCode == cfg[i].ddCode) hit = static_cast(i); // 再按 dsTypeCode。 for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i) if (!cfg[i].dsTypeCode.empty() && r.dsTypeCode == cfg[i].dsTypeCode) hit = static_cast(i); if (hit >= 0) b.segments[static_cast(hit)].push_back(r); } return b; } } // namespace geopro::app ``` - [ ] **Step 5: 注册到 CMake** — 把 `src/app/DatasetCategory.cpp` 加入 app 目标源;把 `tests/data/test_dataset_category.cpp` 按 `test_dataset_dimension`/`test_nav_dto` 同样式注册(确认现有测试在哪个 CMakeLists、用何宏注册,照抄)。 - [ ] **Step 6: 跑测试确认通过** Run: `cmake --build build && ctest --test-dir build --output-on-failure -R SplitByCategory` Expected: PASS。 - [ ] **Step 7: 提交** ```bash git add src/data/repo/CategoryConfig.hpp src/app/DatasetCategory.hpp src/app/DatasetCategory.cpp tests/data/test_dataset_category.cpp src/app/CMakeLists.txt tests/data/CMakeLists.txt git commit -m "feat(app): CategoryConfig 映射表 + splitByCategory 按 dsTypeCode 分大类" ``` --- ## Phase 2 — 请求体 DTO ### Task 3: VoxelGenerateRequest / SliceGenerateRequest + toJson **Files:** - Create: `src/data/dto/Vtk3dRequests.hpp` / `.cpp` - Test: `tests/data/test_vtk3d_requests.cpp` - Modify: `src/data/CMakeLists.txt`(或 app)、tests CMake **Interfaces:** - Produces(对齐 `docs/api/vtk-3d-openapi.json` schema): - `struct VoxelGenerateRequest { std::string projectId, structParentId; int structParentConfType=1; std::string name; std::vector sourceDatasetIds; std::string interpModel="Idw"; double cellXY=1.0,cellZ=0.5,power=2.0,maxDist=4.0; std::string colorScaleId; QJsonObject toJson() const; };` - `struct SliceGenerateRequest { std::string projectId, volumeDsId, name; int axis=3; std::array origin{},point1{},point2{}; std::string colorScaleId; QJsonObject toJson() const; };` - [ ] **Step 1: 写失败测试** — `tests/data/test_vtk3d_requests.cpp`: ```cpp #include #include #include "dto/Vtk3dRequests.hpp" using namespace geopro::data; TEST(Vtk3dRequests, VoxelToJsonMatchesContract) { VoxelGenerateRequest q; q.projectId = "p1"; q.structParentId = "g1"; q.structParentConfType = 1; q.name = "体A"; q.sourceDatasetIds = {"d1", "d2"}; const QJsonObject j = q.toJson(); EXPECT_EQ(j["projectId"].toString(), "p1"); EXPECT_EQ(j["structParentId"].toString(), "g1"); EXPECT_EQ(j["structParentConfType"].toInt(), 1); EXPECT_EQ(j["name"].toString(), "体A"); ASSERT_TRUE(j["sourceDatasetIds"].isArray()); EXPECT_EQ(j["sourceDatasetIds"].toArray().size(), 2); EXPECT_EQ(j["interpModel"].toString(), "Idw"); EXPECT_DOUBLE_EQ(j["cellXY"].toDouble(), 1.0); } TEST(Vtk3dRequests, SliceToJsonMatchesContract) { SliceGenerateRequest q; q.projectId = "p1"; q.volumeDsId = "v1"; q.name = "切片1"; q.axis = 3; q.origin = {0, 0, -10}; q.point1 = {100, 0, -10}; q.point2 = {0, 50, -10}; const QJsonObject j = q.toJson(); EXPECT_EQ(j["volumeDsId"].toString(), "v1"); EXPECT_EQ(j["axis"].toInt(), 3); ASSERT_TRUE(j["origin"].isArray()); EXPECT_EQ(j["origin"].toArray().size(), 3); EXPECT_DOUBLE_EQ(j["point1"].toArray()[0].toDouble(), 100.0); } ``` - [ ] **Step 2: 跑测试确认失败** Run: `ctest --test-dir build --output-on-failure -R Vtk3dRequests` Expected: 编译失败(头缺失)。 - [ ] **Step 3: 写 Vtk3dRequests.hpp** ```cpp #pragma once #include #include #include #include namespace geopro::data { // 对齐 docs/api/vtk-3d-openapi.json VoxelGenerateRequest(spec §8 请求体组装)。 struct VoxelGenerateRequest { std::string projectId; std::string structParentId; // GS/项目根容器节点 id int structParentConfType = 1; // 1=GS/项目根 std::string name; std::vector sourceDatasetIds; std::string interpModel = "Idw"; // Idw|Kriging double cellXY = 1.0, cellZ = 0.5, power = 2.0, maxDist = 4.0; std::string colorScaleId; // 空=取首源色阶 QJsonObject toJson() const; }; // 对齐 SliceGenerateRequest。 struct SliceGenerateRequest { std::string projectId; std::string volumeDsId; // 所属三维体 dsObjectId std::string name; int axis = 3; // 0上下/1前后/2左右/3任意 std::array origin{{0, 0, 0}}; std::array point1{{0, 0, 0}}; std::array point2{{0, 0, 0}}; std::string colorScaleId; QJsonObject toJson() const; }; } // namespace geopro::data ``` - [ ] **Step 4: 写 Vtk3dRequests.cpp** ```cpp #include "dto/Vtk3dRequests.hpp" #include #include namespace geopro::data { namespace { QJsonArray vec3(const std::array& v) { return QJsonArray{v[0], v[1], v[2]}; } QString qs(const std::string& s) { return QString::fromStdString(s); } } // namespace QJsonObject VoxelGenerateRequest::toJson() const { QJsonArray ids; for (const auto& s : sourceDatasetIds) ids.append(qs(s)); QJsonObject o{ {"projectId", qs(projectId)}, {"structParentId", qs(structParentId)}, {"structParentConfType", structParentConfType}, {"name", qs(name)}, {"sourceDatasetIds", ids}, {"interpModel", qs(interpModel)}, {"cellXY", cellXY}, {"cellZ", cellZ}, {"power", power}, {"maxDist", maxDist}, }; if (!colorScaleId.empty()) o.insert("colorScaleId", qs(colorScaleId)); return o; } QJsonObject SliceGenerateRequest::toJson() const { QJsonObject o{ {"projectId", qs(projectId)}, {"volumeDsId", qs(volumeDsId)}, {"name", qs(name)}, {"axis", axis}, {"origin", vec3(origin)}, {"point1", vec3(point1)}, {"point2", vec3(point2)}, }; if (!colorScaleId.empty()) o.insert("colorScaleId", qs(colorScaleId)); return o; } } // namespace geopro::data ``` - [ ] **Step 5: 注册 CMake + 跑测试** Run: `cmake --build build && ctest --test-dir build --output-on-failure -R Vtk3dRequests` Expected: PASS。 - [ ] **Step 6: 提交** ```bash git add src/data/dto/Vtk3dRequests.hpp src/data/dto/Vtk3dRequests.cpp tests/data/test_vtk3d_requests.cpp src/data/CMakeLists.txt tests/data/CMakeLists.txt git commit -m "feat(data): VoxelGenerateRequest/SliceGenerateRequest DTO + toJson" ``` --- ## Phase 3 — 对象树联动改造 ### Task 4: GS 三态状态机(停 AutoTristate)+ 右键 ds/tm **Files:** - Create: `src/app/panels/ObjectTreeSelection.hpp`(纯逻辑,可单测) - Modify: `src/app/panels/ObjectTreePanel.cpp`(:123 停 AutoTristate、:174-191 itemChanged、:207-242 右键菜单)、`.hpp` - Test: `tests/app/test_object_tree_selection.cpp` - Modify: app/tests CMake **Interfaces:** - Produces: - `enum class GsCheck { Unchecked, Partial, Checked };` - `GsCheck aggregateGsState(bool dsOn, int checkedTmCount, int totalTmCount);` - ObjectTreePanel 新增 UserRole:`kRoleGsDsOn`(GS 自身 ds 开关 bool)。 - [ ] **Step 1: 写失败测试** — `tests/app/test_object_tree_selection.cpp`: ```cpp #include #include "panels/ObjectTreeSelection.hpp" using namespace geopro::app; TEST(AggregateGsState, AllOnIsChecked) { EXPECT_EQ(aggregateGsState(true, 3, 3), GsCheck::Checked); } TEST(AggregateGsState, AllOffIsUnchecked) { EXPECT_EQ(aggregateGsState(false, 0, 3), GsCheck::Unchecked); } TEST(AggregateGsState, DsOnTmNoneIsPartial) { EXPECT_EQ(aggregateGsState(true, 0, 3), GsCheck::Partial); // 只 GS 自身 ds } TEST(AggregateGsState, DsOffSomeTmIsPartial) { EXPECT_EQ(aggregateGsState(false, 1, 3), GsCheck::Partial); // 部分子 TM } TEST(AggregateGsState, NoTmFallsBackToDsOnly) { EXPECT_EQ(aggregateGsState(true, 0, 0), GsCheck::Checked); // 无子 TM → 仅看 ds 开关 EXPECT_EQ(aggregateGsState(false, 0, 0), GsCheck::Unchecked); } ``` - [ ] **Step 2: 跑测试确认失败** Run: `ctest --test-dir build --output-on-failure -R AggregateGsState` Expected: 编译失败。 - [ ] **Step 3: 写 ObjectTreeSelection.hpp** ```cpp #pragma once namespace geopro::app { enum class GsCheck { Unchecked, Partial, Checked }; // GS 复选框三态 = [自身 ds 开关] ∨ [子 TM 勾选] 的聚合(spec §6)。 // 无子 TM(totalTmCount==0)时退化为仅看 dsOn。 inline GsCheck aggregateGsState(bool dsOn, int checkedTmCount, int totalTmCount) { const bool anyOn = dsOn || checkedTmCount > 0; if (!anyOn) return GsCheck::Unchecked; const bool tmAll = (totalTmCount == 0) || (checkedTmCount == totalTmCount); if (dsOn && tmAll) return GsCheck::Checked; return GsCheck::Partial; } } // namespace geopro::app ``` - [ ] **Step 4: 跑测试确认通过** Run: `cmake --build build && ctest --test-dir build --output-on-failure -R AggregateGsState` Expected: PASS。 - [ ] **Step 5: 改 ObjectTreePanel —— 停用 AutoTristate + 手动三态** `ObjectTreePanel.cpp` 顶部常量区加 UserRole: ```cpp constexpr int kRoleGsDsOn = Qt::UserRole + 7; // GS 自身 ds 开关(bool) ``` `addNodes` 里 GS 分支(现 :122-125,`else { ... ItemIsAutoTristate ... }`)改为**不设 AutoTristate**、初始化 ds 开关: ```cpp } else { item->setData(0, kRoleConfType, kConfTypeGs); // GS item->setFlags(item->flags() | Qt::ItemIsUserCheckable); // 去掉 ItemIsAutoTristate item->setData(0, kRoleGsDsOn, false); item->setCheckState(0, Qt::Unchecked); item->setIcon(0, makeGlyph(Glyph::WorkArea, iconColor, iconPx)); } ``` 新增私有方法 `void recomputeGsState(QTreeWidgetItem* gs)`(`.hpp` 声明,`.cpp` 实现): ```cpp void ObjectTreePanel::recomputeGsState(QTreeWidgetItem* gs) { if (!gs || gs->data(0, kRoleConfType).toInt() != kConfTypeGs) return; const bool dsOn = gs->data(0, kRoleGsDsOn).toBool(); int total = 0, checked = 0; for (int i = 0; i < gs->childCount(); ++i) { QTreeWidgetItem* c = gs->child(i); if (c->data(0, kRoleConfType).toInt() != kConfTypeTm) continue; ++total; if (c->checkState(0) == Qt::Checked) ++checked; } const GsCheck s = aggregateGsState(dsOn, checked, total); const QSignalBlocker block(tree_); // 不再触发 itemChanged 递归 gs->setCheckState(0, s == GsCheck::Checked ? Qt::Checked : s == GsCheck::Partial ? Qt::PartiallyChecked : Qt::Unchecked); } ``` 在现有 `itemChanged` 合并回调(:174-191)内,遍历收集后**对每个 GS 调 `recomputeGsState`**,再发 `checkedSourcesChanged`(见 Task 5)。点 GS 复选框的「任一开→全关 / 全关→全开」在 `itemClicked`(或复选框命中分支)里处理:读当前 `aggregateGsState`,若非 Unchecked → 置 dsOn=false + 子 TM 全 Unchecked;若 Unchecked → dsOn=true + 子 TM 全 Checked,然后 `recomputeGsState`。 - [ ] **Step 6: 右键 ds/tm 菜单** — 在右键菜单 GS 分支(现 :230-234 `if (isGs)`)加: ```cpp if (isGs) { QMenu* sel = menu.addMenu(QStringLiteral("选择")); const bool dsOn = item->data(0, kRoleGsDsOn).toBool(); bool hasOwnDs = /* 该 GS 是否有直挂 ds:由上层 setStructure 时标记,或暂以 true */ true; int tmCount = 0; for (int i = 0; i < item->childCount(); ++i) if (item->child(i)->data(0, kRoleConfType).toInt() == kConfTypeTm) ++tmCount; QAction* dsAct = sel->addAction(QStringLiteral("ds")); dsAct->setCheckable(true); dsAct->setChecked(dsOn); dsAct->setEnabled(hasOwnDs); QObject::connect(dsAct, &QAction::triggered, this, [this, item](bool on) { item->setData(0, kRoleGsDsOn, on); recomputeGsState(item); emitCheckedSources(); }); QAction* tmAct = sel->addAction(QStringLiteral("tm")); tmAct->setCheckable(true); tmAct->setChecked(tmCount > 0 && allTmChecked(item)); // allTmChecked: 私有辅助 tmAct->setEnabled(tmCount > 0); QObject::connect(tmAct, &QAction::triggered, this, [this, item](bool on) { setAllChildTmChecked(item, on); recomputeGsState(item); emitCheckedSources(); }); // 保留原「新建检测对象 / 新建方法对象」 add(QStringLiteral("新建检测对象"), QStringLiteral("newGs")); add(QStringLiteral("新建方法对象"), QStringLiteral("newTm")); } ``` (`allTmChecked` / `setAllChildTmChecked` / `emitCheckedSources` 为私有辅助,签名在 `.hpp` 声明;`emitCheckedSources` 见 Task 5。`hasOwnDs` 的真值来源:setStructure 时若 StructNode 标记了直挂 ds 则置 true,无标记暂保守 true——不影响正确性,仅菜单项是否灰显。) - [ ] **Step 7: build + 手动验证** Run: `cmake --build build` 手动清单(启动 app,进 VTK 视图,选有 GS 层级的项目): 1. 勾 GS 复选框 → 变全黑、其下 TM 全勾。 2. 再点 GS → 变空、TM 全不勾。 3. 右键 GS→选择▸ds 打勾 → GS 变灰(仅自身 ds)。 4. 右键 GS→选择▸tm 打勾 → 子 TM 全勾、GS 若 ds 也开则变黑、否则灰。 5. 单独勾一个子 TM → GS 变灰、tm 菜单对号消失。 6. 无子 TM 的 GS:ds 开/关 → GS 黑/空(不出现灰)。 - [ ] **Step 8: 提交** ```bash git add src/app/panels/ObjectTreeSelection.hpp src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp tests/app/test_object_tree_selection.cpp src/app/CMakeLists.txt tests/app/CMakeLists.txt git commit -m "feat(tree): GS 三态状态机(停 AutoTristate)+右键 ds/tm 选择" ``` --- ### Task 5: DataSource 去重 + checkedSourcesChanged 信号 **Files:** - Modify: `src/data/repo/RepoTypes.hpp`(`DataSource`) - Create: `src/app/panels/ObjectTreeSelection.hpp`(追加 `dedupeSources`) - Modify: `src/app/panels/ObjectTreePanel.{hpp,cpp}`(`emitCheckedSources` + 信号) - Test: `tests/app/test_object_tree_selection.cpp`(追加) **Interfaces:** - Produces: - `struct DataSource { std::string id; int confType; };`(confType: 1=GS/项目, 2=TM) - `std::vector dedupeSources(std::vector in);`(按 {id,confType} 去重保序) - `ObjectTreePanel` 信号 `void checkedSourcesChanged(const QList& sources);` - 私有 `void emitCheckedSources();` - [ ] **Step 1: 写失败测试** — 追加到 `test_object_tree_selection.cpp`: ```cpp #include "repo/RepoTypes.hpp" using geopro::data::DataSource; TEST(DedupeSources, RemovesDuplicateByIdAndConfType) { std::vector in = {{"t1",2},{"g1",1},{"t1",2},{"g1",1},{"t2",2}}; const auto out = geopro::app::dedupeSources(in); ASSERT_EQ(out.size(), 3u); EXPECT_EQ(out[0].id, "t1"); EXPECT_EQ(out[0].confType, 2); EXPECT_EQ(out[1].id, "g1"); EXPECT_EQ(out[1].confType, 1); EXPECT_EQ(out[2].id, "t2"); } TEST(DedupeSources, SameIdDifferentConfTypeKept) { std::vector in = {{"x",1},{"x",2}}; EXPECT_EQ(geopro::app::dedupeSources(in).size(), 2u); } ``` - [ ] **Step 2: 跑确认失败** — `ctest -R DedupeSources` → 编译失败。 - [ ] **Step 3: 加 DataSource** — `RepoTypes.hpp`: ```cpp // 对象树勾选产出的数据源(spec §6)。confType: 1=GS/项目根, 2=TM。 struct DataSource { std::string id; int confType = 0; }; ``` (若放 `QList` 过信号,需 `Q_DECLARE_METATYPE(geopro::data::DataSource)` + 注册,照 `DsPage` 现有 metatype 注册方式。) - [ ] **Step 4: 加 dedupeSources** — `ObjectTreeSelection.hpp` 追加: ```cpp #include #include "repo/RepoTypes.hpp" namespace geopro::app { inline std::vector dedupeSources(std::vector in) { std::vector out; for (const auto& s : in) { bool dup = false; for (const auto& o : out) if (o.id == s.id && o.confType == s.confType) { dup = true; break; } if (!dup) out.push_back(s); } return out; } } // namespace geopro::app ``` - [ ] **Step 5: 跑确认通过** — `cmake --build build && ctest -R DedupeSources` → PASS。 - [ ] **Step 6: emitCheckedSources** — `ObjectTreePanel.cpp` 实现(替代旧 `checkedTmsChanged` 收集): ```cpp void ObjectTreePanel::emitCheckedSources() { std::vector src; std::function walk = [&](QTreeWidgetItem* node) { for (int i = 0; i < node->childCount(); ++i) { QTreeWidgetItem* c = node->child(i); const int ct = c->data(0, kRoleConfType).toInt(); if (ct == kConfTypeTm && c->checkState(0) == Qt::Checked) src.push_back({c->data(0, kRoleObjId).toString().toStdString(), 2}); if (ct == kConfTypeGs && c->data(0, kRoleGsDsOn).toBool()) src.push_back({c->data(0, kRoleObjId).toString().toStdString(), 1}); walk(c); } }; walk(tree_->invisibleRootItem()); // 项目根直挂 ds 固定加入(根节点 id,confType=1)。 if (tree_->topLevelItemCount() > 0) { QTreeWidgetItem* root = tree_->topLevelItem(0); if (root->data(0, kRoleIsRoot).toBool()) src.push_back({root->data(0, kRoleObjId).toString().toStdString(), 1}); } const auto deduped = geopro::app::dedupeSources(std::move(src)); QList list; for (const auto& s : deduped) list.push_back(s); emit checkedSourcesChanged(list); } ``` 把现有 `itemChanged` 0ms 合并回调(:177-190)末尾的 `emit checkedTmsChanged(...)` 改为 `emitCheckedSources()`,并对涉及 GS 先 `recomputeGsState`。`setAllTmsChecked`/`invertTmChecks` 末尾同样改调 `emitCheckedSources`。`checkedTmsChanged` 信号删除(无其它消费者后;main.cpp 接线在 Task 12 改)。 - [ ] **Step 7: build + 提交** Run: `cmake --build build`(此时 main.cpp 仍连旧信号会编译错——可在 Task 12 一起绿;若分阶段,本 task 暂保留 `checkedTmsChanged` 与新信号并存,Task 12 删旧)。 > 决策:为保持每 task 可编译,**本 task 新增 `checkedSourcesChanged` 并存、不删 `checkedTmsChanged`**;Task 12 接线切换后再删旧信号。 ```bash git add src/data/repo/RepoTypes.hpp src/app/panels/ObjectTreeSelection.hpp src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp tests/app/test_object_tree_selection.cpp git commit -m "feat(tree): checkedSourcesChanged 带 confType 源集合(去重并集)" ``` --- ## Phase 4 — 装置类型 / 采集时间字典服务 ### Task 6: DatasetFieldDictionary **Files:** - Create: `src/data/repo/DatasetFieldDictionary.hpp` / `.cpp` - Test: `tests/data/test_dataset_field_dictionary.cpp` - Modify: data/tests CMake **Interfaces:** - Produces: - `struct DsTypeFields { std::string arrayTypeConfFieldId, collectTimeConfFieldId; std::map arrayTypeLabels; };` - `DsTypeFields parseFieldMapping(const QJsonObject& dynamicFormData);`(纯函数,解析 formList) - class `DatasetFieldDictionary`:`void ensureFor(dsTypeCode, sampleDsId, cb)`(异步拉 dynamicForm 缓存)、`const DsTypeFields* fields(dsTypeCode) const`、`std::string arrayValueOf(const DsRow&) const`(从 properties 取 arrayType 值)、`std::string arrayLabel(dsTypeCode, value) const`(value→中文,缺失回退原值)。 - [ ] **Step 1: 写失败测试** — `tests/data/test_dataset_field_dictionary.cpp`(喂真实 dynamicForm 结构): ```cpp #include #include #include "repo/DatasetFieldDictionary.hpp" using namespace geopro::data; TEST(ParseFieldMapping, ExtractsArrayTypeAndCollectTimeConfFieldIds) { const QString js = R"({"formList":[{"groupName":"基本信息","values":[ {"confFieldId":"f_ct","fieldCode":"collectTime","fieldName":"采集时间","optionsObject":null}, {"confFieldId":"f_at","fieldCode":"arrayType","fieldName":"装置类型","optionsObject":[ {"label":"温纳-施伦贝尔排列","value":"v1"},{"label":"全梯度","value":"v2"}]} ]}]})"; const QJsonObject data = QJsonDocument::fromJson(js.toUtf8()).object(); const DsTypeFields f = parseFieldMapping(data); EXPECT_EQ(f.arrayTypeConfFieldId, "f_at"); EXPECT_EQ(f.collectTimeConfFieldId, "f_ct"); ASSERT_EQ(f.arrayTypeLabels.count("v1"), 1u); EXPECT_EQ(f.arrayTypeLabels.at("v1"), "温纳-施伦贝尔排列"); } ``` - [ ] **Step 2: 跑确认失败** — `ctest -R ParseFieldMapping` → 编译失败。 - [ ] **Step 3: 写 DatasetFieldDictionary.hpp** ```cpp #pragma once #include #include #include #include #include "repo/RepoTypes.hpp" namespace geopro::data { // 某 dsType 的字段映射(spec §10)。 struct DsTypeFields { std::string arrayTypeConfFieldId; // ds 行 properties 里装置类型项的 confFieldId std::string collectTimeConfFieldId; // 采集时间项的 confFieldId std::map arrayTypeLabels; // value→中文(来自 optionsObject) }; // 纯函数:从 dsObject/dynamicForm 的 data 解析字段映射(formList → fieldCode==arrayType/collectTime)。 DsTypeFields parseFieldMapping(const QJsonObject& dynamicFormData); // ds 行的装置类型原始值:properties 中 confFieldId==arrayTypeConfFieldId 的 value(缺=空)。 std::string arrayValueOf(const DsRow& row, const DsTypeFields& f); // ds 行的采集时间:properties 中 confFieldId==collectTimeConfFieldId 的 value(缺=空)。 std::string collectTimeOf(const DsRow& row, const DsTypeFields& f); } // namespace geopro::data ``` - [ ] **Step 4: 写 DatasetFieldDictionary.cpp**(纯解析部分) ```cpp #include "repo/DatasetFieldDictionary.hpp" #include namespace geopro::data { DsTypeFields parseFieldMapping(const QJsonObject& d) { DsTypeFields f; for (const QJsonValue& gv : d.value(QStringLiteral("formList")).toArray()) { for (const QJsonValue& vv : gv.toObject().value(QStringLiteral("values")).toArray()) { const QJsonObject fo = vv.toObject(); const QString code = fo.value(QStringLiteral("fieldCode")).toString(); const std::string cfid = fo.value(QStringLiteral("confFieldId")).toString().toStdString(); if (code == QStringLiteral("arrayType")) { f.arrayTypeConfFieldId = cfid; for (const QJsonValue& ov : fo.value(QStringLiteral("optionsObject")).toArray()) { const QJsonObject oo = ov.toObject(); f.arrayTypeLabels[oo.value(QStringLiteral("value")).toString().toStdString()] = oo.value(QStringLiteral("label")).toString().toStdString(); } } else if (code == QStringLiteral("collectTime")) { f.collectTimeConfFieldId = cfid; } } } return f; } static std::string propValue(const DsRow& row, const std::string& cfid) { if (cfid.empty()) return {}; for (const auto& kv : row.properties) if (kv.confFieldId == cfid) return kv.value; return {}; } std::string arrayValueOf(const DsRow& row, const DsTypeFields& f) { return propValue(row, f.arrayTypeConfFieldId); } std::string collectTimeOf(const DsRow& row, const DsTypeFields& f) { return propValue(row, f.collectTimeConfFieldId); } } // namespace geopro::data ``` > 装置 value→中文:本 task 用 `optionsObject` 建 `arrayTypeLabels`。**已知风险(spec §11)**:实测某些 ds 的 arrayType 原始值不在 optionsObject 里——此时 `arrayLabel` 回退显示原值,待坐实正确字典源后只换 `arrayTypeLabels` 的数据来源、接口不变。 - [ ] **Step 5: 跑确认通过** — `cmake --build build && ctest -R ParseFieldMapping` → PASS。 - [ ] **Step 6: 异步缓存壳**(无独立单测,随 Task 7 集成)— 在同文件加按 dsTypeCode 缓存的 `DatasetFieldDictionary` 类:构造收 `IAsyncProjectRepository&`(或现有 `loadDatasetFormAsync` 提供者);`ensureFor(dsTypeCode, sampleDsId, cb)` 若未缓存则 `loadDatasetFormAsync(sampleDsId)` → `parseFieldMapping` → 存 `std::map` → cb。`fields(dsTypeCode)` 返回缓存指针或 nullptr。 - [ ] **Step 7: 提交** ```bash git add src/data/repo/DatasetFieldDictionary.hpp src/data/repo/DatasetFieldDictionary.cpp tests/data/test_dataset_field_dictionary.cpp src/data/CMakeLists.txt tests/data/CMakeLists.txt git commit -m "feat(data): DatasetFieldDictionary 解析 arrayType/collectTime 映射+装置字典" ``` --- ## Phase 5 — 类型段组件与容器 ### Task 7: CategorySection(段头筛选 + 段体树 + 生成入口) **Files:** - Create: `src/app/panels/columns/CategorySection.hpp` / `.cpp` - Modify: app CMake **Interfaces:** - Consumes: `CategorySpec`(Task 2)、`splitByCategory` 桶里的 `std::vector`、`DatasetListPanel::populateDatasetList`/`applyDatasetFilter`、`DatasetFieldDictionary`(Task 6)。 - Produces: ```cpp class CategorySection : public QWidget { Q_OBJECT public: CategorySection(const geopro::app::CategorySpec& spec, geopro::data::DatasetFieldDictionary* dict, QWidget* parent=nullptr); void setDatasets(const std::vector& rows); signals: void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染 void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); // 段头「+新增三维体」→带勾选源 void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); }; ``` **实现要点(`.cpp`,参照现有 `Column3DDataset.cpp` 的列表+右键 pattern):** - 段头:标题 `QLabel`(`spec.title`)+ 折叠箭头;`spec.hasArrayTypeFilter` 为 true 才加装置类型 `QComboBox`;日期范围两个 `QDateEdit`。 - 段体:`QTreeWidget` + `applyDatasetCardDelegate`;`setDatasets` 调 `populateDatasetList(tree_, rows, false)` + 让数据行可勾选(同现 Column3DDataset.cpp:170-185)。 - 勾选 → `checkedDatasetsChanged`(同 Column3DDataset.cpp:128-135)。 - 筛选:日期/装置类型变化 → 调 `applyDatasetFilter`(日期比较改 collectTime——经 dict 取值;装置类型按 `arrayValueOf` 过滤)。装置类型下拉项 = 当前数据经 `dict->arrayLabel` 去重集合。 - 生成入口(仅 `spec.canGenerateVolume`):**段头**「+新增三维体」按钮;点击 → 收集本段当前勾选的源 ds → `emit generateVolumeRequested(spec.dsTypeCode, checkedSourceDsIds)`(归属「生成位置」与插值参数都在 main 的 `VolumeParamsDialog` 里选,见 Task 12)。 - **段体树(核心实现)**:段体呈现「项目根 / GS / TM 容器节点 → ds 行」层级(spec §4 图)。`CategorySection` 加 `void setStructure(const std::vector& nodes)`(对象树同源的扁平 GS/TM 节点;main.cpp 传入、Task 8 经 CategoryAnalysisTab 转发、Task 12 接线)。`setDatasets` 据 structure 先建容器节点(项目根/GS;TM 作为 GS 子节点),再把每个 ds 按其 `structParentId`/`structParentConfType`(Task 1 已解析)挂到对应容器下;容器内 ds 间再按 `ds.parentId` 派生建树(复用 `populateDatasetList` 逻辑)。(生成三维体不在段体右键——改段头按钮,见上「生成入口」;归属由对话框「生成位置」选,与段体容器节点无关。) - [ ] **Step 1: 写 CategorySection.hpp**(按上 Interfaces 完整声明) - [ ] **Step 2: 写 CategorySection.cpp**(按实现要点;段头/段体/筛选/右键) - [ ] **Step 3: 注册 CMake** - [ ] **Step 4: build** Run: `cmake --build build` Expected: 编译通过(CategorySection 暂未接入 ColumnDrawer,仅编译)。 - [ ] **Step 5: 提交** ```bash git add src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/data/repo/RepoTypes.hpp src/data/dto/NavDto.cpp src/app/CMakeLists.txt git commit -m "feat(ui): CategorySection 类型段组件(段头筛选+段体树+生成入口)" ``` --- ### Task 8: CategoryAnalysisTab 容器 + ColumnDrawer 两 tab **Files:** - Create: `src/app/panels/columns/CategoryAnalysisTab.hpp` / `.cpp` - Modify: `src/app/panels/columns/ColumnDrawer.{hpp,cpp}` - Modify: app CMake **Interfaces:** - Produces: ```cpp class CategoryAnalysisTab : public QWidget { // QScrollArea 堆叠 5 段 Q_OBJECT public: CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* dict, QWidget* parent=nullptr); void setBuckets(const geopro::app::CategoryBuckets& b); // 分发到 5 段 CategorySection* section(const std::string& id) const; // 按 CategorySpec.id 取段 signals: void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选合并(并集)上抛 void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); void detailRequested(const QString&, const QString&, const QString&); }; ``` - `ColumnDrawer`:`col3D_/colAnalysis_` 两个旧成员替换为 `CategoryAnalysisTab* analysisTab_`;tabs 改为 `addTab(analysisTab_, "三维分析")` + `addTab(col2D_, "二维分析")`。`col3D()/colAnalysis()` 访问器替换为 `analysisTab()`。 **实现要点:** `CategoryAnalysisTab` 内 `QScrollArea` + `QVBoxLayout`,遍历 `categoryConfigs()` 建 5 个 `CategorySection`(存 `std::map`);`setBuckets` 把 `b.segments[i]` 给第 i 段;把各段 `checkedDatasetsChanged` 收集成并集再上抛(每段维护各自勾选集,合并)。 - [ ] **Step 1: 写 CategoryAnalysisTab.hpp/.cpp** - [ ] **Step 2: 改 ColumnDrawer**(两 tab;删 col3D_/colAnalysis_,留 col2D_) - [ ] **Step 3: 注册 CMake + build** Run: `cmake --build build` Expected: ColumnDrawer 编译通过;main.cpp 仍引用旧 `col3D()/colAnalysis()` 会编译错——Task 12 切换。**为保持可编译**:本 task 暂保留旧 `col3D()/colAnalysis()` 返回 nullptr 或保留旧成员并存,Task 12 删。决策:保留 `col2D()` + 新增 `analysisTab()`,旧 `col3D()/colAnalysis()` 暂留空实现直到 Task 12。 - [ ] **Step 4: 提交** ```bash git add src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp src/app/CMakeLists.txt git commit -m "feat(ui): CategoryAnalysisTab(QScrollArea 5段)+ColumnDrawer 两tab" ``` --- ## Phase 6 — VTK 画布工具条 ### Task 9: VtkViewToolbar + AxesSettingsDialog **Files:** - Create: `src/app/VtkViewToolbar.hpp` / `.cpp`、`src/app/AxesSettingsDialog.hpp` / `.cpp` - Modify: app CMake **Interfaces:** - Produces: ```cpp class VtkViewToolbar : public QWidget { // 竖排:设置/前后上下左右/放大缩小复位 Q_OBJECT public: explicit VtkViewToolbar(QWidget* parent=nullptr); signals: void axesSettingsRequested(); // 设置→弹 AxesSettingsDialog void viewRequested(geopro::controller::ViewDir dir); // 前后上下左右 void zoomInRequested(); void zoomOutRequested(); void fitRequested(); // 复位=适配 }; struct AxisRange { bool show=true; double min=-500, max=500; }; class AxesSettingsDialog : public QDialog { Q_OBJECT public: AxesSettingsDialog(AxisRange x, AxisRange y, AxisRange z, QWidget* parent=nullptr); AxisRange x() const; AxisRange y() const; AxisRange z() const; // 应用后读取 }; ``` **实现要点:** 工具条三组按钮(QToolButton 竖排),图标用现有 `makeGlyph`/`Glyph`;信号直接转发。`AxesSettingsDialog`:三组(X/Y/深度)各 `QCheckBox 显示` + 两 `QDoubleSpinBox min/max`,底部 取消/应用。视图方向按钮文字「前/后/上/下/左/右」对应 `ViewDir::Front/Back/Top/Bottom/Left/Right`。 - [ ] **Step 1: 写 AxesSettingsDialog.hpp/.cpp** - [ ] **Step 2: 写 VtkViewToolbar.hpp/.cpp** - [ ] **Step 3: 注册 CMake + build** Run: `cmake --build build` Expected: 编译通过(暂未接入中央视图)。 - [ ] **Step 4: 提交** ```bash git add src/app/VtkViewToolbar.hpp src/app/VtkViewToolbar.cpp src/app/AxesSettingsDialog.hpp src/app/AxesSettingsDialog.cpp src/app/CMakeLists.txt git commit -m "feat(ui): VtkViewToolbar 画布工具条 + AxesSettingsDialog" ``` --- ## Phase 7 — Api3dRepository 扩参 + 段重组 ### Task 10: createVolume/createSlice 扩参 + 请求体 DTO 组装 **Files:** - Modify: `src/data/api/Api3dRepository.{hpp,cpp}` - Test: `tests/data/test_api3d_requests.cpp`(新增;验证组装的请求体) **Interfaces:** - Changed: - `std::string createVolume(const VoxelGenerateRequest& req);`(替代旧 `(VolumeBuildParams,name)`;内部 `StoredVolume` 同时存 `req` 与从 req 派生的 `VolumeBuildParams`) - `void createSlice(const SliceSpec& spec, const std::string& name, const std::string& projectId, OnOk, OnErr);`(加 projectId) - `const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const;`(测试/调试取回组装请求体) - [ ] **Step 1: 写失败测试** — `tests/data/test_api3d_requests.cpp`:构造 `Api3dRepository`(喂 stub dsRepo + frame),调 `createVolume(req)`,取回 `lastVoxelRequest(id)->toJson()` 断言含 structParentId/structParentConfType/sourceDatasetIds。 (若 `Api3dRepository` 构造依赖重,改为只测「VolumeBuildParams ← VoxelGenerateRequest 的派生函数」纯函数 + toJson;把派生逻辑抽 `VolumeBuildParams fromRequest(const VoxelGenerateRequest&)` 纯函数单测。) ```cpp TEST(Api3dRequestAssembly, VolumeBuildParamsFromRequest) { geopro::data::VoxelGenerateRequest q; q.sourceDatasetIds = {"d1"}; q.cellXY = 2.0; q.power = 3.0; q.colorScaleId = "cs1"; const auto p = geopro::data::fromRequest(q); EXPECT_EQ(p.sourceDatasetIds.size(), 1u); EXPECT_DOUBLE_EQ(p.cellXY, 2.0); EXPECT_DOUBLE_EQ(p.power, 3.0); EXPECT_EQ(p.colorScaleId, "cs1"); } ``` - [ ] **Step 2: 跑确认失败** — `ctest -R Api3dRequestAssembly` → 失败。 - [ ] **Step 3: 加 `fromRequest`**(`Vtk3dRequests.{hpp,cpp}` 或 Api3d):`VolumeBuildParams fromRequest(const VoxelGenerateRequest&)` 映射字段(interpModel 字符串→enum)。 - [ ] **Step 4: 改 createVolume/createSlice 签名** — `createVolume(const VoxelGenerateRequest& req)`:`StoredVolume` 存 `req` + `fromRequest(req)`;返回 mock id。`createSlice` 加 projectId,内部组装 `SliceGenerateRequest`(`qDebug()` 打印 `toJson()`)。 - [ ] **Step 5: 跑确认通过 + build** — `cmake --build build && ctest -R Api3dRequestAssembly` → PASS。 - [ ] **Step 6: 提交** ```bash git add src/data/api/Api3dRepository.hpp src/data/api/Api3dRepository.cpp src/data/dto/Vtk3dRequests.hpp src/data/dto/Vtk3dRequests.cpp tests/data/test_api3d_requests.cpp tests/data/CMakeLists.txt git commit -m "feat(data): createVolume/createSlice 扩参+请求体DTO组装(mock)" ``` --- ### Task 11: 三维体段「体→切片/异常」三级树 + 异常按归属挂体/切片(spec §8) > **设计修订(2026-06-24)**:取消「独立异常区」的旧设计。异常不再单列、不再"随当前活动体"展示,而是作为叶子挂在它**归属的实体节点**(体 或 切片)下。归属按 `异常→所在切片→切片所属体` 链确定,挂载目标由「切片是否已保存成 dd_slice」决定(见 spec §8)。多体渲染本就支持(`VtkSceneView::dsProps_` 按 dsId 各存 actor);`volumeOwnerDs_`/`currentVolumeImage_` = 当前切片操作所基于的体(非"唯一可渲体")。 **Files:** - Modify: `src/core/model/Anomaly.hpp`(`volumeDsId` → `remarkSourceId` + `remarkSourceType`) - Modify: `src/data/api/Api3dRepository.{hpp,cpp}`(`saveAnomaly`/`loadAnomalyTree` mock 按 remarkSource 存/查;anomalyRows 供树注入) - Modify: `src/app/main.cpp`(创建异常时判断所在切片是否已保存 → 设 remarkSourceId/Type;refreshAnomalies 改注入三维体段树) - Modify: `src/app/panels/columns/CategorySection.cpp` / `CategoryAnalysisTab.{hpp,cpp}`(三维体段三级树 + 切片/异常勾选·详情·删除转发) - Test: `tests/...`(remarkSource 归属判定纯函数 + Api3d 异常 mock 存查) **Interfaces / 数据模型:** - `Anomaly`:`volumeDsId` 改名为 `std::string remarkSourceId;`(挂载实体 dsId = 体 or 切片)。**不加** type 字段——挂体/挂切片由 `remarkSourceId` 查 `isVolume/isSlice` 区分,展示树按 `parentId=remarkSourceId` 自动挂载。(⚠️ 后端 `remarkSourceType` 是标注几何形态 1-4 = `markType`,勿混。) - 纯函数(可单测):`std::string resolveAnomalyMount(bool sliceIsSaved, const std::string& savedSliceDsId, const std::string& volumeDsId);` —— 已保存切片→`savedSliceDsId`;否则→`volumeDsId`。返回挂载实体 dsId(= remarkSourceId)。 - [ ] **Step 1(逻辑层,可单测): Anomaly 模型 + resolveAnomalyMount** 改 `Anomaly`(remarkSourceId/Type,全量改其引用点:VtkSceneView addAnomaly/removeAnomaly 按 id 跟踪不受影响,仅 main 创建处赋值变);写 `resolveAnomalyMount` 纯函数 + 单测(已保存切片挂切片 / 临时切片挂体 两例)。**build test 绿**。 - [ ] **Step 2(逻辑层): Api3dRepository 异常 mock 按归属存查** `StoredAnomaly` 按 remarkSourceId/Type 存;`loadAnomalyTree(sourceId)` 或新增 `anomalyRows(remarkSourceId)` 返回该体/切片下异常行(DsRow 形态,ddCode 自定如 `dd_anomaly`,parentId=remarkSourceId)供树注入;`saveAnomaly` 存 remarkSource。单测:挂体/挂切片分别能查回。 - [ ] **Step 3: main 创建异常逻辑**(main:~502 区) 画异常时从 `interactionMgr` 取当前选中切片状态:已保存切片→其 dsId(`selectSavedSlice`/`selectedSavedSliceId` 核实接口);临时切片→`volumeOwnerDs_`。调 `resolveAnomalyMount` 设 `a.remarkSourceId/remarkSourceType`。(**先核实 interactionMgr 如何区分"当前切片已保存 vs 临时"+取其 dsId**,不凭印象。) - [ ] **Step 4(UI,需真实验证): 三维体段三级树** `refreshAnomalies` 改:把异常行按 remarkSourceId 注入三维体段树——挂体异常作体节点子、挂切片异常作切片节点子;切片作体节点子(parentId=volumeDsId)。`CategoryAnalysisTab`/`CategorySection`(voxel 段)补:体/切片/异常三级建树 + 异常/切片勾选·详情·删除信号转发(迁 `Column3DAnalysis` 对应控件逻辑)。 - [ ] **Step 5: build + 手动验证**(生成体→体节点;体上画异常(切片未存)→异常挂体下;保存切片→切片挂体下;切片上画异常→异常挂该切片下;勾选/详情/删除各级生效) - [ ] **Step 6: 提交**(分逻辑层[Step1-3] 与 UI[Step4] 两 commit;前者可 build test 绿,后者 build app + 真实验证) > **波及**:`Anomaly.volumeDsId` 改名会触及现有所有读取点(VtkSceneView 渲染按 worldPts/plane,不读 volumeDsId;main saveAnomaly 赋值;Api3dRepository StoredAnomaly)——Step 1 一并改全。切片保存/关闭(main setItemChecked)随三级树勾选 API 一并迁。 --- ## Phase 8 — main.cpp 接线总成 ### Task 12: 数据流接线 + 退役旧栏 **Files:** - Modify: `src/app/main.cpp`(:388-460 异常/勾选聚合、:1113-1225 对象树→分类分发) - Modify: `src/app/panels/ObjectTreePanel.{hpp,cpp}`(删旧 `checkedTmsChanged`) - Modify: `src/app/panels/columns/ColumnDrawer.{hpp,cpp}`(删旧 `col3D()/colAnalysis()`) - Delete refs: `Column3DDataset` / `Column3DAnalysis`(源文件可留待后续清理,先解除 main 引用) **接线改动:** 1. 对象树勾选:`objectTree::checkedTmsChanged` → `checkedSourcesChanged(QList)`。回调内对每源 `loadRowsAsync(projId, src.id, src.confType, /*classify*/3, 1, 100000)`(第3参 `src.confType` 取代字面量 `2`,见 ApiProjectRepository.cpp:81-89 透传),汇总 `DsRow[]`(保留现有 `generation` 防陈旧 + 多源计数 finish)。 2. 分类分发:`finish` 内 `splitByCategory(*acc)` → `drawer->analysisTab()->setBuckets(b)`(取代旧 `col3D/col2D/colAnalysis setDatasets` + `splitByDimension`)。二维数据(dd_trajectory_data 等)仍走 `drawer->col2D()->setDatasets(...)`——**注意**:splitByCategory 只产 5 个 3D 段,二维数据需单独分出:保留一个 `dim2D` 过滤(trajectory 类)喂 col2D。 3. 渲染勾选并集:`analysisTab::checkedDatasetsChanged`(5 段并集,含帘面源=电阻率/视电阻率/瞬变 + 体素/切片)→ 并入 `checkedProfiles`/`checkedAnalysis` → `pushChecked()`(沿用现 :457-462 并集模型)。 4. 生成三维体:`analysisTab::generateVolumeRequested(dsTypeCode, sourceDsIds)` → 弹 `VolumeParamsDialog`(**左侧**勾选源 ds 树·按 GS 分组·可二次增删确认;**右侧**参数含「生成位置」下拉 = 项目内 GS/TM 列表,**默认**源单 GS→该 GS、跨 GS→项目根)→ 用户确认 → 组装 `VoxelGenerateRequest{projectId, structParentId=所选生成位置, structParentConfType=1或2, name, sourceDatasetIds=对话框最终勾选, params}` → `scene3dRepo->createVolume(req)` → `refreshAnalysis()`。`VolumeParamsDialog` 需扩:左侧源列表(含二次增删)+ 「生成位置」下拉(项目 GS/TM 列表来自对象树结构,按默认规则预选)。 5. 切片保存调用点补 `nav.currentProjectId()`(:581/716+ createSlice 调用)。 6. 工具条接入中央视图:实例化 `VtkViewToolbar` 叠加在 QVTK 上,信号接 `sceneCtrl`(axesSettingsRequested→弹 AxesSettingsDialog→应用到坐标轴;viewRequested/zoom*/fit 接现有槽,迁自 Column3DDataset 接线 :898 等);`setVerticalExaggeration` 默认回灌迁到工具条对应控件。 7. 删旧信号/访问器/`splitByDimension` 引用。 - [ ] **Step 1: 改对象树勾选接线**(confType 分流拉取 + splitByCategory 分发) - [ ] **Step 2: 改渲染勾选并集**(analysisTab 并集→pushChecked) - [ ] **Step 3: 改生成三维体接线**(组装 VoxelGenerateRequest) - [ ] **Step 4: 接入工具条 + 坐标轴对话框** - [ ] **Step 5: 切片保存补 projectId** - [ ] **Step 6: 删旧信号/访问器/Column3D* 引用 + splitByDimension** - [ ] **Step 7: build + 全量手动回归** Run: `cmake --build build && ctest --test-dir build --output-on-failure` Expected: 全绿 + 编译通过。手动回归清单: 1. 勾对象树 GS/TM/项目根 → 对应数据进 5 个大类段(电阻率/视电阻率/瞬变/三维体/切片各就各位)。 2. 勾电阻率某行 → 中央出帘面(原渲染不丢)。 3. GS 节点右键「生成三维体」→ 弹参数对话框 → 生成体进三维体段 → 勾选出体素。 4. 工具条:设置弹坐标轴对话框、前后上下左右切视图、放大/缩小/复位生效。 5. 日期/装置类型筛选(电阻率段)生效;瞬变段无装置类型下拉。 6. 二维分析 tab 不变(足迹照常)。 7. 控制台可见 createVolume/createSlice 打印的真实请求体 JSON。 - [ ] **Step 8: 提交** ```bash git add src/app/main.cpp src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp git commit -m "refactor(app): main 接线总成-分类分发/勾选并集/生成归属/工具条+退役旧栏" ``` --- ## 自审(spec 覆盖核对) | spec 节 | 覆盖任务 | |---|---| | §4 整体架构(两 tab/5 段/工具条) | Task 8, 9, 12 | | §5 DsRow 扩展 + CategoryConfig + splitByCategory | Task 1, 2 | | §6 对象树 GS 三态 + 信号 + 数据流 | Task 4, 5, 12 | | §7 CategorySection(段头/段体/生成入口/勾选承接/双重过滤) | Task 7, 12 | | §8 三维体/切片/异常段 + 请求体组装 | Task 10, 11, 12 | | §9 VtkViewToolbar + AxesSettingsDialog + setVE 回灌 | Task 9, 12 | | §10 DatasetFieldDictionary + collectTime 筛选 | Task 6, 7 | | §11 装置 value→中文待坐实 | Task 6(回退原值 + 注明) | | §12 组件/文件边界 | 全部 | **待坐实(不阻塞实施,实现中坐实):** 装置类型 value→中文 字典源(Task 6 注明,待属性面板截图)。 **Phase 间编译连续性:** Task 5/8 采用「新旧并存」过渡(保留旧信号/访问器),Task 12 统一切换并删旧——保证每次提交可编译。 ---