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

54 KiB
Raw Blame History

对象单击/勾选 驱动 数据列表·异常(含异常体)·属性 面板 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 让单击对象加载其 DS 列表与对象属性、勾选对象聚合显示「异常+异常体」只读树、单击数据集显示数据集属性,全部接真实业务 API。

Architecture: 沿用四层net→data→controller→app。数据层新增纯函数 DTOTDD 单测)+ 仓储方法;控制器新增 slots/signals 编排UI 新增两个被动面板(DynamicFormViewObjectExceptionPanel)并改造 ObjectTreePanel 为三态勾选。中央/详情 VTK 渲染本轮不动。

Tech Stack: C++17、Qt6 Widgets、GoogleTest/CTest、CMakepreset msvc-release)、现有 ApiClient(同步 HTTPtoken 已注入)。

Spec: docs/superpowers/specs/2026-06-10-object-selection-panels-design.md


构建 / 测试命令(全程通用)

  • 配置(仅首次或改 CMakeLists 后):cmake --preset msvc-release
  • 构建测试目标:cmake --build --preset release --target geopro_tests
  • 构建桌面程序:cmake --build --preset release --target geopro_desktop
  • 跑 DTO 单测:ctest --test-dir build/release -C Release -R NavDto --output-on-failure

MSVC 多配置:ctest 必须带 -C Release。新增 test 源文件后需重新 cmake --preset msvc-release 再构建。


文件结构

数据/模型层

  • src/data/repo/RepoTypes.hpp(改)— 新增 DynamicFormField/Group/DynamicFormExceptionRowConsortiumGroupObjectExceptionGroupGroupedExceptions
  • src/data/dto/NavDto.hpp/.cpp(改)— 新增 parseDynamicForm / parseExceptions / groupExceptionsByConsortium
  • src/data/repo/IProjectRepository.hpp(改)— loadTmRowsloadRows 泛化 + 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: 模型 + parseDynamicFormTDD

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.hppnamespace 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: PASSParseDynamicForm* 两条)。

  • 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: parseExceptionsTDD

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.hppExceptionRow

紧接 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、
//   createTimeconsortium* 取自 consortiumId/consortiumName/consortiumType来源待 live 验证§12.3
//   detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。
std::vector<ExceptionRow> parseExceptions(const QJsonArray& arr);
  • Step 3: 写失败测试

tests/data/test_nav_dto.cpp 末尾加:

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: PASSParseExceptionsMapsFieldsAndSummary)。

  • 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: groupExceptionsByConsortiumTDD

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 之后加:

// 异常体分组(树中间层)+ 对象分组(树根层,对应一个被勾选 TMstruct 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: PASSGroupExceptions* 两条)。

  • 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.hpploadTmRowsloadRows + 3 新方法

把现有 loadTmRows 纯虚声明整段替换为:

    // 按结构父节点分页拉数据/文件行parentConfType 1=GS 2=TMclassifyType 3=数据 1=文件;
    //   pageNo 从 1 起pageSize 固定 5。
    virtual RepoResult<DsPage> loadRows(const std::string& projectId, const std::string& parentId,
                                        int parentConfType, int classifyType, int pageNo) = 0;
    // 对象详情confType 1=GS(getGsObjectDetail) 2=TM(tmObject/getDetail) → 动态表单。
    virtual RepoResult<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) = 0;
    // 数据集详情dsObject/dynamicForm/{dsObjectId} → 动态表单。
    virtual RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) = 0;
    // 单 TM 异常列表(含异常体归属字段)。
    virtual RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) = 0;
  • Step 2: 改 ApiProjectRepository.hpp:同步声明

loadTmRowsoverride 声明整段替换为(保持类内 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 即原 loadTmRowsstructParentConfType 从写死 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 直接是数组 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: 提交
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: PASScontroller 库单独编译通过)。

  • 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.txtadd_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;     // 节点对象 idGS/TM 都存)
constexpr int kRoleConfType = Qt::UserRole + 3;  // 1=GS 2=TM

void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes) {
    for (const auto& n : nodes) {
        auto* item = new QTreeWidgetItem(parent);
        item->setText(0, QString::fromStdString(n.node.name));
        item->setData(0, kRoleObjId, QString::fromStdString(n.node.id));
        if (n.isTm) {
            item->setData(0, kRoleConfType, 2);
            item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
            item->setCheckState(0, Qt::Unchecked);
        } else {
            item->setData(0, kRoleConfType, 1);  // GS
            item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate);
            item->setCheckState(0, Qt::Unchecked);
        }
        addNodes(item, n.children);
    }
}
  • Step 3: 改 ObjectTreePanel.cpp 信号连接(单击 + 合并发射勾选叶子集)

构造函数里把现有两个 QObject::connect(tree_, ...)itemClicked / itemChanged)整段替换为:

    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/selectTmObjectTreePanel.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);

紧随其后那段对 anomalyBadgeobjectName + 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 的现有 lambdaobjectTree->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.1structParentConfType=1 不被支持。改 ApiProjectRepository::loadRows:当 parentConfType==1 时,改为对该 GS 直接子 TM 逐个查 data/page 合并(需控制器传子 TM 列表,或在仓储内先 loadStructure 取子 TM先记录现象,必要时回到 Task 4/5 调整。

  • Step 3: 验证「对象属性 / 数据集属性」键值与显示名

确认字段显示名为中文、值正确。

  • 若值全空 → §12.2properties 非按 fieldCode 索引。用浏览器/抓包看 getDetail 真实返回,定位真实索引键,改 parseDynamicFormprops.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) 若需项目级映射,在 ApiProjectRepositorygetExceptionConsortiumTree/{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: 全绿。

git add -A
git commit -m "fix(data): 按 live 联调校正 GS数据/属性键/异常体归属 映射"

若本步无改动则跳过提交。


自审清单(写计划后核对,已逐条检查)

  • Spec 覆盖D1(真实API)=Task1-5D2(GS三态)=Task8D3(单击高亮)=Task8 itemClicked + Task9 objectDetailD4(动态表单)=Task1+6D5(异常体只读树)=Task2-3+7D6(详情展开做/眼睛推迟)=Task2 detailSummary + Task7 详情子项。三件交互=Task9 接线。验证点=Task10。
  • 占位扫描:无 TBD/TODO 式步骤;每个代码步给出完整代码。
  • 类型一致loadRows(非 loadTmRows)、objectClicked/checkedTmsChangedselectObject/setCheckedTms/selectDatasetexceptionTreeLoadedDynamicForm/ObjectExceptionGroup/ExceptionRow 各任务间签名一致;kDsIdRole 取自 DatasetListPanel.hpp(已有)。
  • 保留不删AnomalyListPanel(populateAnomalyList/眼睛/VTK)、render/*LocalSampleRepositoryrebuildDetail 均保留。