diff --git a/docs/superpowers/plans/2026-06-09-real-api-navigation.md b/docs/superpowers/plans/2026-06-09-real-api-navigation.md new file mode 100644 index 0000000..59d6eab --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-real-api-navigation.md @@ -0,0 +1,1497 @@ +# 接入真实导航(工作空间 / 项目 / 对象树)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` — 被动对象树视图 +- `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: main.cpp 接线(构造仓储/控制器 + 信号 + 移除启动 demo) + +**Files:** +- Modify: `src/app/main.cpp` + +把对象树/数据列表/顶部切换器接到控制器;移除启动自动渲染本地 demo;真实 DS 点击 → 中央/详情占位。 + +- [ ] **Step 1: 增加头文件 include** + +在 main.cpp 顶部 include 区(`#include "TopBar.hpp"` 附近)加: + +```cpp +#include "WorkbenchNavController.hpp" +#include "api/ApiProjectRepository.hpp" +#include "panels/ObjectTreePanel.hpp" +``` + +- [ ] **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: 中央视图改为占位(移除本地结构驱动)** + +`rebuildCentral` 原实现遍历 `tree` 勾选项并渲染本地 grid。本轮中央不接真实数据,改为:清空场景 + 应用背景,不再依赖 `tree`/`structure`。把 `rebuildCentral` lambda 整体替换为: + +```cpp + // 本轮中央视图不接真实剖面数据(下一轮接 dd 接口):仅维护视图模式背景,内容占位为空。 + auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, slicePlane]() { + if (*slicePlane) { (*slicePlane)->Off(); *slicePlane = nullptr; } + scene->clear(); + const bool is2D = (*viewMode == ViewMode::Map2D); + rendererPtr->SetBackground(is2D ? 0.96 : 1.0, is2D ? 0.97 : 1.0, is2D ? 0.99 : 1.0); + if (is2D) + geopro::render::applyTop2D(rendererPtr); + else + geopro::render::applyFree3D(rendererPtr); + rendererPtr->ResetCamera(); + renderWindowPtr->Render(); + }; +``` + +> 这样 `showVoxel/showTerrain/crs/frame/refElev` 等捕获不再被 `rebuildCentral` 使用。它们仍被其它 lambda(图层勾选)引用——图层勾选回调保留但因中央为空而无可视效果,本轮可接受;**不删**这些变量与回调(保留渲染基础设施)。 + +- [ ] **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 9(接线)✅ +- 项目列表/切换 → 同上 ✅ +- 对象树 项目→GS→TM(叶子=TM)→ Task 4(buildStructTree)+ Task 8(ObjectTreePanel)✅ +- TM 下 DS 列表 → Task 3(parseDatasets)+ Task 5 + Task 9 ✅ +- 失败显示错误/空状态、不回退本地样本 → Task 8(showMessage)+ Task 9(loadFailed 接线)✅ +- 渲染解耦占位、移除启动 demo、保留 render/LocalSampleRepository → Task 9 ✅ +- 项目 crsCode 存入控制器(下一轮替换 EPSG:4547)→ Task 6(currentCrsCode_)✅ +- 分层:接口(net 复用)/数据(data: 仓储+dto)/逻辑(controller)/UI(app) → 各 Task 就位、依赖单向向下 ✅ +- 纯逻辑单测(dto + 树构建)→ Task 2/3/4 ✅;线程同步+WaitCursor → Task 9 ✅ + +## 下一轮(不在本计划) +dd/ert/gpr 真实渲染替换占位;crsCode 重建 GeoLocalFrame;异步仓储 + 分页“加载更多”;用户信息 `auth/getUserInfo`;token 过期自动跳登录;顶部菜单接真实页面。