geopro/docs/superpowers/plans/2026-06-10-object-selection...

1355 lines
54 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 对象单击/勾选 驱动 数据列表·异常(含异常体)·属性 面板 实现计划
> **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。数据层新增纯函数 DTOTDD 单测)+ 仓储方法;控制器新增 slots/signals 编排UI 新增两个被动面板(`DynamicFormView`、`ObjectExceptionPanel`)并改造 `ObjectTreePanel` 为三态勾选。中央/详情 VTK 渲染本轮不动。
**Tech Stack:** C++17、Qt6 Widgets、GoogleTest/CTest、CMakepreset `msvc-release`)、现有 `ApiClient`(同步 HTTPtoken 已注入)。
**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<DynamicFormField> fields; };
struct DynamicForm { std::string name; std::vector<DynamicFormGroup> 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 <algorithm>`(若无则加)。在 `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<QJsonObject> gv;
gv.reserve(static_cast<size_t>(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<QJsonObject> fv;
fv.reserve(static_cast<size_t>(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、
// createTimeconsortium* 取自 consortiumId/consortiumName/consortiumType来源待 live 验证§12.3
// detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。
std::vector<ExceptionRow> 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 <QString>``<limits>`(若无则加)。加:
```cpp
namespace {
// elevationList 极值拼 "高程 min~max m";空返回空串。
std::string elevationSummary(const QJsonArray& el) {
if (el.isEmpty()) return {};
double lo = std::numeric_limits<double>::max(), hi = -std::numeric_limits<double>::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<ExceptionRow> parseExceptions(const QJsonArray& arr) {
std::vector<ExceptionRow> out;
out.reserve(static_cast<size_t>(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 <QStringList>`(若未含)。
- [ ] **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<ExceptionRow> exceptions; };
struct ObjectExceptionGroup {
std::string objectId, objectName;
std::vector<ConsortiumGroup> consortia;
std::vector<ExceptionRow> looseExceptions;
};
struct GroupedExceptions { std::vector<ConsortiumGroup> consortia; std::vector<ExceptionRow> loose; };
```
- [ ] **Step 2: 在 `NavDto.hpp` 声明 `groupExceptionsByConsortium`**
```cpp
// 把一个对象(TM)的异常行按 consortiumId 分组:同 id 归一组(组名/类型取首个非空);
// consortiumId 空 → loose。保持首次出现顺序稳定。纯函数、可单测。
GroupedExceptions groupExceptionsByConsortium(const std::vector<ExceptionRow>& rows);
```
- [ ] **Step 3: 写失败测试**
`tests/data/test_nav_dto.cpp` 末尾加(顶部已有 `#include <vector>`?若无则在文件顶部 includes 处加 `#include <vector>`
```cpp
TEST(NavDto, GroupExceptionsByConsortiumSplitsLooseAndGroups) {
std::vector<ExceptionRow> 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<ExceptionRow> 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 <unordered_map>`。加:
```cpp
GroupedExceptions groupExceptionsByConsortium(const std::vector<ExceptionRow>& rows) {
GroupedExceptions out;
std::unordered_map<std::string, size_t> 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=TMclassifyType 3=数据 1=文件;
// pageNo 从 1 起pageSize 固定 5。
virtual RepoResult<DsPage> 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<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) = 0;
// 数据集详情dsObject/dynamicForm/{dsObjectId} → 动态表单。
virtual RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) = 0;
// 单 TM 异常列表(含异常体归属字段)。
virtual RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) = 0;
```
- [ ] **Step 2: 改 `ApiProjectRepository.hpp`:同步声明**
`loadTmRows``override` 声明整段替换为(保持类内 `public:` 区):
```cpp
RepoResult<DsPage> loadRows(const std::string& projectId, const std::string& parentId,
int parentConfType, int classifyType, int pageNo) override;
RepoResult<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) override;
RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) override;
RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) override;
```
- [ ] **Step 3: 改 `ApiProjectRepository.cpp`:实现**
把现有 `ApiProjectRepository::loadTmRows(...)` 整个函数体替换为下面 4 个函数(`loadRows` 即原 `loadTmRows``structParentConfType` 从写死 2 改为入参 `parentConfType`
```cpp
RepoResult<DsPage> 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<DynamicForm> 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<DynamicForm> 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<std::vector<ExceptionRow>> 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 <map>
#include <QStringList>
```
`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<geopro::data::ObjectExceptionGroup>& groups, int tmCount);
void datasetDetailLoaded(const geopro::data::DynamicForm& form);
```
`private:` 区:把 `std::string currentTmId_;` 替换为:
```cpp
std::string currentParentId_;
int currentParentConfType_ = 0;
std::vector<data::StructNode> lastStructNodes_; // tmId→name 解析
std::map<std::string, std::vector<data::ExceptionRow>> 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<data::ObjectExceptionGroup> 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<int>(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: PASScontroller 库单独编译通过)。
- [ ] **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 <QWidget>
#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 <QFormLayout>
#include <QLabel>
#include <QScrollArea>
#include <QVBoxLayout>
#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 <QWidget>
#include <vector>
#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<geopro::data::ObjectExceptionGroup>& 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 <QLabel>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QVBoxLayout>
#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<geopro::data::ObjectExceptionGroup>& 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 <QStringList>`
- [ ] **Step 2: 改 `ObjectTreePanel.cpp` 节点构建GS 三态可勾选 + 存 confType**
`anonymous namespace` 内加一个 confType 角色常量,并改 `addNodes`。把现有:
```cpp
constexpr int kRoleTmId = Qt::UserRole + 2;
void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& 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; // 节点对象 idGS/TM 都存)
constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM
void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& 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<void(QTreeWidgetItem*)> 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 <QTimer>``#include <functional>`
- [ ] **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<geopro::data::ObjectExceptionGroup>& 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-5D2(GS三态)=Task8D3(单击高亮)=Task8 itemClicked + Task9 objectDetailD4(动态表单)=Task1+6D5(异常体只读树)=Task2-3+7D6(详情展开做/眼睛推迟)=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` 均保留。