# 接入真实导航(工作空间 / 项目 / 对象树)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`(含扁平→树,可单测);新增 `controller` 层 `WorkbenchNavController`(QObject 状态机,信号驱动 UI);改造 `app` 层 `TopBar`(数据驱动类)+ 新增 `ObjectTreePanel`(被动视图)。依赖单向向下,UI 不直接碰 ApiClient。 **Tech Stack:** C++17, Qt6 (Core/Widgets/Network, QtJSON), GoogleTest + CTest, CMake + Ninja(preset `msvc-release`,构建目录 `build/release`)。 **参考 spec:** `docs/superpowers/specs/2026-06-09-real-api-navigation-design.md` --- ## 关键事实(实现前必读) - **成功判定**:`ApiResponse.code == 200`(见 `AuthService.cpp` 的 `kCodeSuccess`)。传输错误时 `code==0` 且 `rawError` 有值。 - **数组型 data**:`ApiClient::parseBody` 把非对象的 `data`(顶层数组/标量)包成 `{"value": }`。 - 数组型接口(`joined/list`、`queryDsByTmObjectId`)→ 读 `resp.data.value("value").toArray()`。 - 对象型接口(`queryByUser` 的 `{hasNextPage,projectList}`、`queryProjectStruct` 的 `{projectStructList,...}`)→ 直接读对象字段。 - **基址**:`ApiClient` 已设 `http://tenant.geomative.cn/pop-api`,路径以 `/business/...` 开头。 - **层级**:真实结构是 `项目 → GS → TM → DS`,结构接口只回 GS+TM 扁平列表(`parentId`),**TM = 叶子节点**;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` - `src/data/dto/NavDto.hpp` / `src/data/dto/NavDto.cpp` — JSON→模型纯映射 + `buildStructTree` - `src/data/api/ApiProjectRepository.hpp` / `.cpp` — 用 `ApiClient` 实现 `IProjectRepository` - `src/controller/CMakeLists.txt` — `geopro_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.txt` — `add_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` **保留不删**:`LocalSampleRepository`、`render/*`、现有详情/中央渲染代码。 --- ## Task 1: 导航模型 + 仓储接口(纯声明) **Files:** - Modify: `src/data/repo/RepoTypes.hpp` - Create: `src/data/repo/IProjectRepository.hpp` 纯数据结构与抽象接口,无行为,无单测;以"能编译"为验收。 - [ ] **Step 1: 追加导航模型到 RepoTypes.hpp** 把以下三个结构体加入 `namespace geopro::data {}`(放在 `Project` 之后、命名空间右括号之前): ```cpp // 工作空间(=企业租户/空间)。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** ```cpp #pragma once #include #include #include "repo/RepoTypes.hpp" namespace geopro::data { // 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。 template struct RepoResult { bool ok = false; T value{}; std::string error; }; // 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。 class IProjectRepository { public: virtual ~IProjectRepository() = default; virtual RepoResult> listWorkspaces() = 0; virtual RepoResult switchWorkspace(const std::string& tenantId) = 0; virtual RepoResult> listProjects(const std::string& lastProjectId) = 0; virtual RepoResult> loadStructure(const std::string& projectId) = 0; virtual RepoResult> 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** ```bash git add src/data/repo/RepoTypes.hpp src/data/repo/IProjectRepository.hpp git commit -m "feat(data): 导航模型(Workspace/ProjectSummary/StructNode) + IProjectRepository 接口" ``` --- ## Task 2: NavDto 脚手架 + parseWorkspaces(TDD,打通测试构建) **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`** ```cpp #include #include #include #include #include #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`(声明全部函数,便于后续任务复用)** ```cpp #pragma once #include #include #include #include "repo/RepoTypes.hpp" namespace geopro::data::dto { // 工作空间数组(joined/list 的 data["value"])→ 模型。isCurTenant==1 → isCurrent。 std::vector parseWorkspaces(const QJsonArray& arr); // 项目分页(queryByUser 的 data 对象 {hasNextPage, projectList})→ 模型。 struct ProjectPage { std::vector projects; bool hasNextPage = false; }; ProjectPage parseProjects(const QJsonObject& data); // 结构扁平节点数组(queryProjectStruct 的 data["projectStructList"])→ 模型。 std::vector parseStructNodes(const QJsonArray& arr); // DS 聚合数组(queryDsByTmObjectId 的 data["value"])→ DsNode。ddCode → ddType。 std::vector parseDatasets(const QJsonArray& arr); // 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理:项目直挂 TM、孤儿 parentId、空表。 struct StructTreeNode { StructNode node; bool isTm = false; std::vector children; }; std::vector buildStructTree(const std::vector& flat); } // namespace geopro::data::dto ``` - [ ] **Step 3: 创建 `src/data/dto/NavDto.cpp`,仅实现 parseWorkspaces(其余空桩,后续任务填)** ```cpp #include "dto/NavDto.hpp" #include 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 parseWorkspaces(const QJsonArray& arr) { std::vector out; out.reserve(static_cast(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 parseStructNodes(const QJsonArray&) { return {}; } std::vector parseDatasets(const QJsonArray&) { return {}; } std::vector buildStructTree(const std::vector&) { return {}; } } // namespace geopro::data::dto ``` - [ ] **Step 4: 接入 `src/data/CMakeLists.txt`** 把 `add_library(geopro_data STATIC ...)` 源列表改为含新文件,并链接 `geopro_net`(ApiProjectRepository 后续任务用到,提前接好): ```cmake 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.cpp`(Task 5 创建)。为让本任务先编译通过,**先创建占位空文件**: > Run: `printf '#include "api/ApiProjectRepository.hpp"\n' > src/data/api/ApiProjectRepository.cpp` —— 但其头文件 Task 5 才有。 > **改为**:本任务 CMake **暂不**加 `api/ApiProjectRepository.cpp` 那一行;只加 `dto/NavDto.cpp` 与 `geopro_net` 链接。Task 5 再补 api 行。即本步源列表实际为: > ```cmake > 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)` 之前或之后,加一行: ```cmake target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) ``` (`geopro_tests` 已链接 `geopro_data` 与 `Qt6::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** ```bash 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 / parseDatasets(TDD) **Files:** - Modify: `src/data/dto/NavDto.cpp`, `tests/data/test_nav_dto.cpp` - [ ] **Step 1: 追加失败测试到 `tests/data/test_nav_dto.cpp`** 文件顶部 `arrOf` 之后追加 `objOf` 辅助: ```cpp 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 中对应空桩)** ```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(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 parseStructNodes(const QJsonArray& arr) { std::vector out; out.reserve(static_cast(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 parseDatasets(const QJsonArray& arr) { std::vector out; out.reserve(static_cast(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** ```bash 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: 追加失败测试** ```cpp TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) { const std::vector 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 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 `, `#include `)** ```cpp std::vector buildStructTree(const std::vector& flat) { std::set ids; std::set 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(const std::string&, bool)> build = [&](const std::string& parentId, bool root) { std::vector 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** ```bash 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`** ```cpp #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> listWorkspaces() override; RepoResult switchWorkspace(const std::string& tenantId) override; RepoResult> listProjects(const std::string& lastProjectId) override; RepoResult> loadStructure(const std::string& projectId) override; RepoResult> loadDatasetsOfTm(const std::string& tmObjectId) override; private: net::ApiClient& api_; }; } // namespace geopro::data ``` - [ ] **Step 2: 创建 `src/data/api/ApiProjectRepository.cpp`** ```cpp #include "api/ApiProjectRepository.hpp" #include #include #include #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> 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 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> 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> 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> 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`** 源列表改为: ```cmake 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 测试仍 PASS(`ctest --test-dir build/release -R NavDto`)。 - [ ] **Step 5: Commit** ```bash 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`** ```cpp #pragma once #include #include #include #include #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& list, const QString& currentId); void projectsLoaded(const std::vector& list, const QString& currentId); void structureLoaded(const QString& projectName, const std::vector& nodes); void datasetsLoaded(const QString& tmObjectId, const std::vector& list); void loadFailed(const QString& stage, const QString& message); private: void loadProjectsAndStructure(); // start + switchWorkspace 共用 data::IProjectRepository& repo_; std::vector lastProjects_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; }; } // namespace geopro::controller ``` - [ ] **Step 2: 创建 `src/controller/WorkbenchNavController.cpp`** ```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`** ```cmake 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` 启用 controller(在 `add_subdirectory(app)` 之前)** ```cmake 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** ```bash 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`** ```cpp #pragma once #include #include #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& list, const QString& currentId); void setProjects(const std::vector& 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/buildDeviceMenu` 与 `buildMenuBar`(**不改**)。仅把末尾的 `buildTopToolBar(QWidget*)` 函数整段替换为下面的 `TopBar` 类实现: ```cpp 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& 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& 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` 的 `` 可保留或删除;新增需要 ` `(原文件已包含这些)。`makeGlyph` 来自 `Glyphs.hpp`(原文件已 include)。 - [ ] **Step 3: 临时修正 main.cpp 调用点以保持可编译** `main.cpp` 顶部菜单区块当前是: ```cpp topLayout->addWidget(geopro::app::buildMenuBar(topChrome)); topLayout->addWidget(geopro::app::buildTopToolBar(topChrome)); ``` 改为(Task 9 会再加信号接线): ```cpp 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** ```bash 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`** ```cpp #pragma once #include #include #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& 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`** ```cpp #include "panels/ObjectTreePanel.hpp" #include #include #include #include #include #include #include #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& 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& 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.hpp`(main.cpp 同名用法已验证存在)。 - [ ] **Step 3: 接入 `src/app/CMakeLists.txt`** 在 `add_executable(geopro_desktop WIN32 ...)` 源列表加 `panels/ObjectTreePanel.cpp`,并在 `target_link_libraries` 加 `geopro_controller`: ```cmake 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` 行后加: ```cmake geopro_render # Phase 4:render 层(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** ```bash 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`** ```cpp #pragma once #include #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 }; // 一个待渲染剖面:grid(2D 测线 / 3D 帘面都用)+ colorScale(3D 帘面上色)。 struct SectionInput { geopro::core::Grid grid; geopro::core::ColorScale colorScale; }; // 中央场景重建(脱离对象树,按显式 sections 渲染): // 2D = 每个 section 的 buildSurveyLine;3D = 每个 section 的 buildCurtain(受 showCurtain)。 // 下一轮接真实 DS:构建 sections 后调用本函数即可,render 层零改动。 void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, vtkRenderWindow* renderWindow, ViewMode mode, const std::vector& sections, bool showCurtain, const geopro::core::GeoLocalFrame& frame, double verticalExaggeration); } // namespace geopro::app ``` - [ ] **Step 2: 创建 `src/app/CentralScene.cpp`** ```cpp #include "CentralScene.hpp" #include #include #include #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& 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` 用法:`buildSurveyLine`→`actors/MapLineActor.hpp`, > `buildCurtain`→`actors/CurtainActor.hpp`,`applyTop2D/applyFree3D`→`CameraPreset.hpp`, > `Scene`→`Scene.hpp`。这些符号来自 `geopro_render`(app 已链接)。 - [ ] **Step 3: 接入 `src/app/CMakeLists.txt` 源列表** 在 `add_executable(geopro_desktop WIN32 ...)` 源列表加 `CentralScene.cpp`(与 Task 8 的 `panels/ObjectTreePanel.cpp` 同处): ```cmake panels/ObjectTreePanel.cpp CentralScene.cpp) ``` - [ ] **Step 4: 构建确认通过** Run: `cmake --build build/release --target geopro_desktop` Expected: 编译/链接通过(CentralScene 暂未被引用,单独编译即可)。 - [ ] **Step 5: Commit** ```bash 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"` 附近)加: ```cpp #include "CentralScene.hpp" #include "WorkbenchNavController.hpp" #include "api/ApiProjectRepository.hpp" #include "panels/ObjectTreePanel.hpp" ``` 并把匿名命名空间里原本的本地枚举定义: ```cpp enum class ViewMode { Map2D, View3D }; ``` 替换为复用 helper 的同名枚举(保持后续 `ViewMode::Map2D` 等引用不变): ```cpp using geopro::app::ViewMode; ``` - [ ] **Step 2: 修改 `buildWorkbench` 签名,注入控制器** 把: ```cpp void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo) ``` 改为: ```cpp 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 ...))`)。整段替换为: ```cpp // 左上 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); ``` > 同时删除文件顶部不再使用的 `populateTree`、`findTm`、`kRoleTmId` 三处(若编译报未使用可保留 `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 整体替换为: ```cpp // 中央编排已解耦到 CentralScene::rebuildCentralScene(Task 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{}, *showCurtain, *frame, kVerticalExaggeration); }; ``` > 这样 `showVoxel/showTerrain/showSlice/slicePlane/crs/refElev/structure` 不再被 `rebuildCentral` 捕获使用; > 它们的声明与图层勾选回调**保留**(渲染基础设施不删),仅不再产生可视效果。 并把"视图详情"浮层里**体素 / 切片 / 地形**三个勾选框本轮置灰提示(它们不绑定单 DS,需独立真实数据源, 见 spec §12.1 E)。在三个 `chk*` 创建之后、`if (!crs)` 块附近,追加: ```cpp // 本轮中央不接真实派生层:体素/切片/地形勾选置灰,待下一轮接入对应数据源。 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 点击改占位** 定位 `datasetList` 的 `itemClicked` 连接(调用 `loadDataset`)。把其回调替换为占位(不再加载本地样本): ```cpp 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 1(Task 7)里临时的 `new geopro::app::TopBar(topChrome)` 改为持有指针并接线。把顶部 chrome 区块替换为: ```cpp 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); } ``` 然后在其后加控制器接线: ```cpp // ── 控制器 ↔ 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& list, const QString& cur) { topBar->setWorkspaces(list, cur); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar, [topBar](const std::vector& 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& 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& list) { geopro::app::populateDatasetList(datasetList, list); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏")); datasetTabs->setTabText( 0, QStringLiteral("数据 (%1)").arg(static_cast(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.hpp`(main.cpp 已 include)。`datasetTitle/datasetTabs/datasetList` 为既有局部变量,确保接线代码在它们定义之后。 - [ ] **Step 9: 在 `main()` 构造仓储/控制器并启动** 定位 `main()` 中构建工作台处: ```cpp geopro::data::LocalSampleRepository repo( "D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/"); QMainWindow window; ... buildWorkbench(window, repo); window.show(); ``` 改为(`api` 已在上文构造并 `setToken`): ```cpp 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** ```bash git add src/app/main.cpp git commit -m "feat(app): 工作台接入真实导航(空间/项目/对象树/DS),中央渲染占位" ``` --- ## 自检结论(spec 覆盖核对) - 工作空间列表/切换 → Task 5(仓储)+ Task 6(控制器)+ Task 7(TopBar)+ Task 10(接线)✅ - 项目列表/切换 → 同上 ✅ - 对象树 项目→GS→TM(叶子=TM)→ Task 4(buildStructTree)+ Task 8(ObjectTreePanel)✅ - TM 下 DS 列表 → Task 3(parseDatasets)+ Task 5 + Task 10 ✅ - 失败显示错误/空状态、不回退本地样本 → Task 8(showMessage)+ Task 10(loadFailed 接线)✅ - 中央三维编排解耦为数据驱动 helper(保留可复用)→ Task 9(CentralScene)✅ - 渲染占位、移除启动 demo、保留 render/LocalSampleRepository/rebuildDetail → Task 10 ✅ - 项目 crsCode 存入控制器(下一轮替换 EPSG:4547)→ Task 6(currentCrsCode_)✅ - 分层:接口(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 过期跳登录、 体素/切片/地形真实数据源、顶部菜单接页面。