1355 lines
54 KiB
Markdown
1355 lines
54 KiB
Markdown
# 对象单击/勾选 驱动 数据列表·异常(含异常体)·属性 面板 实现计划
|
||
|
||
> **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<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、
|
||
// createTime;consortium* 取自 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=TM;classifyType 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: 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 <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; // 节点对象 id(GS/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-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` 均保留。
|