# 对象单击/勾选 驱动 数据列表·异常(含异常体)·属性 面板 实现计划 > **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:** 让单击对象加载其 DS 列表与对象属性、勾选对象聚合显示「异常+异常体」只读树、单击数据集显示数据集属性,全部接真实业务 API。 **Architecture:** 沿用四层(net→data→controller→app)。数据层新增纯函数 DTO(TDD 单测)+ 仓储方法;控制器新增 slots/signals 编排;UI 新增两个被动面板(`DynamicFormView`、`ObjectExceptionPanel`)并改造 `ObjectTreePanel` 为三态勾选。中央/详情 VTK 渲染本轮不动。 **Tech Stack:** C++17、Qt6 Widgets、GoogleTest/CTest、CMake(preset `msvc-release`)、现有 `ApiClient`(同步 HTTP,token 已注入)。 **Spec:** `docs/superpowers/specs/2026-06-10-object-selection-panels-design.md` --- ## 构建 / 测试命令(全程通用) - 配置(仅首次或改 CMakeLists 后):`cmake --preset msvc-release` - 构建测试目标:`cmake --build --preset release --target geopro_tests` - 构建桌面程序:`cmake --build --preset release --target geopro_desktop` - 跑 DTO 单测:`ctest --test-dir build/release -C Release -R NavDto --output-on-failure` > MSVC 多配置:`ctest` 必须带 `-C Release`。新增 test 源文件后需重新 `cmake --preset msvc-release` 再构建。 --- ## 文件结构 **数据/模型层** - `src/data/repo/RepoTypes.hpp`(改)— 新增 `DynamicFormField/Group/DynamicForm`、`ExceptionRow`、`ConsortiumGroup`、`ObjectExceptionGroup`、`GroupedExceptions` - `src/data/dto/NavDto.hpp`/`.cpp`(改)— 新增 `parseDynamicForm` / `parseExceptions` / `groupExceptionsByConsortium` - `src/data/repo/IProjectRepository.hpp`(改)— `loadTmRows`→`loadRows` 泛化 + 3 个新方法 - `src/data/api/ApiProjectRepository.hpp`/`.cpp`(改)— 实现新方法 - `tests/data/test_nav_dto.cpp`(改)— 新增 3 组用例 **逻辑层** - `src/controller/WorkbenchNavController.hpp`/`.cpp`(改)— 新 slots/signals/状态 **UI 层** - `src/app/panels/DynamicFormView.hpp`/`.cpp`(新)— 动态表单键值渲染器 - `src/app/panels/ObjectExceptionPanel.hpp`/`.cpp`(新)— 异常+异常体只读树 - `src/app/panels/ObjectTreePanel.hpp`/`.cpp`(改)— GS 三态勾选 + 新信号 - `src/app/main.cpp`(改)— 接线 + 移除占位 - `src/app/CMakeLists.txt`(改)— 加两个新源文件 --- ## Task 1: 模型 + `parseDynamicForm`(TDD) **Files:** - Modify: `src/data/repo/RepoTypes.hpp` - Modify: `src/data/dto/NavDto.hpp` - Modify: `src/data/dto/NavDto.cpp` - Test: `tests/data/test_nav_dto.cpp` - [ ] **Step 1: 在 `RepoTypes.hpp` 加动态表单模型** 在 `RepoTypes.hpp` 的 `namespace geopro::data {` 内、`StructNode` 之后加: ```cpp // 动态表单(GS/TM/DS 详情统一模型)。值已与字段定义合并、已按 sort 排好序。 struct DynamicFormField { std::string name, value; }; struct DynamicFormGroup { std::string name; std::vector fields; }; struct DynamicForm { std::string name; std::vector groups; }; ``` - [ ] **Step 2: 在 `NavDto.hpp` 声明 `parseDynamicForm`** 在 `namespace geopro::data::dto {` 内加: ```cpp // DynamicFormVO 对象 → DynamicForm:合并 formList(字段定义) + properties(值)。 // 组按 groupSort、字段按 displaySort 排序;值取 properties[fieldCode](缺失→空串)。 // 表头 name 取 data["name"]。 DynamicForm parseDynamicForm(const QJsonObject& data); ``` - [ ] **Step 3: 写失败测试** 在 `tests/data/test_nav_dto.cpp` 末尾(`using namespace geopro::data;` 已在文件顶部)加: ```cpp TEST(NavDto, ParseDynamicFormMergesFieldsValuesAndSorts) { const auto data = objOf(R"({ "name": "测线1", "properties": { "depth": "120", "len": "300", "owner": "张三" }, "formList": [ { "groupName": "几何", "groupSort": 1, "values": [ { "fieldName": "长度", "fieldCode": "len", "displaySort": 2 }, { "fieldName": "深度", "fieldCode": "depth", "displaySort": 1 } ]}, { "groupName": "归属", "groupSort": 2, "values": [ { "fieldName": "负责人", "fieldCode": "owner", "displaySort": 1 }, { "fieldName": "缺失项", "fieldCode": "nope", "displaySort": 2 } ]} ] })"); const auto form = dto::parseDynamicForm(data); EXPECT_EQ(form.name, "测线1"); ASSERT_EQ(form.groups.size(), 2u); EXPECT_EQ(form.groups[0].name, "几何"); // groupSort 升序 ASSERT_EQ(form.groups[0].fields.size(), 2u); EXPECT_EQ(form.groups[0].fields[0].name, "深度"); // displaySort 升序 EXPECT_EQ(form.groups[0].fields[0].value, "120"); EXPECT_EQ(form.groups[0].fields[1].name, "长度"); EXPECT_EQ(form.groups[0].fields[1].value, "300"); EXPECT_EQ(form.groups[1].fields[1].value, ""); // properties 缺失 → 空串 } TEST(NavDto, ParseDynamicFormEmptyFormListYieldsNoGroups) { const auto data = objOf(R"({ "name":"空", "properties":{}, "formList":[] })"); const auto form = dto::parseDynamicForm(data); EXPECT_EQ(form.name, "空"); EXPECT_TRUE(form.groups.empty()); } ``` - [ ] **Step 4: 跑测试确认失败** Run: `cmake --preset msvc-release && cmake --build --preset release --target geopro_tests` Expected: 编译失败(`parseDynamicForm` 未定义)。 - [ ] **Step 5: 在 `NavDto.cpp` 实现 `parseDynamicForm`** `NavDto.cpp` 顶部确保有 `#include `(若无则加)。在 `namespace geopro::data::dto {` 内加: ```cpp DynamicForm parseDynamicForm(const QJsonObject& data) { DynamicForm form; form.name = str(data, "name"); const QJsonObject props = data.value(QStringLiteral("properties")).toObject(); QJsonArray groups = data.value(QStringLiteral("formList")).toArray(); std::vector gv; gv.reserve(static_cast(groups.size())); for (const QJsonValue& g : groups) gv.push_back(g.toObject()); std::stable_sort(gv.begin(), gv.end(), [](const QJsonObject& a, const QJsonObject& b) { return a.value(QStringLiteral("groupSort")).toInt() < b.value(QStringLiteral("groupSort")).toInt(); }); for (const QJsonObject& g : gv) { DynamicFormGroup group; group.name = str(g, "groupName"); QJsonArray vals = g.value(QStringLiteral("values")).toArray(); std::vector fv; fv.reserve(static_cast(vals.size())); for (const QJsonValue& v : vals) fv.push_back(v.toObject()); std::stable_sort(fv.begin(), fv.end(), [](const QJsonObject& a, const QJsonObject& b) { return a.value(QStringLiteral("displaySort")).toInt() < b.value(QStringLiteral("displaySort")).toInt(); }); for (const QJsonObject& f : fv) { DynamicFormField field; field.name = str(f, "fieldName"); const QString code = f.value(QStringLiteral("fieldCode")).toString(); field.value = props.value(code).toVariant().toString().toStdString(); // 值可能非字符串 group.fields.push_back(std::move(field)); } form.groups.push_back(std::move(group)); } return form; } ``` - [ ] **Step 6: 跑测试确认通过** Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release -R NavDto --output-on-failure` Expected: PASS(含 `ParseDynamicForm*` 两条)。 - [ ] **Step 7: 提交** ```bash git add src/data/repo/RepoTypes.hpp src/data/dto/NavDto.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp git commit -m "feat(data): parseDynamicForm 合并动态表单字段定义与值(含排序)" ``` --- ## Task 2: `parseExceptions`(TDD) **Files:** - Modify: `src/data/repo/RepoTypes.hpp` - Modify: `src/data/dto/NavDto.hpp` - Modify: `src/data/dto/NavDto.cpp` - Test: `tests/data/test_nav_dto.cpp` - [ ] **Step 1: 在 `RepoTypes.hpp` 加 `ExceptionRow`** 紧接 `DynamicForm` 之后加: ```cpp // 异常(树叶,本轮只读)。consortium* 空 = 独立异常;detailSummary = 详情展开内联显示。 struct ExceptionRow { std::string id, name, typeName, createTime; std::string consortiumId, consortiumName, consortiumType; std::string detailSummary; }; ``` - [ ] **Step 2: 在 `NavDto.hpp` 声明 `parseExceptions`** ```cpp // ExceptionVO 数组 → [ExceptionRow]。字段:id、name=exceptionName、typeName=exceptionTypeName、 // createTime;consortium* 取自 consortiumId/consortiumName/consortiumType(来源待 live 验证,§12.3); // detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。 std::vector parseExceptions(const QJsonArray& arr); ``` - [ ] **Step 3: 写失败测试** 在 `tests/data/test_nav_dto.cpp` 末尾加: ```cpp TEST(NavDto, ParseExceptionsMapsFieldsAndSummary) { const auto arr = arrOf(R"([ { "id":"e1", "exceptionName":"空洞A", "exceptionTypeName":"空洞", "exceptionMarkTypeName":"自动", "createTime":"2026-06-01", "elevationList":[120.0, 80.0, 100.0], "remark":"复核中", "consortiumId":"c1", "consortiumName":"体A", "consortiumType":"溶洞群" }, { "id":"e2", "exceptionName":"裂隙B", "exceptionTypeName":"裂隙", "exceptionMarkTypeName":"手动", "createTime":"2026-06-02", "elevationList":[], "remark":"" } ])"); const auto rows = dto::parseExceptions(arr); ASSERT_EQ(rows.size(), 2u); EXPECT_EQ(rows[0].id, "e1"); EXPECT_EQ(rows[0].name, "空洞A"); EXPECT_EQ(rows[0].typeName, "空洞"); EXPECT_EQ(rows[0].consortiumId, "c1"); EXPECT_EQ(rows[0].consortiumName, "体A"); EXPECT_EQ(rows[0].consortiumType, "溶洞群"); // detailSummary 含标记类型、创建时间、高程极值、备注 EXPECT_NE(rows[0].detailSummary.find("自动"), std::string::npos); EXPECT_NE(rows[0].detailSummary.find("2026-06-01"), std::string::npos); EXPECT_NE(rows[0].detailSummary.find("80"), std::string::npos); EXPECT_NE(rows[0].detailSummary.find("120"), std::string::npos); EXPECT_NE(rows[0].detailSummary.find("复核中"), std::string::npos); // e2 无 consortium、无高程、无备注:consortiumId 空、summary 不崩 EXPECT_TRUE(rows[1].consortiumId.empty()); EXPECT_NE(rows[1].detailSummary.find("手动"), std::string::npos); } ``` - [ ] **Step 4: 跑测试确认失败** Run: `cmake --build --preset release --target geopro_tests` Expected: 编译失败(`parseExceptions` 未定义)。 - [ ] **Step 5: 在 `NavDto.cpp` 实现 `parseExceptions`** `NavDto.cpp` 顶部确保有 `#include ` 与 ``(若无则加)。加: ```cpp namespace { // elevationList 极值拼 "高程 min~max m";空返回空串。 std::string elevationSummary(const QJsonArray& el) { if (el.isEmpty()) return {}; double lo = std::numeric_limits::max(), hi = -std::numeric_limits::max(); for (const QJsonValue& v : el) { const double d = v.toDouble(); if (d < lo) lo = d; if (d > hi) hi = d; } return QStringLiteral("高程 %1~%2m") .arg(lo, 0, 'f', 0).arg(hi, 0, 'f', 0).toStdString(); } } // namespace std::vector parseExceptions(const QJsonArray& arr) { std::vector out; out.reserve(static_cast(arr.size())); for (const QJsonValue& v : arr) { const QJsonObject o = v.toObject(); ExceptionRow r; r.id = str(o, "id"); r.name = str(o, "exceptionName"); r.typeName = str(o, "exceptionTypeName"); r.createTime = str(o, "createTime"); r.consortiumId = str(o, "consortiumId"); // §12.3 待 live 验证字段名 r.consortiumName = str(o, "consortiumName"); r.consortiumType = str(o, "consortiumType"); // detailSummary:标记类型 · 创建时间 [\n 高程 …] [\n 备注 …] QStringList lines; lines << QStringLiteral("标记 %1 · 创建 %2") .arg(QString::fromStdString(str(o, "exceptionMarkTypeName"))) .arg(QString::fromStdString(r.createTime)); const std::string elev = elevationSummary(o.value(QStringLiteral("elevationList")).toArray()); if (!elev.empty()) lines << QString::fromStdString(elev); const std::string remark = str(o, "remark"); if (!remark.empty()) lines << QStringLiteral("备注 %1").arg(QString::fromStdString(remark)); r.detailSummary = lines.join(QLatin1Char('\n')).toStdString(); out.push_back(std::move(r)); } return out; } ``` > `NavDto.cpp` 需 `#include `(若未含)。 - [ ] **Step 6: 跑测试确认通过** Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release -R NavDto --output-on-failure` Expected: PASS(含 `ParseExceptionsMapsFieldsAndSummary`)。 - [ ] **Step 7: 提交** ```bash git add src/data/repo/RepoTypes.hpp src/data/dto/NavDto.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp git commit -m "feat(data): parseExceptions 映射异常字段 + 详情摘要" ``` --- ## Task 3: `groupExceptionsByConsortium`(TDD) **Files:** - Modify: `src/data/repo/RepoTypes.hpp` - Modify: `src/data/dto/NavDto.hpp` - Modify: `src/data/dto/NavDto.cpp` - Test: `tests/data/test_nav_dto.cpp` - [ ] **Step 1: 在 `RepoTypes.hpp` 加分组模型** 紧接 `ExceptionRow` 之后加: ```cpp // 异常体分组(树中间层)+ 对象分组(树根层,对应一个被勾选 TM)。 struct ConsortiumGroup { std::string id, name, typeName; std::vector exceptions; }; struct ObjectExceptionGroup { std::string objectId, objectName; std::vector consortia; std::vector looseExceptions; }; struct GroupedExceptions { std::vector consortia; std::vector loose; }; ``` - [ ] **Step 2: 在 `NavDto.hpp` 声明 `groupExceptionsByConsortium`** ```cpp // 把一个对象(TM)的异常行按 consortiumId 分组:同 id 归一组(组名/类型取首个非空); // consortiumId 空 → loose。保持首次出现顺序稳定。纯函数、可单测。 GroupedExceptions groupExceptionsByConsortium(const std::vector& rows); ``` - [ ] **Step 3: 写失败测试** 在 `tests/data/test_nav_dto.cpp` 末尾加(顶部已有 `#include `?若无则在文件顶部 includes 处加 `#include `): ```cpp TEST(NavDto, GroupExceptionsByConsortiumSplitsLooseAndGroups) { std::vector rows = { { "e1","空洞A","空洞","t1","c1","体A","溶洞群","" }, { "e2","空洞B","空洞","t1","c1","","","" }, // 同体 c1,名/类型取首个非空 { "e3","裂隙X","裂隙","t1","","","","" }, // 独立异常 { "e4","空洞C","空洞","t1","c2","体B","溶洞群","" }, }; const auto g = dto::groupExceptionsByConsortium(rows); ASSERT_EQ(g.consortia.size(), 2u); EXPECT_EQ(g.consortia[0].id, "c1"); EXPECT_EQ(g.consortia[0].name, "体A"); // 首个非空 EXPECT_EQ(g.consortia[0].typeName, "溶洞群"); ASSERT_EQ(g.consortia[0].exceptions.size(), 2u); EXPECT_EQ(g.consortia[1].id, "c2"); ASSERT_EQ(g.loose.size(), 1u); EXPECT_EQ(g.loose[0].id, "e3"); } TEST(NavDto, GroupExceptionsAllLooseWhenNoConsortium) { std::vector rows = { { "e1","a","t","t1","","","","" }, { "e2","b","t","t1","","","","" } }; const auto g = dto::groupExceptionsByConsortium(rows); EXPECT_TRUE(g.consortia.empty()); EXPECT_EQ(g.loose.size(), 2u); } ``` - [ ] **Step 4: 跑测试确认失败** Run: `cmake --build --preset release --target geopro_tests` Expected: 编译失败(`groupExceptionsByConsortium` 未定义)。 - [ ] **Step 5: 在 `NavDto.cpp` 实现** `NavDto.cpp` 顶部确保有 `#include `。加: ```cpp GroupedExceptions groupExceptionsByConsortium(const std::vector& rows) { GroupedExceptions out; std::unordered_map indexById; // consortiumId → out.consortia 下标 for (const auto& r : rows) { if (r.consortiumId.empty()) { out.loose.push_back(r); continue; } auto it = indexById.find(r.consortiumId); if (it == indexById.end()) { ConsortiumGroup g; g.id = r.consortiumId; g.name = r.consortiumName; g.typeName = r.consortiumType; indexById.emplace(r.consortiumId, out.consortia.size()); out.consortia.push_back(std::move(g)); it = indexById.find(r.consortiumId); } ConsortiumGroup& g = out.consortia[it->second]; if (g.name.empty() && !r.consortiumName.empty()) g.name = r.consortiumName; // 取首个非空 if (g.typeName.empty() && !r.consortiumType.empty()) g.typeName = r.consortiumType; g.exceptions.push_back(r); } return out; } ``` - [ ] **Step 6: 跑测试确认通过** Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release -R NavDto --output-on-failure` Expected: PASS(含 `GroupExceptions*` 两条)。 - [ ] **Step 7: 提交** ```bash git add src/data/repo/RepoTypes.hpp src/data/dto/NavDto.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp git commit -m "feat(data): groupExceptionsByConsortium 按异常体分组 + 独立异常" ``` --- ## Task 4: 仓储接口扩展 + API 实现 无单测(依赖 live 后端);以**编译通过**为验证。 **Files:** - Modify: `src/data/repo/IProjectRepository.hpp` - Modify: `src/data/api/ApiProjectRepository.hpp` - Modify: `src/data/api/ApiProjectRepository.cpp` - [ ] **Step 1: 改 `IProjectRepository.hpp`:`loadTmRows`→`loadRows` + 3 新方法** 把现有 `loadTmRows` 纯虚声明整段替换为: ```cpp // 按结构父节点分页拉数据/文件行:parentConfType 1=GS 2=TM;classifyType 3=数据 1=文件; // pageNo 从 1 起,pageSize 固定 5。 virtual RepoResult loadRows(const std::string& projectId, const std::string& parentId, int parentConfType, int classifyType, int pageNo) = 0; // 对象详情:confType 1=GS(getGsObjectDetail) 2=TM(tmObject/getDetail) → 动态表单。 virtual RepoResult loadObjectDetail(const std::string& objectId, int confType) = 0; // 数据集详情:dsObject/dynamicForm/{dsObjectId} → 动态表单。 virtual RepoResult loadDatasetForm(const std::string& dsObjectId) = 0; // 单 TM 异常列表(含异常体归属字段)。 virtual RepoResult> loadExceptionsByTm(const std::string& tmObjectId) = 0; ``` - [ ] **Step 2: 改 `ApiProjectRepository.hpp`:同步声明** 把 `loadTmRows` 的 `override` 声明整段替换为(保持类内 `public:` 区): ```cpp RepoResult loadRows(const std::string& projectId, const std::string& parentId, int parentConfType, int classifyType, int pageNo) override; RepoResult loadObjectDetail(const std::string& objectId, int confType) override; RepoResult loadDatasetForm(const std::string& dsObjectId) override; RepoResult> loadExceptionsByTm(const std::string& tmObjectId) override; ``` - [ ] **Step 3: 改 `ApiProjectRepository.cpp`:实现** 把现有 `ApiProjectRepository::loadTmRows(...)` 整个函数体替换为下面 4 个函数(`loadRows` 即原 `loadTmRows` 把 `structParentConfType` 从写死 2 改为入参 `parentConfType`): ```cpp RepoResult ApiProjectRepository::loadRows(const std::string& projectId, const std::string& parentId, int parentConfType, int classifyType, int pageNo) { const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") : QStringLiteral("/business/dsObject/data/page"); const QJsonObject body{ {QStringLiteral("projectId"), QString::fromStdString(projectId)}, {QStringLiteral("structParentId"), QString::fromStdString(parentId)}, {QStringLiteral("structParentConfType"), parentConfType}, {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, {QStringLiteral("pageNo"), pageNo}, {QStringLiteral("pageSize"), 5}}; const net::ApiResponse r = api_.postJson(path, body); if (!ok(r)) return {false, {}, errorOf(r, "loadRows failed")}; return {true, dto::parseDsPage(r.data), {}}; } RepoResult ApiProjectRepository::loadObjectDetail(const std::string& objectId, int confType) { const QString path = (confType == 1) ? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId)) : QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId)); const net::ApiResponse r = api_.get(path); if (!ok(r)) return {false, {}, errorOf(r, "loadObjectDetail failed")}; return {true, dto::parseDynamicForm(r.data), {}}; } RepoResult ApiProjectRepository::loadDatasetForm(const std::string& dsObjectId) { const QString path = QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId)); const net::ApiResponse r = api_.get(path); if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetForm failed")}; return {true, dto::parseDynamicForm(r.data), {}}; } RepoResult> ApiProjectRepository::loadExceptionsByTm( const std::string& tmObjectId) { const QString path = QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId)); const net::ApiResponse r = api_.get(path); if (!ok(r)) return {false, {}, errorOf(r, "loadExceptionsByTm failed")}; return {true, dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()), {}}; } ``` > 注:`queryExceptionByTmObjectId` 的数组位置(`data` 直接是数组 vs `data.value("value")`)待 live 验证(§12.3);若 `data` 本身即数组,改为 `r.data` 取数组的方式。当前按 `data.value("value")` 写(与 listWorkspaces 同约定)。 - [ ] **Step 4: 构建确认通过** Run: `cmake --build --preset release --target geopro_desktop` Expected: 编译失败 —— 因 `WorkbenchNavController.cpp` 仍调用旧 `loadTmRows`。**这是预期**,Task 5 修复。先单独验证 data 库编译: Run: `cmake --build --preset release --target geopro_data` Expected: PASS。 - [ ] **Step 5: 提交** ```bash git add src/data/repo/IProjectRepository.hpp src/data/api/ApiProjectRepository.hpp src/data/api/ApiProjectRepository.cpp git commit -m "feat(data): 仓储泛化 loadRows + 对象/数据集详情 + 按TM异常 接口实现" ``` --- ## Task 5: 控制器编排(selectObject / setCheckedTms / selectDataset) 无单测;以**编译通过 + 后续手动联调**验证。 **Files:** - Modify: `src/controller/WorkbenchNavController.hpp` - Modify: `src/controller/WorkbenchNavController.cpp` - [ ] **Step 1: 改 `WorkbenchNavController.hpp`** 头部 includes 加: ```cpp #include #include ``` 把 `public slots:` 区的 `void selectTm(const QString& tmObjectId);` 替换为: ```cpp void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情 void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树 void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单 ``` `signals:` 区在 `datasetsLoaded/filesLoaded` 之后加: ```cpp void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form); void exceptionTreeLoaded(const std::vector& groups, int tmCount); void datasetDetailLoaded(const geopro::data::DynamicForm& form); ``` `private:` 区:把 `std::string currentTmId_;` 替换为: ```cpp std::string currentParentId_; int currentParentConfType_ = 0; std::vector lastStructNodes_; // tmId→name 解析 std::map> tmExceptionCache_; ``` - [ ] **Step 2: 改 `WorkbenchNavController.cpp` — 留存结构节点** 在 `loadProjectsAndStructure()` 里,把 `emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);` **之前**加: ```cpp lastStructNodes_ = st.value; ``` 同样在 `switchProject(...)` 里 `emit structureLoaded(...)` **之前**加 `lastStructNodes_ = st.value;`。 并在这两处的「暂无项目 → 空树」分支(`emit structureLoaded(QString(), {});`)前加 `lastStructNodes_.clear();`、`tmExceptionCache_.clear();`。 - [ ] **Step 3: 改 `WorkbenchNavController.cpp` — 用 selectObject 取代 selectTm** 把整个 `WorkbenchNavController::selectTm(...)` 函数替换为: ```cpp void WorkbenchNavController::selectObject(const QString& objectId, int confType) { if (objectId.isEmpty() || busy_) return; BusyGuard guard(this, &busy_); currentParentId_ = objectId.toStdString(); currentParentConfType_ = confType; const std::string pid = currentProjectId_; dataPageNo_ = 1; filePageNo_ = 1; const auto d = repo_.loadRows(pid, currentParentId_, confType, 3, dataPageNo_); if (!d.ok) { emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); return; } dataTotal_ = d.value.total; emit datasetsLoaded(objectId, d.value.rows, d.value.total, false); const auto f = repo_.loadRows(pid, currentParentId_, confType, 1, filePageNo_); if (!f.ok) { emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); return; } fileTotal_ = f.value.total; emit filesLoaded(objectId, f.value.rows, f.value.total, false); const auto detail = repo_.loadObjectDetail(currentParentId_, confType); if (!detail.ok) { emit loadFailed(QStringLiteral("objectDetail"), QString::fromStdString(detail.error)); return; } emit objectDetailLoaded(objectId, detail.value); } ``` - [ ] **Step 4: 改 `WorkbenchNavController.cpp` — loadMoreData/Files 用泛化 parent** 把 `loadMoreData()` 里的守卫与调用: `if (currentTmId_.empty() || busy_) return;` → `if (currentParentId_.empty() || busy_) return;` `repo_.loadTmRows(currentProjectId_, currentTmId_, 3, ++dataPageNo_)` → `repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_)` `emit datasetsLoaded(QString::fromStdString(currentTmId_), ...)` → `emit datasetsLoaded(QString::fromStdString(currentParentId_), ...)` `loadMoreFiles()` 同样:`currentTmId_`→`currentParentId_`、`loadTmRows(...,1,...)`→`loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_)`、emit 的 id 改 `currentParentId_`。 - [ ] **Step 5: 改 `WorkbenchNavController.cpp` — 新增 setCheckedTms / selectDataset** `.cpp` 顶部加(若无): ```cpp #include "dto/NavDto.hpp" ``` 在文件末尾 `} // namespace geopro::controller` **之前**加: ```cpp void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { if (busy_) return; BusyGuard guard(this, &busy_); // tmId → 名称(自留存的扁平结构解析;找不到回退用 id)。 auto nameOf = [this](const std::string& id) -> std::string { for (const auto& n : lastStructNodes_) if (n.id == id) return n.name; return id; }; std::vector groups; int total = 0; for (const QString& tmQ : tmObjectIds) { const std::string tm = tmQ.toStdString(); auto it = tmExceptionCache_.find(tm); if (it == tmExceptionCache_.end()) { const auto ex = repo_.loadExceptionsByTm(tm); if (!ex.ok) { emit loadFailed(QStringLiteral("exceptions"), QString::fromStdString(ex.error)); return; } it = tmExceptionCache_.emplace(tm, ex.value).first; } const auto grouped = data::dto::groupExceptionsByConsortium(it->second); data::ObjectExceptionGroup g; g.objectId = tm; g.objectName = nameOf(tm); g.consortia = grouped.consortia; g.looseExceptions = grouped.loose; total += static_cast(it->second.size()); groups.push_back(std::move(g)); } emit exceptionTreeLoaded(groups, total); } void WorkbenchNavController::selectDataset(const QString& dsObjectId) { if (dsObjectId.isEmpty() || busy_) return; BusyGuard guard(this, &busy_); const auto form = repo_.loadDatasetForm(dsObjectId.toStdString()); if (!form.ok) { emit loadFailed(QStringLiteral("datasetDetail"), QString::fromStdString(form.error)); return; } emit datasetDetailLoaded(form.value); } ``` - [ ] **Step 6: 构建确认(仍会因 main.cpp 旧接线失败)** Run: `cmake --build --preset release --target geopro_controller` Expected: PASS(controller 库单独编译通过)。 - [ ] **Step 7: 提交** ```bash git add src/controller/WorkbenchNavController.hpp src/controller/WorkbenchNavController.cpp git commit -m "feat(controller): selectObject/setCheckedTms/selectDataset 编排 + 异常缓存" ``` --- ## Task 6: `DynamicFormView` 共享键值渲染器(新) **Files:** - Create: `src/app/panels/DynamicFormView.hpp` - Create: `src/app/panels/DynamicFormView.cpp` - Modify: `src/app/CMakeLists.txt` - [ ] **Step 1: 写 `DynamicFormView.hpp`** ```cpp #pragma once #include #include "repo/RepoTypes.hpp" class QVBoxLayout; namespace geopro::app { // 被动:渲染 DynamicForm(分组键值)。对象属性 / 数据集属性两面板共用。 class DynamicFormView : public QWidget { public: explicit DynamicFormView(QWidget* parent = nullptr); void setForm(const geopro::data::DynamicForm& form); void showMessage(const QString& message); // 空/错占位 private: void clear(); QVBoxLayout* content_ = nullptr; // 滚动区内容布局 }; } // namespace geopro::app ``` - [ ] **Step 2: 写 `DynamicFormView.cpp`** ```cpp #include "panels/DynamicFormView.hpp" #include #include #include #include #include "Theme.hpp" namespace geopro::app { DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) { auto* outer = new QVBoxLayout(this); outer->setContentsMargins(0, 0, 0, 0); outer->setSpacing(0); auto* scroll = new QScrollArea(this); scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); auto* host = new QWidget(); content_ = new QVBoxLayout(host); content_->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, geopro::app::space::kLg, geopro::app::space::kMd); content_->setSpacing(geopro::app::space::kSm); content_->addStretch(); scroll->setWidget(host); outer->addWidget(scroll); showMessage(QStringLiteral("(选中后显示属性详情)")); } void DynamicFormView::clear() { while (content_->count() > 0) { QLayoutItem* it = content_->takeAt(0); if (it->widget()) it->widget()->deleteLater(); delete it; } } void DynamicFormView::showMessage(const QString& message) { clear(); auto* hint = new QLabel(message); hint->setAlignment(Qt::AlignCenter); geopro::app::applyTokenizedStyleSheet(hint, QStringLiteral("color:{{text/disabled}}; padding:16px;")); content_->addWidget(hint); content_->addStretch(); } void DynamicFormView::setForm(const geopro::data::DynamicForm& form) { clear(); if (form.groups.empty()) { showMessage(QStringLiteral("(暂无属性)")); return; } for (const auto& group : form.groups) { auto* title = new QLabel(QString::fromStdString(group.name)); geopro::app::applyTokenizedStyleSheet( title, QStringLiteral("color:{{text/secondary}}; font-weight:%1; padding-top:6px;") .arg(geopro::app::type::kWeightSemibold)); content_->addWidget(title); auto* form_w = new QWidget(); auto* fl = new QFormLayout(form_w); fl->setContentsMargins(0, 0, 0, 0); fl->setLabelAlignment(Qt::AlignLeft | Qt::AlignTop); fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); for (const auto& f : group.fields) { auto* k = new QLabel(QString::fromStdString(f.name)); geopro::app::applyTokenizedStyleSheet(k, QStringLiteral("color:{{text/tertiary}};")); auto* v = new QLabel(QString::fromStdString(f.value)); v->setWordWrap(true); geopro::app::applyTokenizedStyleSheet(v, QStringLiteral("color:{{text/primary}};")); fl->addRow(k, v); } content_->addWidget(form_w); } content_->addStretch(); } } // namespace geopro::app ``` > 注:`Theme.hpp` 提供 `applyTokenizedStyleSheet` / `space::*` / `type::*`(已被其它面板使用,见 `ObjectTreePanel.cpp`)。若 `text/tertiary` 令牌不存在,改用 `text/secondary`(实现时按 `Theme.hpp` 实际令牌名校正)。 - [ ] **Step 3: 加入构建** `src/app/CMakeLists.txt` 的 `add_executable(geopro_desktop WIN32 ...)` 源列表里,`panels/ObjectTreePanel.cpp` 之后加一行: ```cmake panels/DynamicFormView.cpp ``` - [ ] **Step 4: 构建确认通过** Run: `cmake --preset msvc-release && cmake --build --preset release --target geopro_desktop` Expected: 仍会因 main.cpp 旧接线失败属正常;**但 `DynamicFormView.cpp` 本身不应有编译错误**(错误只应来自 main.cpp 的 selectTm/tmClicked)。确认错误仅来自 main.cpp。 - [ ] **Step 5: 提交** ```bash git add src/app/panels/DynamicFormView.hpp src/app/panels/DynamicFormView.cpp src/app/CMakeLists.txt git commit -m "feat(ui): DynamicFormView 动态表单分组键值渲染器" ``` --- ## Task 7: `ObjectExceptionPanel` 异常+异常体只读树(新) **Files:** - Create: `src/app/panels/ObjectExceptionPanel.hpp` - Create: `src/app/panels/ObjectExceptionPanel.cpp` - Modify: `src/app/CMakeLists.txt` - [ ] **Step 1: 写 `ObjectExceptionPanel.hpp`** ```cpp #pragma once #include #include #include "repo/RepoTypes.hpp" class QTreeWidget; class QLabel; namespace geopro::app { // 被动:异常 + 异常体 只读树(对象→异常体→异常→详情 + 独立异常)。数据由控制器推送。 class ObjectExceptionPanel : public QWidget { public: explicit ObjectExceptionPanel(QWidget* parent = nullptr); void setGroups(const std::vector& groups); void showMessage(const QString& message); private: QTreeWidget* tree_ = nullptr; QLabel* hint_ = nullptr; }; } // namespace geopro::app ``` - [ ] **Step 2: 写 `ObjectExceptionPanel.cpp`** ```cpp #include "panels/ObjectExceptionPanel.hpp" #include #include #include #include #include "Theme.hpp" namespace geopro::app { namespace { QTreeWidgetItem* addException(QTreeWidgetItem* parent, const geopro::data::ExceptionRow& e) { auto* item = new QTreeWidgetItem(parent); const QString type = e.typeName.empty() ? QString() : QStringLiteral("(%1)").arg(QString::fromStdString(e.typeName)); item->setText(0, QString::fromStdString(e.name) + type); // 详情展开(D6):异常下挂一个详情子项,显示已加载摘要(多行)。 if (!e.detailSummary.empty()) { auto* detail = new QTreeWidgetItem(item); detail->setText(0, QString::fromStdString(e.detailSummary)); detail->setFirstColumnSpanned(true); detail->setForeground(0, geopro::app::tokenColor("text/tertiary")); } return item; } } // namespace ObjectExceptionPanel::ObjectExceptionPanel(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); lay->setSpacing(0); tree_ = new QTreeWidget(this); tree_->setHeaderHidden(true); tree_->setIndentation(14); lay->addWidget(tree_, 1); hint_ = new QLabel(QStringLiteral("(勾选对象后显示其异常 / 异常体)"), this); hint_->setAlignment(Qt::AlignCenter); geopro::app::applyTokenizedStyleSheet(hint_, QStringLiteral("color:{{text/disabled}}; padding:16px;")); lay->addWidget(hint_); tree_->setVisible(false); } void ObjectExceptionPanel::setGroups( const std::vector& groups) { tree_->clear(); bool any = false; for (const auto& g : groups) { auto* objItem = new QTreeWidgetItem(tree_); objItem->setText(0, QString::fromStdString(g.objectName)); // 异常体分组 for (const auto& c : g.consortia) { auto* cItem = new QTreeWidgetItem(objItem); const QString ctype = c.typeName.empty() ? QString() : QStringLiteral("(%1)").arg(QString::fromStdString(c.typeName)); cItem->setText(0, QStringLiteral("异常体 %1%2") .arg(QString::fromStdString(c.name.empty() ? c.id : c.name)) .arg(ctype)); for (const auto& e : c.exceptions) { addException(cItem, e); any = true; } } // 独立异常 if (!g.looseExceptions.empty()) { auto* looseItem = new QTreeWidgetItem(objItem); looseItem->setText(0, QStringLiteral("独立异常(未合并)")); for (const auto& e : g.looseExceptions) { addException(looseItem, e); any = true; } } } if (!any) { showMessage(QStringLiteral("(所选对象暂无异常)")); return; } hint_->setVisible(false); tree_->setVisible(true); tree_->expandAll(); } void ObjectExceptionPanel::showMessage(const QString& message) { tree_->clear(); tree_->setVisible(false); hint_->setText(message); hint_->setVisible(true); } } // namespace geopro::app ``` > `tokenColor(...)` 已被 `AnomalyListPanel.cpp` 使用,来自 `Theme.hpp`。 - [ ] **Step 3: 加入构建** `src/app/CMakeLists.txt` 源列表里 `panels/DynamicFormView.cpp` 之后加: ```cmake panels/ObjectExceptionPanel.cpp ``` - [ ] **Step 4: 构建确认(仅 main.cpp 报错为正常)** Run: `cmake --preset msvc-release && cmake --build --preset release --target geopro_desktop` Expected: 错误仅来自 main.cpp 旧接线;`ObjectExceptionPanel.cpp` 自身无编译错误。 - [ ] **Step 5: 提交** ```bash git add src/app/panels/ObjectExceptionPanel.hpp src/app/panels/ObjectExceptionPanel.cpp src/app/CMakeLists.txt git commit -m "feat(ui): ObjectExceptionPanel 异常+异常体只读树(含详情展开)" ``` --- ## Task 8: `ObjectTreePanel` GS 三态勾选 + 新信号 **Files:** - Modify: `src/app/panels/ObjectTreePanel.hpp` - Modify: `src/app/panels/ObjectTreePanel.cpp` - [ ] **Step 1: 改 `ObjectTreePanel.hpp` 信号** 把 `signals:` 区两行替换为: ```cpp signals: // confType: 1=GS 2=TM。单击行(驱动数据列表 + 对象属性)。 void objectClicked(const QString& objectId, int confType); // 当前全部被勾选的 TM 叶子 id(已合并发射)。 void checkedTmsChanged(const QStringList& tmObjectIds); ``` 头部 includes 加 `#include `。 - [ ] **Step 2: 改 `ObjectTreePanel.cpp` 节点构建(GS 三态可勾选 + 存 confType)** `anonymous namespace` 内加一个 confType 角色常量,并改 `addNodes`。把现有: ```cpp constexpr int kRoleTmId = Qt::UserRole + 2; void addNodes(QTreeWidgetItem* parent, const std::vector& nodes) { for (const auto& n : nodes) { auto* item = new QTreeWidgetItem(parent); item->setText(0, QString::fromStdString(n.node.name)); if (n.isTm) { item->setData(0, kRoleTmId, QString::fromStdString(n.node.id)); item->setFlags(item->flags() | Qt::ItemIsUserCheckable); item->setCheckState(0, Qt::Unchecked); // 真实数据渲染下一轮接入,默认不勾 } addNodes(item, n.children); } } ``` 替换为: ```cpp constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 id(GS/TM 都存) constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM void addNodes(QTreeWidgetItem* parent, const std::vector& nodes) { for (const auto& n : nodes) { auto* item = new QTreeWidgetItem(parent); item->setText(0, QString::fromStdString(n.node.name)); item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); if (n.isTm) { item->setData(0, kRoleConfType, 2); item->setFlags(item->flags() | Qt::ItemIsUserCheckable); item->setCheckState(0, Qt::Unchecked); } else { item->setData(0, kRoleConfType, 1); // GS item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); item->setCheckState(0, Qt::Unchecked); } addNodes(item, n.children); } } ``` - [ ] **Step 3: 改 `ObjectTreePanel.cpp` 信号连接(单击 + 合并发射勾选叶子集)** 构造函数里把现有两个 `QObject::connect(tree_, ...)`(`itemClicked` / `itemChanged`)整段替换为: ```cpp QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) { const QString id = item->data(0, kRoleObjId).toString(); const int confType = item->data(0, kRoleConfType).toInt(); if (!id.isEmpty() && confType != 0) emit objectClicked(id, confType); }); // 勾选变化:GS 级联会触发多次 itemChanged,用 0ms 单发合并成一次「收集勾选叶子并发射」。 QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { if (checkPending_) return; checkPending_ = true; QTimer::singleShot(0, this, [this]() { checkPending_ = false; QStringList tmIds; std::function walk = [&](QTreeWidgetItem* node) { for (int i = 0; i < node->childCount(); ++i) { QTreeWidgetItem* c = node->child(i); if (c->data(0, kRoleConfType).toInt() == 2 && c->checkState(0) == Qt::Checked) tmIds << c->data(0, kRoleObjId).toString(); walk(c); } }; walk(tree_->invisibleRootItem()); emit checkedTmsChanged(tmIds); }); }); ``` 头部 includes 加 `#include ` 与 `#include `。 - [ ] **Step 4: 改 `ObjectTreePanel.hpp` 加合并标志** `private:` 区加: ```cpp bool checkPending_ = false; // 勾选合并发射防重入 ``` - [ ] **Step 5: 构建确认(仅 main.cpp 报错为正常)** Run: `cmake --build --preset release --target geopro_desktop` Expected: 错误仅来自 main.cpp(仍连 `tmClicked`/`selectTm`);`ObjectTreePanel.cpp` 自身无错误。 - [ ] **Step 6: 提交** ```bash git add src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp git commit -m "feat(ui): ObjectTreePanel GS三态勾选 + objectClicked/checkedTmsChanged 合并发射" ``` --- ## Task 9: main.cpp 接线 + 移除占位 **Files:** - Modify: `src/app/main.cpp` - [ ] **Step 1: 头文件** `main.cpp` 顶部 includes 区,`#include "panels/DatasetListPanel.hpp"` 之后加: ```cpp #include "panels/DynamicFormView.hpp" #include "panels/ObjectExceptionPanel.hpp" ``` - [ ] **Step 2: 右上面板:异常列表 → ObjectExceptionPanel;对象属性 → DynamicFormView** 找到右上 dock 构建处(`auto* anomalyList = new QListWidget();` 起,到 `auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock);`)。把: ```cpp auto* anomalyList = new QListWidget(); geopro::app::applyAnomalyCardDelegate(anomalyList); auto* objAttrLabel = new QLabel(QStringLiteral("(选中对象后显示其属性)")); objAttrLabel->setWordWrap(true); objAttrLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); objAttrLabel->setMargin(8); auto anomalyPanel = geopro::app::buildTabbedPanel( {{geopro::app::Glyph::Anomaly, QStringLiteral("异常"), anomalyList, true}, {geopro::app::Glyph::Property, QStringLiteral("对象属性"), objAttrLabel, false}}, {{geopro::app::Glyph::Filter, QStringLiteral("筛选")}, {geopro::app::Glyph::Plus, QStringLiteral("添加异常")}}); auto* anomalyBadge = anomalyPanel.badges.value(0); // 异常列表 Tab 的数量徽标 ``` 替换为: ```cpp auto* exceptionPanel = new geopro::app::ObjectExceptionPanel(); auto* objAttrView = new geopro::app::DynamicFormView(); auto anomalyPanel = geopro::app::buildTabbedPanel( {{geopro::app::Glyph::Anomaly, QStringLiteral("对象异常"), exceptionPanel, true}, {geopro::app::Glyph::Property, QStringLiteral("对象属性"), objAttrView, false}}, {{geopro::app::Glyph::Filter, QStringLiteral("筛选")}, {geopro::app::Glyph::Plus, QStringLiteral("添加异常")}}); auto* anomalyBadge = anomalyPanel.badges.value(0); ``` > 紧随其后那段对 `anomalyBadge` 改 `objectName` + unpolish/polish 的代码**保留不动**(仍对徽标着色)。 - [ ] **Step 3: 右下面板:数据集属性 QLabel → DynamicFormView** 找到: ```cpp auto* propLabel = new QLabel(QStringLiteral("(单击左侧数据集查看属性与平面剖面)")); propLabel->setWordWrap(true); propLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); propLabel->setMargin(8); auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性")); propDock->setWidget( wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propLabel)); ``` 替换为: ```cpp auto* propView = new geopro::app::DynamicFormView(); auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性")); propDock->setWidget( wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propView)); ``` - [ ] **Step 4: 删除已停用的 loadDataset/旧异常列表接线** 删除 `loadDataset` lambda 整段(从 `auto loadDataset = [&repo, propLabel, currentDsId, ...` 到其结束 `};` 以及紧随的 `(void)loadDataset;` 与上方相关 TODO 注释)。 删除「异常列表勾选(显隐)」整段:`QObject::connect(anomalyList, &QListWidget::itemChanged, ...)`。 > 这些都引用了被移除的 `anomalyList`/`propLabel`/`anomalyBadge` 旧用法或已 park 代码。`rebuildDetail`/`currentDsId` 等中央/详情渲染相关保留(仍被主题切换连接使用)。 - [ ] **Step 5: 数据集单击 → selectDataset(替换占位文案)** 把现有数据集单击连接: ```cpp QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, [propLabel, detailRendererPtr, detailRenderWindowPtr, &nav](QListWidgetItem* item) { if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { nav.loadMoreData(); return; } const QString name = item->data(Qt::DisplayRole).toString().section('\n', 0, 0); detailRendererPtr->RemoveAllViewProps(); detailRenderWindowPtr->Render(); propLabel->setText(QStringLiteral( "数据集: %1\n(该数据集的剖面/反演渲染将在下一阶段接入 dd 接口)").arg(name)); }); ``` 替换为: ```cpp QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, [&nav](QListWidgetItem* item) { if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { nav.loadMoreData(); return; } const QString dsId = item->data(geopro::app::kDsIdRole).toString(); if (!dsId.isEmpty()) nav.selectDataset(dsId); }); ``` - [ ] **Step 6: 对象树信号接线(objectClicked / checkedTmsChanged)** 把: ```cpp QObject::connect(objectTree, &geopro::app::ObjectTreePanel::tmClicked, &nav, &geopro::controller::WorkbenchNavController::selectTm); ``` 替换为: ```cpp QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectClicked, &nav, &geopro::controller::WorkbenchNavController::selectObject); QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &nav, &geopro::controller::WorkbenchNavController::setCheckedTms); ``` - [ ] **Step 7: 控制器新信号 → 面板** 在控制器↔UI 接线区(`structureLoaded` 连接附近)加: ```cpp QObject::connect(&nav, &geopro::controller::WorkbenchNavController::objectDetailLoaded, objAttrView, [objAttrView](const QString&, const geopro::data::DynamicForm& form) { objAttrView->setForm(form); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::exceptionTreeLoaded, exceptionPanel, [exceptionPanel, anomalyBadge]( const std::vector& groups, int total) { exceptionPanel->setGroups(groups); if (anomalyBadge) { anomalyBadge->setText(QString::number(total)); anomalyBadge->setVisible(total > 0); } }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetDetailLoaded, propView, [propView](const geopro::data::DynamicForm& form) { propView->setForm(form); }); ``` - [ ] **Step 8: 切项目/空间清空三面板** 在 `structureLoaded` 的现有 lambda(`objectTree->setStructure(...)` 那个)里,捕获列表加 `exceptionPanel, objAttrView, propView, anomalyBadge`,并在 `datasetList->clear(); fileList->clear();` 之后加: ```cpp exceptionPanel->showMessage(QStringLiteral("(勾选对象后显示其异常 / 异常体)")); objAttrView->showMessage(QStringLiteral("(选中对象后显示其属性)")); propView->showMessage(QStringLiteral("(单击数据集查看属性)")); if (anomalyBadge) anomalyBadge->setVisible(false); ``` - [ ] **Step 9: 构建确认通过** Run: `cmake --build --preset release --target geopro_desktop` Expected: PASS(无编译错误)。若报 `propLabel`/`anomalyList`/`loadDataset` 未定义残留引用,按 Step 4 清干净。 - [ ] **Step 10: 全量测试 + 提交** Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release --output-on-failure` Expected: 全绿。 ```bash git add src/app/main.cpp git commit -m "feat(ui): 接线 对象单击/勾选/数据集单击 → 三面板(移除占位)" ``` --- ## Task 10: Live 联调验证(§12 验证点)+ 修正 无单测;手动运行桌面程序验证。**逐项验证,发现不符按指引改一处。** - [ ] **Step 1: 运行程序,登录进入工作台** Run: 启动 `build/release` 下的 `geopro_desktop.exe`(或在 IDE 运行)。登录后确认对象树加载真实结构。 - [ ] **Step 2: 验证「单击对象」** 单击一个 TM:左下数据/文件列表出现该 TM 的 DS;右上「对象属性」出现动态表单分组键值。 单击一个 GS:左下出现该 GS 下 DS。 - 若 GS 单击左下为空 → §12.1:`structParentConfType=1` 不被支持。改 `ApiProjectRepository::loadRows`:当 `parentConfType==1` 时,改为对该 GS 直接子 TM 逐个查 `data/page` 合并(需控制器传子 TM 列表,或在仓储内先 `loadStructure` 取子 TM)。**先记录现象,必要时回到 Task 4/5 调整。** - [ ] **Step 3: 验证「对象属性 / 数据集属性」键值与显示名** 确认字段显示名为中文、值正确。 - 若值全空 → §12.2:`properties` 非按 `fieldCode` 索引。用浏览器/抓包看 `getDetail` 真实返回,定位真实索引键,改 `parseDynamicForm` 里 `props.value(code)` 的 `code` 来源(仅这一处)。重跑 Task 1 单测(按真实键调整测试 JSON)。 - [ ] **Step 4: 验证「勾选 → 异常 + 异常体树」** 勾选 TM/GS:右上「对象异常」出现「对象→异常体→异常→详情 / 独立异常」树;徽标显示总数;取消勾选对应异常消失。 - 若异常体层始终为空(全进独立异常)→ §12.3:异常 payload 不带 `consortiumId/Name/Type`。两种修法择一: - (a) 若 payload 用别的字段名(如 `parentId`+`parentConfType`),改 `parseExceptions` 三个 `str(o,"consortium*")` 的键名;重跑 Task 2 单测(按真实键调整)。 - (b) 若需项目级映射,在 `ApiProjectRepository` 增 `getExceptionConsortiumTree/{projectId}` 拉取并在控制器装配时套用映射(回退方案,§9)。 - [ ] **Step 5: 验证异常数组位置** 若 Step 4 异常完全为空且无错 → 检查 `loadExceptionsByTm` 的数组取法:把 `r.data.value("value").toArray()` 改为 `r.data` 直接当数组(部分接口 `data` 即数组)。按真实返回定。 - [ ] **Step 6: 回归 + 收尾提交** Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release --output-on-failure` Expected: 全绿。 ```bash git add -A git commit -m "fix(data): 按 live 联调校正 GS数据/属性键/异常体归属 映射" ``` > 若本步无改动则跳过提交。 --- ## 自审清单(写计划后核对,已逐条检查) - **Spec 覆盖**:D1(真实API)=Task1-5;D2(GS三态)=Task8;D3(单击高亮)=Task8 itemClicked + Task9 objectDetail;D4(动态表单)=Task1+6;D5(异常体只读树)=Task2-3+7;D6(详情展开做/眼睛推迟)=Task2 detailSummary + Task7 详情子项。三件交互=Task9 接线。验证点=Task10。 - **占位扫描**:无 TBD/TODO 式步骤;每个代码步给出完整代码。 - **类型一致**:`loadRows`(非 loadTmRows)、`objectClicked`/`checkedTmsChanged`、`selectObject`/`setCheckedTms`/`selectDataset`、`exceptionTreeLoaded`、`DynamicForm`/`ObjectExceptionGroup`/`ExceptionRow` 各任务间签名一致;`kDsIdRole` 取自 `DatasetListPanel.hpp`(已有)。 - **保留不删**:`AnomalyListPanel`(populateAnomalyList/眼睛/VTK)、`render/*`、`LocalSampleRepository`、`rebuildDetail` 均保留。