geopro/docs/superpowers/plans/2026-06-09-real-api-navigat...

64 KiB
Raw Blame History

接入真实导航(工作空间 / 项目 / 对象树Implementation Plan

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: 把顶部工作空间/项目切换与对象树接到真实 pop-api 接口(中央渲染暂占位),按 接口/数据/逻辑/UI 四层落位。

Architecture: 复用现有 net::ApiClient同步、共享会话、token 已注入)。新增 data 层仓储接口 IProjectRepository + 实现 ApiProjectRepository + 纯映射 dto/NavDto(含扁平→树,可单测);新增 controllerWorkbenchNavControllerQObject 状态机,信号驱动 UI改造 appTopBar(数据驱动类)+ 新增 ObjectTreePanel被动视图。依赖单向向下UI 不直接碰 ApiClient。

Tech Stack: C++17, Qt6 (Core/Widgets/Network, QtJSON), GoogleTest + CTest, CMake + Ninjapreset msvc-release,构建目录 build/release)。

参考 spec docs/superpowers/specs/2026-06-09-real-api-navigation-design.md


关键事实(实现前必读)

  • 成功判定ApiResponse.code == 200(见 AuthService.cppkCodeSuccess)。传输错误时 code==0rawError 有值。
  • 数组型 dataApiClient::parseBody 把非对象的 data(顶层数组/标量)包成 {"value": <data>}
    • 数组型接口(joined/listqueryDsByTmObjectId)→ 读 resp.data.value("value").toArray()
    • 对象型接口(queryByUser{hasNextPage,projectList}queryProjectStruct{projectStructList,...})→ 直接读对象字段。
  • 基址ApiClient 已设 http://tenant.geomative.cn/pop-api,路径以 /business/... 开头。
  • 层级:真实结构是 项目 → GS → TM → DS,结构接口只回 GS+TM 扁平列表(parentIdTM = 叶子节点DS 按 TM 单拉。
  • 构建/测试命令Windows PowerShell 或 bash 均可,路径用正斜杠):
    • 配置:cmake --preset msvc-release
    • 构建测试:cmake --build build/release --target geopro_tests
    • 跑测试:ctest --test-dir build/release -R NavDto --output-on-failure
    • 构建主程序:cmake --build build/release --target geopro_desktop

文件结构(本计划新增/改造)

新增

  • src/data/repo/IProjectRepository.hpp — 导航仓储抽象 + RepoResult<T>
  • src/data/dto/NavDto.hpp / src/data/dto/NavDto.cpp — JSON→模型纯映射 + buildStructTree
  • src/data/api/ApiProjectRepository.hpp / .cpp — 用 ApiClient 实现 IProjectRepository
  • src/controller/CMakeLists.txtgeopro_controller 静态库AUTOMOC ON
  • src/controller/WorkbenchNavController.hpp / .cpp — 导航状态机
  • src/app/panels/ObjectTreePanel.hpp / .cpp — 被动对象树视图
  • src/app/CentralScene.hpp / .cpp — 中央三维编排的数据驱动 helper脱离对象树下一轮接真实 DS 复用)
  • tests/data/test_nav_dto.cpp — DTO + 树构建单测

改造

  • src/data/repo/RepoTypes.hpp — 追加 Workspace / ProjectSummary / StructNode
  • src/data/CMakeLists.txt — 加源文件 + 链接 geopro_net
  • src/CMakeLists.txtadd_subdirectory(controller)(在 app 之前)
  • src/app/CMakeLists.txt — 加 panels/ObjectTreePanel.cpp + 链接 geopro_controller
  • src/app/TopBar.hpp / .cpp — 由自由函数升级为 TopBar 数据驱动类
  • src/app/main.cpp — 构造仓储/控制器、接线信号、移除启动 demo 渲染、DS 点击占位
  • tests/CMakeLists.txt — 加 data/test_nav_dto.cpp

保留不删LocalSampleRepositoryrender/*、现有详情/中央渲染代码。


Task 1: 导航模型 + 仓储接口(纯声明)

Files:

  • Modify: src/data/repo/RepoTypes.hpp
  • Create: src/data/repo/IProjectRepository.hpp

纯数据结构与抽象接口,无行为,无单测;以"能编译"为验收。

  • Step 1: 追加导航模型到 RepoTypes.hpp

把以下三个结构体加入 namespace geopro::data {}(放在 Project 之后、命名空间右括号之前):

// 工作空间(=企业租户/空间。ownerType: 1 个人空间 2 企业空间。
struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; };

// 项目摘要列表用。crsCode/crsName 为项目参考坐标系,下一轮替换硬编码 EPSG:4547。
struct ProjectSummary { std::string id, name, typeName, crsCode, crsName; int status = 0; };

// 项目结构扁平节点(仅 GS / TM。客户端按 parentId 建树,叶子=TM。
struct StructNode { std::string id, name, parentId, typeName, confCode; int type = 0; };
  • Step 2: 创建 IProjectRepository.hpp
#pragma once
#include <string>
#include <vector>
#include "repo/RepoTypes.hpp"

namespace geopro::data {

// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。
template <class T>
struct RepoResult {
    bool ok = false;
    T value{};
    std::string error;
};

// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。
class IProjectRepository {
public:
    virtual ~IProjectRepository() = default;
    virtual RepoResult<std::vector<Workspace>>      listWorkspaces() = 0;
    virtual RepoResult<bool>                         switchWorkspace(const std::string& tenantId) = 0;
    virtual RepoResult<std::vector<ProjectSummary>> listProjects(const std::string& lastProjectId) = 0;
    virtual RepoResult<std::vector<StructNode>>      loadStructure(const std::string& projectId) = 0;
    virtual RepoResult<std::vector<DsNode>>          loadDatasetsOfTm(const std::string& tmObjectId) = 0;
};

}  // namespace geopro::data
  • Step 3: 验证编译(语法)

Run: cmake --build build/release --target geopro_data Expected: 通过(仅头文件改动,geopro_data 仍按原样编译;新头未被引用属正常)。

  • Step 4: Commit
git add src/data/repo/RepoTypes.hpp src/data/repo/IProjectRepository.hpp
git commit -m "feat(data): 导航模型(Workspace/ProjectSummary/StructNode) + IProjectRepository 接口"

Task 2: NavDto 脚手架 + parseWorkspacesTDD打通测试构建

Files:

  • Create: src/data/dto/NavDto.hpp, src/data/dto/NavDto.cpp

  • Create: tests/data/test_nav_dto.cpp

  • Modify: src/data/CMakeLists.txt, tests/CMakeLists.txt

  • Step 1: 写失败测试 tests/data/test_nav_dto.cpp

#include <gtest/gtest.h>
#include <QByteArray>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>

#include "dto/NavDto.hpp"

using namespace geopro::data;

namespace {
QJsonArray arrOf(const char* json) {
    return QJsonDocument::fromJson(QByteArray(json)).array();
}
}  // namespace

TEST(NavDto, ParseWorkspacesMapsFieldsAndCurrentFlag) {
    const auto arr = arrOf(R"([
      {"id":"t1","name":"个人空间","ownerType":1,"isCurTenant":1},
      {"id":"t2","name":"企业A","ownerType":2,"isCurTenant":0}
    ])");
    const auto ws = dto::parseWorkspaces(arr);
    ASSERT_EQ(ws.size(), 2u);
    EXPECT_EQ(ws[0].id, "t1");
    EXPECT_EQ(ws[0].ownerType, 1);
    EXPECT_TRUE(ws[0].isCurrent);
    EXPECT_FALSE(ws[1].isCurrent);
}
  • Step 2: 创建 src/data/dto/NavDto.hpp(声明全部函数,便于后续任务复用)
#pragma once
#include <vector>
#include <QJsonArray>
#include <QJsonObject>
#include "repo/RepoTypes.hpp"

namespace geopro::data::dto {

// 工作空间数组joined/list 的 data["value"])→ 模型。isCurTenant==1 → isCurrent。
std::vector<Workspace> parseWorkspaces(const QJsonArray& arr);

// 项目分页queryByUser 的 data 对象 {hasNextPage, projectList})→ 模型。
struct ProjectPage { std::vector<ProjectSummary> projects; bool hasNextPage = false; };
ProjectPage parseProjects(const QJsonObject& data);

// 结构扁平节点数组queryProjectStruct 的 data["projectStructList"])→ 模型。
std::vector<StructNode> parseStructNodes(const QJsonArray& arr);

// DS 聚合数组queryDsByTmObjectId 的 data["value"])→ DsNode。ddCode → ddType。
std::vector<DsNode> parseDatasets(const QJsonArray& arr);

// 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理项目直挂 TM、孤儿 parentId、空表。
struct StructTreeNode {
    StructNode node;
    bool isTm = false;
    std::vector<StructTreeNode> children;
};
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat);

}  // namespace geopro::data::dto
  • Step 3: 创建 src/data/dto/NavDto.cpp,仅实现 parseWorkspaces其余空桩后续任务填
#include "dto/NavDto.hpp"

#include <QJsonValue>

namespace geopro::data::dto {

namespace {
std::string str(const QJsonObject& o, const char* key) {
    return o.value(QString::fromLatin1(key)).toString().toStdString();
}
}  // namespace

std::vector<Workspace> parseWorkspaces(const QJsonArray& arr) {
    std::vector<Workspace> out;
    out.reserve(static_cast<size_t>(arr.size()));
    for (const QJsonValue& v : arr) {
        const QJsonObject o = v.toObject();
        Workspace w;
        w.id = str(o, "id");
        w.name = str(o, "name");
        w.ownerType = o.value(QStringLiteral("ownerType")).toInt();
        w.isCurrent = o.value(QStringLiteral("isCurTenant")).toInt() == 1;
        out.push_back(std::move(w));
    }
    return out;
}

ProjectPage parseProjects(const QJsonObject&) { return {}; }
std::vector<StructNode> parseStructNodes(const QJsonArray&) { return {}; }
std::vector<DsNode> parseDatasets(const QJsonArray&) { return {}; }
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>&) { return {}; }

}  // namespace geopro::data::dto
  • Step 4: 接入 src/data/CMakeLists.txt

add_library(geopro_data STATIC ...) 源列表改为含新文件,并链接 geopro_netApiProjectRepository 后续任务用到,提前接好):

find_package(nlohmann_json CONFIG REQUIRED)
find_package(Qt6 COMPONENTS Core REQUIRED)
add_library(geopro_data STATIC
  parse/SampleParsers.cpp
  repo/LocalSampleRepository.cpp
  dto/NavDto.cpp
  api/ApiProjectRepository.cpp)
target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json)
target_compile_features(geopro_data PUBLIC cxx_std_17)
set_target_properties(geopro_data PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)

注意:本步引用了 api/ApiProjectRepository.cppTask 5 创建)。为让本任务先编译通过,先创建占位空文件 Run: printf '#include "api/ApiProjectRepository.hpp"\n' > src/data/api/ApiProjectRepository.cpp —— 但其头文件 Task 5 才有。 改为:本任务 CMake 暂不api/ApiProjectRepository.cpp 那一行;只加 dto/NavDto.cppgeopro_net 链接。Task 5 再补 api 行。即本步源列表实际为:

add_library(geopro_data STATIC
  parse/SampleParsers.cpp
  repo/LocalSampleRepository.cpp
  dto/NavDto.cpp)

链接行已含 geopro_net无害net 已存在)。

  • Step 5: 接入 tests/CMakeLists.txt

target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) 之后、target_link_libraries(geopro_tests PRIVATE geopro_data) 之前或之后,加一行:

target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp)

geopro_tests 已链接 geopro_dataQt6::Core,无需额外链接。)

  • Step 6: 配置 + 构建测试,确认编译通过、用例存在

Run: cmake --preset msvc-release 然后 cmake --build build/release --target geopro_tests Expected: 编译通过。

  • Step 7: 跑测试,确认通过

Run: ctest --test-dir build/release -R NavDto --output-on-failure Expected: NavDto.ParseWorkspacesMapsFieldsAndCurrentFlag PASS。

  • Step 8: Commit
git add src/data/dto/NavDto.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp src/data/CMakeLists.txt tests/CMakeLists.txt
git commit -m "feat(data): NavDto 脚手架 + parseWorkspaces含测试接入"

Task 3: parseProjects / parseStructNodes / parseDatasetsTDD

Files:

  • Modify: src/data/dto/NavDto.cpp, tests/data/test_nav_dto.cpp

  • Step 1: 追加失败测试到 tests/data/test_nav_dto.cpp

文件顶部 arrOf 之后追加 objOf 辅助:

namespace {
QJsonObject objOf(const char* json) {
    return QJsonDocument::fromJson(QByteArray(json)).object();
}
}  // namespace

TEST(NavDto, ParseProjectsMapsCrsAndPaging) {
    const auto data = objOf(R"({
      "hasNextPage": true,
      "projectList": [
        {"id":"p1","projectName":"青海湖北岸","projectTypeName":"ERT",
         "referenceCRSCode":"EPSG:4547","referenceCRSName":"CGCS2000","status":1}
      ]
    })");
    const auto page = dto::parseProjects(data);
    EXPECT_TRUE(page.hasNextPage);
    ASSERT_EQ(page.projects.size(), 1u);
    EXPECT_EQ(page.projects[0].id, "p1");
    EXPECT_EQ(page.projects[0].name, "青海湖北岸");
    EXPECT_EQ(page.projects[0].typeName, "ERT");
    EXPECT_EQ(page.projects[0].crsCode, "EPSG:4547");
    EXPECT_EQ(page.projects[0].status, 1);
}

TEST(NavDto, ParseStructNodesMapsParentAndType) {
    const auto arr = arrOf(R"([
      {"id":"gs1","name":"工区1","parentId":"","type":1,"typeName":"GS","confCode":""},
      {"id":"tm1","name":"测线1","parentId":"gs1","type":2,"typeName":"TM","confCode":"ERT"}
    ])");
    const auto ns = dto::parseStructNodes(arr);
    ASSERT_EQ(ns.size(), 2u);
    EXPECT_EQ(ns[0].id, "gs1");
    EXPECT_EQ(ns[1].parentId, "gs1");
    EXPECT_EQ(ns[1].confCode, "ERT");
    EXPECT_EQ(ns[1].type, 2);
}

TEST(NavDto, ParseDatasetsMapsDdCodeToDdType) {
    const auto arr = arrOf(R"([
      {"id":"ds1","name":"批次1","ddCode":"dd_section","typeName":"剖面"}
    ])");
    const auto ds = dto::parseDatasets(arr);
    ASSERT_EQ(ds.size(), 1u);
    EXPECT_EQ(ds[0].id, "ds1");
    EXPECT_EQ(ds[0].name, "批次1");
    EXPECT_EQ(ds[0].ddType, "dd_section");
}
  • Step 2: 运行确认失败

Run: cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NavDto --output-on-failure Expected: 三个新用例 FAIL空桩返回空

  • Step 3: 实现三个函数(替换 NavDto.cpp 中对应空桩)
ProjectPage parseProjects(const QJsonObject& data) {
    ProjectPage page;
    page.hasNextPage = data.value(QStringLiteral("hasNextPage")).toBool();
    const QJsonArray list = data.value(QStringLiteral("projectList")).toArray();
    page.projects.reserve(static_cast<size_t>(list.size()));
    for (const QJsonValue& v : list) {
        const QJsonObject o = v.toObject();
        ProjectSummary p;
        p.id = str(o, "id");
        p.name = str(o, "projectName");
        p.typeName = str(o, "projectTypeName");
        p.crsCode = str(o, "referenceCRSCode");
        p.crsName = str(o, "referenceCRSName");
        p.status = o.value(QStringLiteral("status")).toInt();
        page.projects.push_back(std::move(p));
    }
    return page;
}

std::vector<StructNode> parseStructNodes(const QJsonArray& arr) {
    std::vector<StructNode> out;
    out.reserve(static_cast<size_t>(arr.size()));
    for (const QJsonValue& v : arr) {
        const QJsonObject o = v.toObject();
        StructNode n;
        n.id = str(o, "id");
        n.name = str(o, "name");
        n.parentId = str(o, "parentId");
        n.typeName = str(o, "typeName");
        n.confCode = str(o, "confCode");
        n.type = o.value(QStringLiteral("type")).toInt();
        out.push_back(std::move(n));
    }
    return out;
}

std::vector<DsNode> parseDatasets(const QJsonArray& arr) {
    std::vector<DsNode> out;
    out.reserve(static_cast<size_t>(arr.size()));
    for (const QJsonValue& v : arr) {
        const QJsonObject o = v.toObject();
        DsNode d;
        d.id = str(o, "id");
        d.name = str(o, "name");
        d.ddType = str(o, "ddCode");
        out.push_back(std::move(d));
    }
    return out;
}
  • Step 4: 运行确认通过

Run: cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NavDto --output-on-failure Expected: 全部 NavDto 用例 PASS。

  • Step 5: Commit
git add src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp
git commit -m "feat(data): NavDto parseProjects/parseStructNodes/parseDatasets"

Task 4: buildStructTree 扁平→树TDD

Files:

  • Modify: src/data/dto/NavDto.cpp, tests/data/test_nav_dto.cpp

  • Step 1: 追加失败测试

TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) {
    const std::vector<StructNode> flat = {
        {"gs1", "工区1", "",    "GS", "", 1},
        {"tm1", "测线1", "gs1", "TM", "", 2},
        {"tm2", "测线2", "gs1", "TM", "", 2},
        {"tmD", "直挂测线", "", "TM", "", 2},   // TM 直挂项目(无 GS
    };
    const auto roots = dto::buildStructTree(flat);
    ASSERT_EQ(roots.size(), 2u);                 // gs1 + tmD
    EXPECT_EQ(roots[0].node.id, "gs1");
    EXPECT_FALSE(roots[0].isTm);                 // 非叶 = GS
    ASSERT_EQ(roots[0].children.size(), 2u);
    EXPECT_EQ(roots[0].children[0].node.id, "tm1");
    EXPECT_TRUE(roots[0].children[0].isTm);      // 叶 = TM
    EXPECT_EQ(roots[1].node.id, "tmD");
    EXPECT_TRUE(roots[1].isTm);                  // 直挂项目的叶子 = TM
}

TEST(NavDto, BuildStructTreeOrphanParentBecomesRoot) {
    const std::vector<StructNode> flat = {
        {"tmX", "孤儿测线", "ghost", "TM", "", 2},  // parentId 不在集合内
    };
    const auto roots = dto::buildStructTree(flat);
    ASSERT_EQ(roots.size(), 1u);
    EXPECT_EQ(roots[0].node.id, "tmX");
    EXPECT_TRUE(roots[0].isTm);
}

TEST(NavDto, BuildStructTreeEmpty) {
    EXPECT_TRUE(dto::buildStructTree({}).empty());
}
  • Step 2: 运行确认失败

Run: cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R "NavDto.BuildStructTree" --output-on-failure Expected: FAIL空桩

  • Step 3: 实现 buildStructTree替换空桩并在文件顶部加 #include <set>, #include <functional>
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat) {
    std::set<std::string> ids;
    std::set<std::string> hasChild;
    for (const auto& n : flat) {
        ids.insert(n.id);
        if (!n.parentId.empty()) hasChild.insert(n.parentId);
    }
    // 叶子(无子节点)= TM。
    auto isLeaf = [&](const std::string& id) { return hasChild.find(id) == hasChild.end(); };
    // 根层节点parentId 为空或 parentId 不在 id 集合内(孤儿)。
    auto isRootLevel = [&](const StructNode& n) {
        return n.parentId.empty() || ids.find(n.parentId) == ids.end();
    };
    std::function<std::vector<StructTreeNode>(const std::string&, bool)> build =
        [&](const std::string& parentId, bool root) {
            std::vector<StructTreeNode> out;
            for (const auto& n : flat) {
                const bool belongs = root ? isRootLevel(n) : (n.parentId == parentId);
                if (!belongs) continue;
                if (n.id == parentId) continue;  // 防自环
                StructTreeNode t;
                t.node = n;
                t.isTm = isLeaf(n.id);
                t.children = build(n.id, false);
                out.push_back(std::move(t));
            }
            return out;
        };
    return build(std::string(), true);
}
  • Step 4: 运行确认通过

Run: cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NavDto --output-on-failure Expected: 全部 NavDto 用例 PASS。

  • Step 5: Commit
git add src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp
git commit -m "feat(data): buildStructTree 扁平→树(叶子=TM含直挂/孤儿/空表)"

Task 5: ApiProjectRepository 实现(接口层↔仓储)

Files:

  • Create: src/data/api/ApiProjectRepository.hpp, src/data/api/ApiProjectRepository.cpp
  • Modify: src/data/CMakeLists.txt

无单测(依赖 live 服务器,按 spec §9 走手动联调);以"编译 + 链接通过"为本任务验收。

  • Step 1: 创建 src/data/api/ApiProjectRepository.hpp
#pragma once
#include "repo/IProjectRepository.hpp"

namespace geopro::net { class ApiClient; }

namespace geopro::data {

// 用共享会话 ApiClient 实现导航仓储同步阻塞。token 由调用方注入 ApiClient。
class ApiProjectRepository : public IProjectRepository {
public:
    explicit ApiProjectRepository(net::ApiClient& api);

    RepoResult<std::vector<Workspace>>      listWorkspaces() override;
    RepoResult<bool>                         switchWorkspace(const std::string& tenantId) override;
    RepoResult<std::vector<ProjectSummary>> listProjects(const std::string& lastProjectId) override;
    RepoResult<std::vector<StructNode>>      loadStructure(const std::string& projectId) override;
    RepoResult<std::vector<DsNode>>          loadDatasetsOfTm(const std::string& tmObjectId) override;

private:
    net::ApiClient& api_;
};

}  // namespace geopro::data
  • Step 2: 创建 src/data/api/ApiProjectRepository.cpp
#include "api/ApiProjectRepository.hpp"

#include <QJsonArray>
#include <QJsonObject>
#include <QString>

#include "ApiClient.hpp"
#include "dto/NavDto.hpp"

namespace geopro::data {

namespace {
constexpr int kCodeSuccess = 200;

bool ok(const net::ApiResponse& r) { return r.code == kCodeSuccess; }

std::string errorOf(const net::ApiResponse& r, const char* fallback) {
    if (!r.msg.isEmpty()) return r.msg.toStdString();
    if (!r.rawError.isEmpty()) return r.rawError.toStdString();
    return fallback;
}
}  // namespace

ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {}

RepoResult<std::vector<Workspace>> ApiProjectRepository::listWorkspaces() {
    const net::ApiResponse r =
        api_.get(QStringLiteral("/business/system/tenant/enterprise/joined/list"));
    if (!ok(r)) return {false, {}, errorOf(r, "listWorkspaces failed")};
    return {true, dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()), {}};
}

RepoResult<bool> ApiProjectRepository::switchWorkspace(const std::string& tenantId) {
    const QString path =
        QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(QString::fromStdString(tenantId));
    const net::ApiResponse r = api_.postJson(path, QJsonObject{});
    if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")};
    return {true, true, {}};
}

RepoResult<std::vector<ProjectSummary>> ApiProjectRepository::listProjects(
    const std::string& lastProjectId) {
    const QString path = QStringLiteral("/business/project/queryByUser?lastProjectId=%1")
                             .arg(QString::fromStdString(lastProjectId));
    const net::ApiResponse r = api_.get(path);
    if (!ok(r)) return {false, {}, errorOf(r, "listProjects failed")};
    return {true, dto::parseProjects(r.data).projects, {}};
}

RepoResult<std::vector<StructNode>> ApiProjectRepository::loadStructure(const std::string& projectId) {
    const QJsonObject body{{QStringLiteral("projectId"), QString::fromStdString(projectId)}};
    const net::ApiResponse r =
        api_.postJson(QStringLiteral("/business/projectWorkbench/queryProjectStruct"), body);
    if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")};
    return {true, dto::parseStructNodes(r.data.value(QStringLiteral("projectStructList")).toArray()), {}};
}

RepoResult<std::vector<DsNode>> ApiProjectRepository::loadDatasetsOfTm(const std::string& tmObjectId) {
    const QString path = QStringLiteral("/business/projectWorkbench/queryDsByTmObjectId/%1")
                             .arg(QString::fromStdString(tmObjectId));
    const net::ApiResponse r = api_.get(path);
    if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetsOfTm failed")};
    return {true, dto::parseDatasets(r.data.value(QStringLiteral("value")).toArray()), {}};
}

}  // namespace geopro::data
  • Step 3: 把 api 源文件加回 src/data/CMakeLists.txt

源列表改为:

add_library(geopro_data STATIC
  parse/SampleParsers.cpp
  repo/LocalSampleRepository.cpp
  dto/NavDto.cpp
  api/ApiProjectRepository.cpp)

geopro_net 链接 Task 2 已加。)

  • Step 4: 构建确认通过

Run: cmake --build build/release --target geopro_data && cmake --build build/release --target geopro_tests Expected: 编译/链接通过NavDto 测试仍 PASSctest --test-dir build/release -R NavDto)。

  • Step 5: Commit
git add src/data/api/ApiProjectRepository.hpp src/data/api/ApiProjectRepository.cpp src/data/CMakeLists.txt
git commit -m "feat(data): ApiProjectRepository 实现 5 个导航接口"

Task 6: WorkbenchNavController逻辑层

Files:

  • Create: src/controller/CMakeLists.txt, src/controller/WorkbenchNavController.hpp, src/controller/WorkbenchNavController.cpp
  • Modify: src/CMakeLists.txt

无单测(依赖仓储 + Qt 事件,手动联调);以"编译 + 链接通过"验收。

  • Step 1: 创建 src/controller/WorkbenchNavController.hpp
#pragma once
#include <QObject>
#include <QString>
#include <string>
#include <vector>

#include "repo/IProjectRepository.hpp"

namespace geopro::controller {

// 导航状态机:编排 IProjectRepository持有当前 空间/项目 状态,经信号驱动 UI。不持有 widget。
class WorkbenchNavController : public QObject {
    Q_OBJECT
public:
    explicit WorkbenchNavController(data::IProjectRepository& repo, QObject* parent = nullptr);

    void start();  // 启动:拉空间 → 项目 → 结构

    QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); }

public slots:
    void switchWorkspace(const QString& tenantId);
    void switchProject(const QString& projectId);
    void selectTm(const QString& tmObjectId);

signals:
    void busyChanged(bool busy);
    void workspacesLoaded(const std::vector<geopro::data::Workspace>& list, const QString& currentId);
    void projectsLoaded(const std::vector<geopro::data::ProjectSummary>& list, const QString& currentId);
    void structureLoaded(const QString& projectName, const std::vector<geopro::data::StructNode>& nodes);
    void datasetsLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsNode>& list);
    void loadFailed(const QString& stage, const QString& message);

private:
    void loadProjectsAndStructure();  // start + switchWorkspace 共用

    data::IProjectRepository& repo_;
    std::vector<data::ProjectSummary> lastProjects_;
    std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
};

}  // namespace geopro::controller
  • Step 2: 创建 src/controller/WorkbenchNavController.cpp
#include "WorkbenchNavController.hpp"

namespace geopro::controller {

using data::ProjectSummary;
using data::Workspace;

WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent)
    : QObject(parent), repo_(repo) {}

void WorkbenchNavController::start() {
    emit busyChanged(true);
    const auto ws = repo_.listWorkspaces();
    if (!ws.ok) {
        emit busyChanged(false);
        emit loadFailed(QStringLiteral("workspaces"), QString::fromStdString(ws.error));
        return;
    }
    QString cur;
    for (const auto& w : ws.value)
        if (w.isCurrent) cur = QString::fromStdString(w.id);
    if (cur.isEmpty() && !ws.value.empty()) cur = QString::fromStdString(ws.value.front().id);
    currentWorkspaceId_ = cur.toStdString();
    emit workspacesLoaded(ws.value, cur);

    loadProjectsAndStructure();
    emit busyChanged(false);
}

void WorkbenchNavController::loadProjectsAndStructure() {
    const auto ps = repo_.listProjects(std::string());
    if (!ps.ok) {
        emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error));
        return;
    }
    lastProjects_ = ps.value;
    QString curP;
    if (!ps.value.empty()) {
        const auto& first = ps.value.front();
        curP = QString::fromStdString(first.id);
        currentProjectId_ = first.id;
        currentProjectName_ = first.name;
        currentCrsCode_ = first.crsCode;
    } else {
        currentProjectId_.clear();
        currentProjectName_.clear();
        currentCrsCode_.clear();
    }
    emit projectsLoaded(ps.value, curP);

    if (curP.isEmpty()) {
        emit structureLoaded(QString(), {});  // 暂无项目 → 空树
        return;
    }
    const auto st = repo_.loadStructure(currentProjectId_);
    if (!st.ok) {
        emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error));
        return;
    }
    emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);
}

void WorkbenchNavController::switchWorkspace(const QString& tenantId) {
    if (tenantId.isEmpty()) return;
    emit busyChanged(true);
    const auto r = repo_.switchWorkspace(tenantId.toStdString());
    if (!r.ok) {
        emit busyChanged(false);
        emit loadFailed(QStringLiteral("switchWorkspace"), QString::fromStdString(r.error));
        return;
    }
    currentWorkspaceId_ = tenantId.toStdString();
    loadProjectsAndStructure();
    emit busyChanged(false);
}

void WorkbenchNavController::switchProject(const QString& projectId) {
    if (projectId.isEmpty()) return;
    emit busyChanged(true);
    currentProjectId_ = projectId.toStdString();
    for (const auto& p : lastProjects_)
        if (p.id == currentProjectId_) {
            currentProjectName_ = p.name;
            currentCrsCode_ = p.crsCode;
        }
    const auto st = repo_.loadStructure(currentProjectId_);
    if (!st.ok) {
        emit busyChanged(false);
        emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error));
        return;
    }
    emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);
    emit busyChanged(false);
}

void WorkbenchNavController::selectTm(const QString& tmObjectId) {
    if (tmObjectId.isEmpty()) return;
    emit busyChanged(true);
    const auto ds = repo_.loadDatasetsOfTm(tmObjectId.toStdString());
    emit busyChanged(false);
    if (!ds.ok) {
        emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(ds.error));
        return;
    }
    emit datasetsLoaded(tmObjectId, ds.value);
}

}  // namespace geopro::controller
  • Step 3: 创建 src/controller/CMakeLists.txt
find_package(Qt6 COMPONENTS Core REQUIRED)
add_library(geopro_controller STATIC
  WorkbenchNavController.cpp)
target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core)
target_compile_features(geopro_controller PUBLIC cxx_std_17)
set_target_properties(geopro_controller PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF)
  • Step 4: 在 src/CMakeLists.txt 启用 controlleradd_subdirectory(app) 之前)
add_subdirectory(core)
add_subdirectory(data)
add_subdirectory(net)
add_subdirectory(render)
add_subdirectory(controller)
add_subdirectory(app)
  • Step 5: 配置 + 构建确认通过

Run: cmake --preset msvc-release && cmake --build build/release --target geopro_controller Expected: 编译/链接通过(含 AUTOMOC 生成 moc

  • Step 6: Commit
git add src/controller/ src/CMakeLists.txt
git commit -m "feat(controller): WorkbenchNavController 导航状态机"

Task 7: TopBar 升级为数据驱动类UI 层)

Files:

  • Modify: src/app/TopBar.hpp, src/app/TopBar.cpp, src/app/main.cpp

buildTopToolBar 自由函数替换为 TopBar 类(保留 buildMenuBar 自由函数)。本任务保持可编译;信号接线在 Task 9。

  • Step 1: 重写 src/app/TopBar.hpp
#pragma once
#include <QWidget>
#include <vector>
#include "repo/RepoTypes.hpp"

class QToolButton;

namespace geopro::app {

// 顶部菜单栏(静态,本轮不接真实页面)。
QWidget* buildMenuBar(QWidget* parent);

// 顶部工具条:数据驱动的工作空间/项目切换器 + 右侧图标 + 用户区。
class TopBar : public QWidget {
    Q_OBJECT
public:
    explicit TopBar(QWidget* parent = nullptr);

    void setWorkspaces(const std::vector<data::Workspace>& list, const QString& currentId);
    void setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId);

signals:
    void workspaceSwitchRequested(const QString& tenantId);
    void projectSwitchRequested(const QString& projectId);

private:
    QToolButton* wsBtn_ = nullptr;
    QToolButton* projBtn_ = nullptr;
};

}  // namespace geopro::app
  • Step 2: 重写 src/app/TopBar.cpp

保留文件原有匿名命名空间里的 kToolIcon/kWorkspaceIcon/makeDivider/makeIconButton/buildViewMenu/buildProjectMenu/buildToolsMenu/buildDeviceMenubuildMenuBar不改)。仅把末尾的 buildTopToolBar(QWidget*) 函数整段替换为下面的 TopBar 类实现:

TopBar::TopBar(QWidget* parent) : QWidget(parent) {
    setObjectName(QStringLiteral("appToolBar"));
    setFixedHeight(56);
    setStyleSheet(QStringLiteral(
        "#appToolBar { background:#FFFFFF; border-bottom:1px solid #E1E6EE; }"
        "#topDivider { color:#E1E6EE; }"
        "#wsSwitcher { color:#1F2A3D; border:none; border-radius:8px; padding:8px 12px;"
        "  font-size:14px; font-weight:600; }"
        "#wsSwitcher:hover { background:#EEF3FB; }"
        "QToolButton#iconBtn { border:none; border-radius:8px; padding:8px; }"
        "QToolButton#iconBtn:hover { background:#EEF3FB; }"
        "QToolButton::menu-indicator { image:none; }"
        "#avatar { background:#2D6CB5; color:#FFFFFF; border-radius:17px; font-weight:700;"
        "  font-size:13px; }"
        "#userName { color:#1F2A3D; font-size:13px; font-weight:600; }"
        "#userRole { color:#8A93A3; font-size:11px; }"));

    auto* lay = new QHBoxLayout(this);
    lay->setContentsMargins(14, 0, 14, 0);
    lay->setSpacing(0);

    // 工作空间切换器(数据驱动;初始占位文本,待 setWorkspaces 填充)。
    wsBtn_ = new QToolButton(this);
    wsBtn_->setObjectName(QStringLiteral("wsSwitcher"));
    wsBtn_->setIcon(makeGlyph(Glyph::Workspace, QColor("#2D6CB5"), kWorkspaceIcon));
    wsBtn_->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
    wsBtn_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
    wsBtn_->setPopupMode(QToolButton::InstantPopup);
    wsBtn_->setCursor(Qt::PointingHandCursor);
    wsBtn_->setText(QStringLiteral("(加载中…)"));
    wsBtn_->setMenu(new QMenu(wsBtn_));
    lay->addWidget(wsBtn_);

    lay->addSpacing(10);
    lay->addWidget(makeDivider(this));
    lay->addSpacing(10);

    // 项目切换器(数据驱动)。
    projBtn_ = new QToolButton(this);
    projBtn_->setObjectName(QStringLiteral("wsSwitcher"));
    projBtn_->setIcon(makeGlyph(Glyph::Folder, QColor("#2D6CB5"), kWorkspaceIcon));
    projBtn_->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
    projBtn_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
    projBtn_->setPopupMode(QToolButton::InstantPopup);
    projBtn_->setCursor(Qt::PointingHandCursor);
    projBtn_->setText(QStringLiteral("(加载中…)"));
    projBtn_->setMenu(new QMenu(projBtn_));
    lay->addWidget(projBtn_);

    lay->addStretch();

    lay->addWidget(makeIconButton(this, Glyph::Help, QStringLiteral("帮助")));
    lay->addWidget(makeIconButton(this, Glyph::Bell, QStringLiteral("通知")));
    lay->addWidget(makeIconButton(this, Glyph::Gear, QStringLiteral("设置")));
    lay->addSpacing(10);
    lay->addWidget(makeDivider(this));
    lay->addSpacing(12);

    // 用户区(本轮静态)。
    auto* avatar = new QLabel(QStringLiteral("ZL"), this);
    avatar->setObjectName(QStringLiteral("avatar"));
    avatar->setFixedSize(34, 34);
    avatar->setAlignment(Qt::AlignCenter);
    lay->addWidget(avatar);
    lay->addSpacing(8);

    auto* userBox = new QWidget(this);
    auto* userLay = new QVBoxLayout(userBox);
    userLay->setContentsMargins(0, 0, 0, 0);
    userLay->setSpacing(0);
    auto* userName = new QLabel(QStringLiteral("张磊"), userBox);
    userName->setObjectName(QStringLiteral("userName"));
    auto* userRole = new QLabel(QStringLiteral("高级工程师"), userBox);
    userRole->setObjectName(QStringLiteral("userRole"));
    userLay->addWidget(userName);
    userLay->addWidget(userRole);
    lay->addWidget(userBox);
}

void TopBar::setWorkspaces(const std::vector<data::Workspace>& list, const QString& currentId) {
    auto* menu = new QMenu(wsBtn_);
    auto* header = menu->addAction(QStringLiteral("切换空间"));
    header->setEnabled(false);
    menu->addSeparator();
    QString currentName;
    for (const auto& w : list) {
        const QString id = QString::fromStdString(w.id);
        const QString name = QString::fromStdString(w.name);
        auto* a = menu->addAction(name);
        a->setCheckable(true);
        a->setChecked(id == currentId);
        if (id == currentId) currentName = name;
        QObject::connect(a, &QAction::triggered, this,
                         [this, id]() { emit workspaceSwitchRequested(id); });
    }
    if (list.empty()) {
        auto* none = menu->addAction(QStringLiteral("(暂无空间)"));
        none->setEnabled(false);
    }
    wsBtn_->setMenu(menu);
    wsBtn_->setText((currentName.isEmpty() ? QStringLiteral("选择空间") : currentName) +
                    QStringLiteral("  ▾"));
}

void TopBar::setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId) {
    auto* menu = new QMenu(projBtn_);
    auto* header = menu->addAction(QStringLiteral("切换项目"));
    header->setEnabled(false);
    menu->addSeparator();
    QString currentName;
    for (const auto& p : list) {
        const QString id = QString::fromStdString(p.id);
        const QString name = QString::fromStdString(p.name);
        auto* a = menu->addAction(name);
        a->setCheckable(true);
        a->setChecked(id == currentId);
        if (id == currentId) currentName = name;
        QObject::connect(a, &QAction::triggered, this,
                         [this, id]() { emit projectSwitchRequested(id); });
    }
    if (list.empty()) {
        auto* none = menu->addAction(QStringLiteral("(暂无项目)"));
        none->setEnabled(false);
    }
    projBtn_->setMenu(menu);
    projBtn_->setText((currentName.isEmpty() ? QStringLiteral("选择项目") : currentName) +
                      QStringLiteral("  ▾"));
}

头文件包含:确保 TopBar.cpp 顶部已 #include<QActionGroup> 可保留或删除;新增需要 <QMenu> <QToolButton> <QHBoxLayout> <QVBoxLayout> <QLabel> <QSize> <QColor>(原文件已包含这些)。makeGlyph 来自 Glyphs.hpp(原文件已 include

  • Step 3: 临时修正 main.cpp 调用点以保持可编译

main.cpp 顶部菜单区块当前是:

        topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
        topLayout->addWidget(geopro::app::buildTopToolBar(topChrome));

改为Task 9 会再加信号接线):

        topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
        topLayout->addWidget(new geopro::app::TopBar(topChrome));
  • Step 4: 构建主程序确认通过

Run: cmake --build build/release --target geopro_desktop Expected: 编译/链接通过TopBar 经 AUTOMOC 生成 moc

  • Step 5: Commit
git add src/app/TopBar.hpp src/app/TopBar.cpp src/app/main.cpp
git commit -m "feat(app): TopBar 升级为数据驱动类(工作空间/项目切换信号)"

Task 8: ObjectTreePanel 被动对象树UI 层)

Files:

  • Create: src/app/panels/ObjectTreePanel.hpp, src/app/panels/ObjectTreePanel.cpp

  • Modify: src/app/CMakeLists.txt

  • Step 1: 创建 src/app/panels/ObjectTreePanel.hpp

#pragma once
#include <QWidget>
#include <vector>
#include "repo/RepoTypes.hpp"

class QTreeWidget;
class QLabel;

namespace geopro::app {

// 被动对象树:项目根 → GS → TM叶子=TM可勾选。数据来自控制器自身不发请求。
class ObjectTreePanel : public QWidget {
    Q_OBJECT
public:
    explicit ObjectTreePanel(QWidget* parent = nullptr);

    // 用扁平结构节点重建树(内部调 dto::buildStructTree    void setStructure(const QString& projectName, const std::vector<data::StructNode>& nodes);
    void showMessage(const QString& message);  // 错误/空状态占位

signals:
    void tmClicked(const QString& tmObjectId);
    void tmCheckToggled(const QString& tmObjectId, bool checked);

private:
    QTreeWidget* tree_ = nullptr;
    QLabel* hint_ = nullptr;
};

}  // namespace geopro::app
  • Step 2: 创建 src/app/panels/ObjectTreePanel.cpp
#include "panels/ObjectTreePanel.hpp"

#include <QColor>
#include <QLabel>
#include <QSignalBlocker>
#include <QStackedLayout>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QVBoxLayout>

#include "Glyphs.hpp"
#include "dto/NavDto.hpp"

namespace geopro::app {

namespace {
// TM 节点把 tmObjectId 存在该角色GS/项目根节点为空。
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);
    }
}
}  // namespace

ObjectTreePanel::ObjectTreePanel(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);
    {
        const QString openArrow = writeChevronIcon(true, QColor("#8A93A3"));
        const QString closedArrow = writeChevronIcon(false, QColor("#8A93A3"));
        tree_->setStyleSheet(
            QStringLiteral("QTreeView::branch { background: #FFFFFF; }"
                           "QTreeView::branch:has-children:!has-siblings:closed,"
                           "QTreeView::branch:closed:has-children:has-siblings { image: url(%1); }"
                           "QTreeView::branch:open:has-children:!has-siblings,"
                           "QTreeView::branch:open:has-children:has-siblings { image: url(%2); }")
                .arg(closedArrow, openArrow));
    }
    lay->addWidget(tree_, 1);

    hint_ = new QLabel(QStringLiteral("(加载中…)"), this);
    hint_->setAlignment(Qt::AlignCenter);
    hint_->setStyleSheet(QStringLiteral("color:#9AA6B6; padding:16px;"));
    hint_->setVisible(false);
    lay->addWidget(hint_);

    QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) {
        const QString tmId = item->data(0, kRoleTmId).toString();
        if (!tmId.isEmpty()) emit tmClicked(tmId);
    });
    QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* item, int) {
        const QString tmId = item->data(0, kRoleTmId).toString();
        if (!tmId.isEmpty())
            emit tmCheckToggled(tmId, item->checkState(0) == Qt::Checked);
    });
}

void ObjectTreePanel::setStructure(const QString& projectName,
                                   const std::vector<data::StructNode>& nodes) {
    const QSignalBlocker block(tree_);  // 重建触发 itemChanged先屏蔽
    tree_->clear();
    const auto roots = data::dto::buildStructTree(nodes);
    if (roots.empty()) {
        showMessage(projectName.isEmpty() ? QStringLiteral("(暂无项目)")
                                          : QStringLiteral("(该项目暂无结构)"));
        return;
    }
    hint_->setVisible(false);
    tree_->setVisible(true);
    auto* rootItem = new QTreeWidgetItem(tree_);
    rootItem->setText(0, projectName.isEmpty() ? QStringLiteral("项目") : projectName);
    addNodes(rootItem, roots);
    tree_->expandAll();
}

void ObjectTreePanel::showMessage(const QString& message) {
    tree_->clear();
    tree_->setVisible(false);
    hint_->setText(message);
    hint_->setVisible(true);
}

}  // namespace geopro::app

writeChevronIcon 来自 Glyphs.hppmain.cpp 同名用法已验证存在)。

  • Step 3: 接入 src/app/CMakeLists.txt

add_executable(geopro_desktop WIN32 ...) 源列表加 panels/ObjectTreePanel.cpp,并在 target_link_librariesgeopro_controller

add_executable(geopro_desktop WIN32
  main.cpp
  Theme.cpp
  TopBar.cpp
  Glyphs.cpp
  PanelHeader.cpp
  login/LoginWindow.cpp
  panels/AnomalyListPanel.cpp
  panels/DatasetListPanel.cpp
  panels/ObjectTreePanel.cpp)

link 段在 geopro_render 行后加:

  geopro_render      # Phase 4render 层Scene / GridContourActor / 相机预设)
  geopro_controller  # Phase 5导航编排WorkbenchNavController
)
  • Step 4: 构建确认通过

Run: cmake --preset msvc-release && cmake --build build/release --target geopro_desktop Expected: 编译/链接通过。

  • Step 5: Commit
git add src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp src/app/CMakeLists.txt
git commit -m "feat(app): ObjectTreePanel 被动对象树项目→GS→TM"

Task 9: CentralScene 数据驱动 helper解耦中央三维编排

Files:

  • Create: src/app/CentralScene.hpp, src/app/CentralScene.cpp
  • Modify: src/app/CMakeLists.txt

把"每个剖面 section 的中央渲染"从对象树解耦为显式数据驱动 helper。本轮用空 sections中央占位 下一轮用真实 DS 构建 sections 调同一 helper 即复活spec §8.1 / §12.1。无单测VTK 渲染,手动联调)。

  • Step 1: 创建 src/app/CentralScene.hpp
#pragma once
#include <vector>

#include "model/ColorScale.hpp"
#include "model/Field.hpp"

namespace geopro::core { class GeoLocalFrame; }
namespace geopro::render { class Scene; }
class vtkRenderer;
class vtkRenderWindow;

namespace geopro::app {

// 中央视图模式:二维地图(测线红线俯视)/ 三维视图(断面墙)。
enum class ViewMode { Map2D, View3D };

// 一个待渲染剖面grid2D 测线 / 3D 帘面都用)+ colorScale3D 帘面上色)。
struct SectionInput {
    geopro::core::Grid grid;
    geopro::core::ColorScale colorScale;
};

// 中央场景重建(脱离对象树,按显式 sections 渲染):
//   2D = 每个 section 的 buildSurveyLine3D = 每个 section 的 buildCurtain受 showCurtain// 下一轮接真实 DS构建 sections 后调用本函数即可render 层零改动。
void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
                         vtkRenderWindow* renderWindow, ViewMode mode,
                         const std::vector<SectionInput>& sections, bool showCurtain,
                         const geopro::core::GeoLocalFrame& frame, double verticalExaggeration);

}  // namespace geopro::app
  • Step 2: 创建 src/app/CentralScene.cpp
#include "CentralScene.hpp"

#include <vtkActor.h>
#include <vtkRenderWindow.h>
#include <vtkRenderer.h>

#include "CameraPreset.hpp"
#include "Scene.hpp"
#include "actors/CurtainActor.hpp"
#include "actors/MapLineActor.hpp"
#include "geo/GeoLocalFrame.hpp"

namespace geopro::app {

void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
                         vtkRenderWindow* renderWindow, ViewMode mode,
                         const std::vector<SectionInput>& sections, bool showCurtain,
                         const geopro::core::GeoLocalFrame& frame, double verticalExaggeration) {
    scene.clear();
    const bool is2D = (mode == ViewMode::Map2D);
    renderer->SetBackground(is2D ? 0.96 : 1.0, is2D ? 0.97 : 1.0, is2D ? 0.99 : 1.0);

    for (const auto& s : sections) {
        if (is2D) {
            auto line = geopro::render::buildSurveyLine(s.grid, frame);
            if (line) scene.addActor(line);
        } else if (showCurtain) {
            auto curtain = geopro::render::buildCurtain(s.grid, s.colorScale, frame);
            if (curtain) {
                curtain->SetScale(1.0, 1.0, verticalExaggeration);  // 纵向夸张成墙
                scene.addActor(curtain);
            }
        }
    }

    if (is2D)
        geopro::render::applyTop2D(renderer);
    else
        geopro::render::applyFree3D(renderer);
    renderer->ResetCamera();
    renderWindow->Render();
}

}  // namespace geopro::app

头文件名核对自现有 main.cpp 用法:buildSurveyLineactors/MapLineActor.hpp buildCurtainactors/CurtainActor.hppapplyTop2D/applyFree3DCameraPreset.hpp SceneScene.hpp。这些符号来自 geopro_renderapp 已链接)。

  • Step 3: 接入 src/app/CMakeLists.txt 源列表

add_executable(geopro_desktop WIN32 ...) 源列表加 CentralScene.cpp(与 Task 8 的 panels/ObjectTreePanel.cpp 同处):

  panels/ObjectTreePanel.cpp
  CentralScene.cpp)
  • Step 4: 构建确认通过

Run: cmake --build build/release --target geopro_desktop Expected: 编译/链接通过CentralScene 暂未被引用,单独编译即可)。

  • Step 5: Commit
git add src/app/CentralScene.hpp src/app/CentralScene.cpp src/app/CMakeLists.txt
git commit -m "feat(app): CentralScene 数据驱动 helper解耦中央三维编排下一轮接真实DS复用"

Task 10: main.cpp 接线(构造仓储/控制器 + 信号 + 移除启动 demo

Files:

  • Modify: src/app/main.cpp

把对象树/数据列表/顶部切换器接到控制器;移除启动自动渲染本地 demo真实 DS 点击 → 中央/详情占位。

  • Step 1: 增加头文件 include

在 main.cpp 顶部 include 区(#include "TopBar.hpp" 附近)加:

#include "CentralScene.hpp"
#include "WorkbenchNavController.hpp"
#include "api/ApiProjectRepository.hpp"
#include "panels/ObjectTreePanel.hpp"

并把匿名命名空间里原本的本地枚举定义:

enum class ViewMode { Map2D, View3D };

替换为复用 helper 的同名枚举(保持后续 ViewMode::Map2D 等引用不变):

using geopro::app::ViewMode;
  • Step 2: 修改 buildWorkbench 签名,注入控制器

把:

void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo)

改为:

void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
                    geopro::controller::WorkbenchNavController& nav)
  • Step 3: 用 ObjectTreePanel 取代左上内联树

定位左上 dock 构建处(当前为 auto* tree = new QTreeWidget(); ... populateTree(tree, *structure); ...leftDock->setWidget(wrapWithHeader(... tree ...)))。整段替换为:

    // 左上 dock对象树真实结构项目根 → GS → TM。被动视图数据由控制器推送。
    auto* objectTree = new geopro::app::ObjectTreePanel();
    auto* leftDock = new ads::CDockWidget(QStringLiteral("对象显示栏"));
    leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象显示栏"),
                                       objectTree,
                                       {{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}}));
    auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);

同时删除文件顶部不再使用的 populateTreefindTmkRoleTmId 三处(若编译报未使用可保留 findTm/kRoleTmId,但 populateTree 必删以免引用旧 GsNode)。 删除 auto structure = std::make_shared<...>(repo.loadStructure());(真实结构改由控制器提供)。 注意:原 rebuildCentral lambda 捕获了 tree/structure。见 Step 4 处理。

  • Step 4: 中央视图改为调 CentralScene helper空 sections = 占位)

rebuildCentral 原实现遍历 tree 勾选项 + repo.loadGrid + 体素/切片/地形。本轮中央不接真实数据,改为 委托 Task 9 的 rebuildCentralScene,传空 sections → 空背景占位(下一轮喂真实 DS 即复活)。把 rebuildCentral lambda 整体替换为:

    // 中央编排已解耦到 CentralScene::rebuildCentralSceneTask 9。本轮空 sections → 空背景占位。
    // 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活spec §8.1 / §12.1)。
    auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() {
        geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode,
                                         std::vector<geopro::app::SectionInput>{}, *showCurtain,
                                         *frame, kVerticalExaggeration);
    };

这样 showVoxel/showTerrain/showSlice/slicePlane/crs/refElev/structure 不再被 rebuildCentral 捕获使用; 它们的声明与图层勾选回调保留(渲染基础设施不删),仅不再产生可视效果。

并把"视图详情"浮层里体素 / 切片 / 地形三个勾选框本轮置灰提示(它们不绑定单 DS需独立真实数据源 见 spec §12.1 E。在三个 chk* 创建之后、if (!crs) 块附近,追加:

    // 本轮中央不接真实派生层:体素/切片/地形勾选置灰,待下一轮接入对应数据源。
    for (QCheckBox* c : {chkVoxel, chkSlice, chkTerrain}) {
        c->setEnabled(false);
        c->setToolTip(QStringLiteral("(下一轮接入真实数据源)"));
    }

chkCurtain 保持可用(切换 showCurtain,被 rebuildCentralScene 使用)。

  • Step 5: 删除"对象树驱动中央/数据列表"的旧连接

删除这两段(基于内联 tree 的连接,objectTree 已无 tree 指针):

  • QObject::connect(tree, &QTreeWidget::itemChanged, ...rebuildCentral...) 整段。

  • QObject::connect(tree, &QTreeWidget::itemClicked, ...populateDatasetList...) 整段。

  • Step 6: 数据详情 DS 点击改占位

定位 datasetListitemClicked 连接(调用 loadDataset)。把其回调替换为占位(不再加载本地样本):

    QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
                     [propLabel, detailRendererPtr, detailRenderWindowPtr](QListWidgetItem* item) {
                         const QString name =
                             item->data(Qt::DisplayRole).toString().section('\n', 0, 0);
                         detailRendererPtr->RemoveAllViewProps();
                         detailRenderWindowPtr->Render();
                         propLabel->setText(QStringLiteral(
                             "数据集: %1\n(该数据集的剖面/反演渲染将在下一阶段接入 dd 接口)").arg(name));
                     });

保留 loadDataset / rebuildDetail 定义(渲染代码保留不删),仅不再从数据列表触发它们。若编译报 loadDataset 未使用警告(/W4 不会因未使用 lambda 报错),无视。

  • Step 7: 移除启动 demo 渲染块

删除文件中两处启动渲染:

  • // ── 启动默认:测线已勾选 ... rebuildCentral(); 之上的注释 + rebuildCentral(); 调用(即首帧本地渲染)。改为保留一次 rebuildCentral(); 以建立空背景视图(保留这一行,删上面那段“启动默认”注释语义即可)。
  • 整段 // 启动默认:选第一个含 dd_section 的测线 ... for (const auto& gs : *structure) { ... }(依赖 *structure,必删)。

结论:rebuildCentral(); 保留一次(建立空视图);依赖 *structure 的“选第一个测线”循环整段删除。

  • Step 8: 接线控制器 ↔ TopBar/ObjectTree/DatasetList

buildWorkbench 末尾dock 持久化块之前)加信号接线。先把 Step 1Task 7里临时的 new geopro::app::TopBar(topChrome) 改为持有指针并接线。把顶部 chrome 区块替换为:

    geopro::app::TopBar* topBar = nullptr;
    {
        auto* topChrome = new QWidget(&window);
        auto* topLayout = new QVBoxLayout(topChrome);
        topLayout->setContentsMargins(0, 0, 0, 0);
        topLayout->setSpacing(0);
        topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
        topBar = new geopro::app::TopBar(topChrome);
        topLayout->addWidget(topBar);
        window.setMenuWidget(topChrome);
    }

然后在其后加控制器接线:

    // ── 控制器 ↔ UI 信号接线(导航壳)──────────────────────────────────────
    QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
                     &geopro::controller::WorkbenchNavController::switchWorkspace);
    QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav,
                     &geopro::controller::WorkbenchNavController::switchProject);
    QObject::connect(objectTree, &geopro::app::ObjectTreePanel::tmClicked, &nav,
                     &geopro::controller::WorkbenchNavController::selectTm);

    QObject::connect(&nav, &geopro::controller::WorkbenchNavController::workspacesLoaded, topBar,
                     [topBar](const std::vector<geopro::data::Workspace>& list, const QString& cur) {
                         topBar->setWorkspaces(list, cur);
                     });
    QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar,
                     [topBar](const std::vector<geopro::data::ProjectSummary>& list,
                              const QString& cur) { topBar->setProjects(list, cur); });
    QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree,
                     [objectTree, datasetList, datasetTitle, datasetTabs](
                         const QString& projectName,
                         const std::vector<geopro::data::StructNode>& nodes) {
                         objectTree->setStructure(projectName, nodes);
                         datasetList->clear();  // 切项目清空 DS 列表
                         if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏"));
                         datasetTabs->setTabText(0, QStringLiteral("数据"));
                     });
    QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList,
                     [datasetList, datasetTitle, datasetTabs](
                         const QString&, const std::vector<geopro::data::DsNode>& list) {
                         geopro::app::populateDatasetList(datasetList, list);
                         if (datasetTitle)
                             datasetTitle->setText(QStringLiteral("数据集显示栏"));
                         datasetTabs->setTabText(
                             0, QStringLiteral("数据 (%1)").arg(static_cast<int>(list.size())));
                     });
    QObject::connect(&nav, &geopro::controller::WorkbenchNavController::loadFailed, objectTree,
                     [objectTree, &window](const QString& stage, const QString& msg) {
                         if (stage == QStringLiteral("structure") ||
                             stage == QStringLiteral("projects"))
                             objectTree->showMessage(QStringLiteral("加载失败:%1").arg(msg));
                         window.statusBar()->showMessage(
                             QStringLiteral("加载失败(%1%2").arg(stage, msg), 8000);
                     });
    QObject::connect(&nav, &geopro::controller::WorkbenchNavController::busyChanged, &window,
                     [](bool busy) {
                         if (busy)
                             QApplication::setOverrideCursor(Qt::WaitCursor);
                         else
                             QApplication::restoreOverrideCursor();
                     });

populateDatasetList 已在 panels/DatasetListPanel.hppmain.cpp 已 includedatasetTitle/datasetTabs/datasetList 为既有局部变量,确保接线代码在它们定义之后。

  • Step 9: 在 main() 构造仓储/控制器并启动

定位 main() 中构建工作台处:

    geopro::data::LocalSampleRepository repo(
        "D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/");

    QMainWindow window;
    ...
    buildWorkbench(window, repo);
    window.show();

改为(api 已在上文构造并 setToken

    geopro::data::LocalSampleRepository repo(
        "D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/");

    // 导航仓储 + 控制器(接口/逻辑层):用同一共享会话 ApiClient。
    geopro::data::ApiProjectRepository projectRepo(api);
    geopro::controller::WorkbenchNavController nav(projectRepo);

    QMainWindow window;
    window.setWindowTitle(QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)"));
    window.resize(1280, 800);
    window.setMinimumSize(1024, 680);

    buildWorkbench(window, repo, nav);
    window.show();

    nav.start();  // 进入工作台后拉真实 空间/项目/结构show 后调用,确保 UI 已就绪)

    return app.exec();

删除原先重复的 window.setWindowTitle/resize/setMinimumSize(已并入上面),避免重复。

  • Step 10: 构建主程序确认通过

Run: cmake --build build/release --target geopro_desktop Expected: 编译/链接通过。若报某变量未使用导致 /W4 /WX(本项目未开 /WX,仅告警),可忽略告警。

  • Step 11: 手动联调(真实接口)

Run: 启动 build/release/src/app/geopro_desktop.exe(或用 /run)。登录后验证:

  • 顶部工作空间下拉显示真实空间列表,切换触发项目刷新。
  • 项目下拉显示真实项目,切换触发对象树刷新。
  • 对象树显示 项目根 → GS → TM单击 TM 左下数据列表出现真实 DS。
  • 单击 DS中央/数据详情显示占位文案、右下属性显示“将在下一阶段接入 dd 接口”。
  • 断网/无数据时显示“加载失败/暂无…”而非崩溃或本地样本。

Expected: 上述行为符合预期(截图留存)。

  • Step 12: Commit
git add src/app/main.cpp
git commit -m "feat(app): 工作台接入真实导航(空间/项目/对象树/DS中央渲染占位"

自检结论spec 覆盖核对)

  • 工作空间列表/切换 → Task 5仓储+ Task 6控制器+ Task 7TopBar+ Task 10接线
  • 项目列表/切换 → 同上
  • 对象树 项目→GS→TM叶子=TM→ Task 4buildStructTree+ Task 8ObjectTreePanel
  • TM 下 DS 列表 → Task 3parseDatasets+ Task 5 + Task 10
  • 失败显示错误/空状态、不回退本地样本 → Task 8showMessage+ Task 10loadFailed 接线)
  • 中央三维编排解耦为数据驱动 helper保留可复用→ Task 9CentralScene
  • 渲染占位、移除启动 demo、保留 render/LocalSampleRepository/rebuildDetail → Task 10
  • 项目 crsCode 存入控制器(下一轮替换 EPSG:4547→ Task 6currentCrsCode_
  • 分层:接口(net 复用)/数据(data: 仓储+dto)/逻辑(controller)/UI(app) → 各 Task 就位、依赖单向向下
  • 纯逻辑单测dto + 树构建)→ Task 2/3/4 ;线程同步+WaitCursor → Task 10

下一轮(不在本计划,详见 spec §12.1

接真实 DS 渲染分四步render 层零改):A 取数(新增 DS 内容仓储方法dd/ert/exception/clr 接口)→ B 映射DTO → core::Grid/ScatterField/ColorScale/Anomaly,加单测)→ C 接线(构建 app::SectionInput 调 Task 9 的 rebuildCentralScene;真实数据触发保留的 rebuildDetail,替换占位)→ D 坐标系(用 currentCrsCode() 重建 GeoLocalFrame,替换硬编码 EPSG:4547。另异步仓储+分页、用户信息、token 过期跳登录、 体素/切片/地形真实数据源、顶部菜单接页面。