From 37300d523e49d8fbf92e1e98a4965ca6f5571fff Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 19:48:22 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=E5=AF=B9=E8=B1=A1=E5=8D=95?= =?UTF-8?q?=E5=87=BB/=E5=8B=BE=E9=80=89=E9=A9=B1=E5=8A=A8=E4=B8=89?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= =?UTF-8?q?=EF=BC=88TDD=EF=BC=8C10=20=E4=BB=BB=E5=8A=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-10-object-selection-panels.md | 1354 +++++++++++++++++ 1 file changed, 1354 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-object-selection-panels.md diff --git a/docs/superpowers/plans/2026-06-10-object-selection-panels.md b/docs/superpowers/plans/2026-06-10-object-selection-panels.md new file mode 100644 index 0000000..9bbc284 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-object-selection-panels.md @@ -0,0 +1,1354 @@ +# 对象单击/勾选 驱动 数据列表·异常(含异常体)·属性 面板 实现计划 + +> **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` 均保留。