54 KiB
对象单击/勾选 驱动 数据列表·异常(含异常体)·属性 面板 实现计划
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、GroupedExceptionssrc/data/dto/NavDto.hpp/.cpp(改)— 新增parseDynamicForm/parseExceptions/groupExceptionsByConsortiumsrc/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 之后加:
// 动态表单(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 { 内加:
// 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; 已在文件顶部)加:
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 { 内加:
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: 提交
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 之后加:
// 异常(树叶,本轮只读)。consortium* 空 = 独立异常;detailSummary = 详情展开内联显示。
struct ExceptionRow {
std::string id, name, typeName, createTime;
std::string consortiumId, consortiumName, consortiumType;
std::string detailSummary;
};
- Step 2: 在
NavDto.hpp声明parseExceptions
// 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 末尾加:
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>(若无则加)。加:
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: 提交
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 之后加:
// 异常体分组(树中间层)+ 对象分组(树根层,对应一个被勾选 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
// 把一个对象(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>):
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>。加:
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: 提交
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 纯虚声明整段替换为:
// 按结构父节点分页拉数据/文件行: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: 区):
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):
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直接是数组 vsdata.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: 提交
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 加:
#include <map>
#include <QStringList>
把 public slots: 区的 void selectTm(const QString& tmObjectId); 替换为:
void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情
void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树
void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单
signals: 区在 datasetsLoaded/filesLoaded 之后加:
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_; 替换为:
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); 之前加:
lastStructNodes_ = st.value;
同样在 switchProject(...) 里 emit structureLoaded(...) 之前加 lastStructNodes_ = st.value;。
并在这两处的「暂无项目 → 空树」分支(emit structureLoaded(QString(), {});)前加 lastStructNodes_.clear();、tmExceptionCache_.clear();。
- Step 3: 改
WorkbenchNavController.cpp— 用 selectObject 取代 selectTm
把整个 WorkbenchNavController::selectTm(...) 函数替换为:
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 顶部加(若无):
#include "dto/NavDto.hpp"
在文件末尾 } // namespace geopro::controller 之前加:
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: 提交
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
#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
#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 之后加一行:
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: 提交
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
#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
#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 之后加:
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: 提交
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: 区两行替换为:
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。把现有:
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);
}
}
替换为:
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)整段替换为:
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: 区加:
bool checkPending_ = false; // 勾选合并发射防重入
- Step 5: 构建确认(仅 main.cpp 报错为正常)
Run: cmake --build --preset release --target geopro_desktop
Expected: 错误仅来自 main.cpp(仍连 tmClicked/selectTm);ObjectTreePanel.cpp 自身无错误。
- Step 6: 提交
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" 之后加:
#include "panels/DynamicFormView.hpp"
#include "panels/ObjectExceptionPanel.hpp"
- Step 2: 右上面板:异常列表 → ObjectExceptionPanel;对象属性 → DynamicFormView
找到右上 dock 构建处(auto* anomalyList = new QListWidget(); 起,到 auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock);)。把:
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 的数量徽标
替换为:
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
找到:
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));
替换为:
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(替换占位文案)
把现有数据集单击连接:
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));
});
替换为:
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)
把:
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::tmClicked, &nav,
&geopro::controller::WorkbenchNavController::selectTm);
替换为:
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 连接附近)加:
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(); 之后加:
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: 全绿。
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)。
- (a) 若 payload 用别的字段名(如
-
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: 全绿。
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均保留。