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

1645 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 接入真实导航(工作空间 / 项目 / 对象树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 + Ninjapreset `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": <data>}`
- 数组型接口(`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<T>`
- `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 <string>
#include <vector>
#include "repo/RepoTypes.hpp"
namespace geopro::data {
// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。
template <class T>
struct RepoResult {
bool ok = false;
T value{};
std::string error;
};
// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。
class IProjectRepository {
public:
virtual ~IProjectRepository() = default;
virtual RepoResult<std::vector<Workspace>> listWorkspaces() = 0;
virtual RepoResult<bool> switchWorkspace(const std::string& tenantId) = 0;
virtual RepoResult<std::vector<ProjectSummary>> listProjects(const std::string& lastProjectId) = 0;
virtual RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) = 0;
virtual RepoResult<std::vector<DsNode>> loadDatasetsOfTm(const std::string& tmObjectId) = 0;
};
} // namespace geopro::data
```
- [ ] **Step 3: 验证编译(语法)**
Run: `cmake --build build/release --target geopro_data`
Expected: 通过(仅头文件改动,`geopro_data` 仍按原样编译;新头未被引用属正常)。
- [ ] **Step 4: Commit**
```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 脚手架 + parseWorkspacesTDD打通测试构建
**Files:**
- Create: `src/data/dto/NavDto.hpp`, `src/data/dto/NavDto.cpp`
- Create: `tests/data/test_nav_dto.cpp`
- Modify: `src/data/CMakeLists.txt`, `tests/CMakeLists.txt`
- [ ] **Step 1: 写失败测试 `tests/data/test_nav_dto.cpp`**
```cpp
#include <gtest/gtest.h>
#include <QByteArray>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include "dto/NavDto.hpp"
using namespace geopro::data;
namespace {
QJsonArray arrOf(const char* json) {
return QJsonDocument::fromJson(QByteArray(json)).array();
}
} // namespace
TEST(NavDto, ParseWorkspacesMapsFieldsAndCurrentFlag) {
const auto arr = arrOf(R"([
{"id":"t1","name":"个人空间","ownerType":1,"isCurTenant":1},
{"id":"t2","name":"企业A","ownerType":2,"isCurTenant":0}
])");
const auto ws = dto::parseWorkspaces(arr);
ASSERT_EQ(ws.size(), 2u);
EXPECT_EQ(ws[0].id, "t1");
EXPECT_EQ(ws[0].ownerType, 1);
EXPECT_TRUE(ws[0].isCurrent);
EXPECT_FALSE(ws[1].isCurrent);
}
```
- [ ] **Step 2: 创建 `src/data/dto/NavDto.hpp`(声明全部函数,便于后续任务复用)**
```cpp
#pragma once
#include <vector>
#include <QJsonArray>
#include <QJsonObject>
#include "repo/RepoTypes.hpp"
namespace geopro::data::dto {
// 工作空间数组joined/list 的 data["value"])→ 模型。isCurTenant==1 → isCurrent。
std::vector<Workspace> parseWorkspaces(const QJsonArray& arr);
// 项目分页queryByUser 的 data 对象 {hasNextPage, projectList})→ 模型。
struct ProjectPage { std::vector<ProjectSummary> projects; bool hasNextPage = false; };
ProjectPage parseProjects(const QJsonObject& data);
// 结构扁平节点数组queryProjectStruct 的 data["projectStructList"])→ 模型。
std::vector<StructNode> parseStructNodes(const QJsonArray& arr);
// DS 聚合数组queryDsByTmObjectId 的 data["value"])→ DsNode。ddCode → ddType。
std::vector<DsNode> parseDatasets(const QJsonArray& arr);
// 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理项目直挂 TM、孤儿 parentId、空表。
struct StructTreeNode {
StructNode node;
bool isTm = false;
std::vector<StructTreeNode> children;
};
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat);
} // namespace geopro::data::dto
```
- [ ] **Step 3: 创建 `src/data/dto/NavDto.cpp`,仅实现 parseWorkspaces其余空桩后续任务填**
```cpp
#include "dto/NavDto.hpp"
#include <QJsonValue>
namespace geopro::data::dto {
namespace {
std::string str(const QJsonObject& o, const char* key) {
return o.value(QString::fromLatin1(key)).toString().toStdString();
}
} // namespace
std::vector<Workspace> parseWorkspaces(const QJsonArray& arr) {
std::vector<Workspace> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
Workspace w;
w.id = str(o, "id");
w.name = str(o, "name");
w.ownerType = o.value(QStringLiteral("ownerType")).toInt();
w.isCurrent = o.value(QStringLiteral("isCurTenant")).toInt() == 1;
out.push_back(std::move(w));
}
return out;
}
ProjectPage parseProjects(const QJsonObject&) { return {}; }
std::vector<StructNode> parseStructNodes(const QJsonArray&) { return {}; }
std::vector<DsNode> parseDatasets(const QJsonArray&) { return {}; }
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>&) { return {}; }
} // namespace geopro::data::dto
```
- [ ] **Step 4: 接入 `src/data/CMakeLists.txt`**
`add_library(geopro_data STATIC ...)` 源列表改为含新文件,并链接 `geopro_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 / parseDatasetsTDD
**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<size_t>(list.size()));
for (const QJsonValue& v : list) {
const QJsonObject o = v.toObject();
ProjectSummary p;
p.id = str(o, "id");
p.name = str(o, "projectName");
p.typeName = str(o, "projectTypeName");
p.crsCode = str(o, "referenceCRSCode");
p.crsName = str(o, "referenceCRSName");
p.status = o.value(QStringLiteral("status")).toInt();
page.projects.push_back(std::move(p));
}
return page;
}
std::vector<StructNode> parseStructNodes(const QJsonArray& arr) {
std::vector<StructNode> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
StructNode n;
n.id = str(o, "id");
n.name = str(o, "name");
n.parentId = str(o, "parentId");
n.typeName = str(o, "typeName");
n.confCode = str(o, "confCode");
n.type = o.value(QStringLiteral("type")).toInt();
out.push_back(std::move(n));
}
return out;
}
std::vector<DsNode> parseDatasets(const QJsonArray& arr) {
std::vector<DsNode> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
DsNode d;
d.id = str(o, "id");
d.name = str(o, "name");
d.ddType = str(o, "ddCode");
out.push_back(std::move(d));
}
return out;
}
```
- [ ] **Step 4: 运行确认通过**
Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NavDto --output-on-failure`
Expected: 全部 NavDto 用例 PASS。
- [ ] **Step 5: Commit**
```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<StructNode> flat = {
{"gs1", "工区1", "", "GS", "", 1},
{"tm1", "测线1", "gs1", "TM", "", 2},
{"tm2", "测线2", "gs1", "TM", "", 2},
{"tmD", "直挂测线", "", "TM", "", 2}, // TM 直挂项目(无 GS
};
const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 2u); // gs1 + tmD
EXPECT_EQ(roots[0].node.id, "gs1");
EXPECT_FALSE(roots[0].isTm); // 非叶 = GS
ASSERT_EQ(roots[0].children.size(), 2u);
EXPECT_EQ(roots[0].children[0].node.id, "tm1");
EXPECT_TRUE(roots[0].children[0].isTm); // 叶 = TM
EXPECT_EQ(roots[1].node.id, "tmD");
EXPECT_TRUE(roots[1].isTm); // 直挂项目的叶子 = TM
}
TEST(NavDto, BuildStructTreeOrphanParentBecomesRoot) {
const std::vector<StructNode> flat = {
{"tmX", "孤儿测线", "ghost", "TM", "", 2}, // parentId 不在集合内
};
const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 1u);
EXPECT_EQ(roots[0].node.id, "tmX");
EXPECT_TRUE(roots[0].isTm);
}
TEST(NavDto, BuildStructTreeEmpty) {
EXPECT_TRUE(dto::buildStructTree({}).empty());
}
```
- [ ] **Step 2: 运行确认失败**
Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R "NavDto.BuildStructTree" --output-on-failure`
Expected: FAIL空桩
- [ ] **Step 3: 实现 buildStructTree替换空桩并在文件顶部加 `#include <set>`, `#include <functional>`**
```cpp
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat) {
std::set<std::string> ids;
std::set<std::string> hasChild;
for (const auto& n : flat) {
ids.insert(n.id);
if (!n.parentId.empty()) hasChild.insert(n.parentId);
}
// 叶子(无子节点)= TM。
auto isLeaf = [&](const std::string& id) { return hasChild.find(id) == hasChild.end(); };
// 根层节点parentId 为空或 parentId 不在 id 集合内(孤儿)。
auto isRootLevel = [&](const StructNode& n) {
return n.parentId.empty() || ids.find(n.parentId) == ids.end();
};
std::function<std::vector<StructTreeNode>(const std::string&, bool)> build =
[&](const std::string& parentId, bool root) {
std::vector<StructTreeNode> out;
for (const auto& n : flat) {
const bool belongs = root ? isRootLevel(n) : (n.parentId == parentId);
if (!belongs) continue;
if (n.id == parentId) continue; // 防自环
StructTreeNode t;
t.node = n;
t.isTm = isLeaf(n.id);
t.children = build(n.id, false);
out.push_back(std::move(t));
}
return out;
};
return build(std::string(), true);
}
```
- [ ] **Step 4: 运行确认通过**
Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NavDto --output-on-failure`
Expected: 全部 NavDto 用例 PASS。
- [ ] **Step 5: Commit**
```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<std::vector<Workspace>> listWorkspaces() override;
RepoResult<bool> switchWorkspace(const std::string& tenantId) override;
RepoResult<std::vector<ProjectSummary>> listProjects(const std::string& lastProjectId) override;
RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) override;
RepoResult<std::vector<DsNode>> loadDatasetsOfTm(const std::string& tmObjectId) override;
private:
net::ApiClient& api_;
};
} // namespace geopro::data
```
- [ ] **Step 2: 创建 `src/data/api/ApiProjectRepository.cpp`**
```cpp
#include "api/ApiProjectRepository.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include "ApiClient.hpp"
#include "dto/NavDto.hpp"
namespace geopro::data {
namespace {
constexpr int kCodeSuccess = 200;
bool ok(const net::ApiResponse& r) { return r.code == kCodeSuccess; }
std::string errorOf(const net::ApiResponse& r, const char* fallback) {
if (!r.msg.isEmpty()) return r.msg.toStdString();
if (!r.rawError.isEmpty()) return r.rawError.toStdString();
return fallback;
}
} // namespace
ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {}
RepoResult<std::vector<Workspace>> ApiProjectRepository::listWorkspaces() {
const net::ApiResponse r =
api_.get(QStringLiteral("/business/system/tenant/enterprise/joined/list"));
if (!ok(r)) return {false, {}, errorOf(r, "listWorkspaces failed")};
return {true, dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()), {}};
}
RepoResult<bool> ApiProjectRepository::switchWorkspace(const std::string& tenantId) {
const QString path =
QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(QString::fromStdString(tenantId));
const net::ApiResponse r = api_.postJson(path, QJsonObject{});
if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")};
return {true, true, {}};
}
RepoResult<std::vector<ProjectSummary>> ApiProjectRepository::listProjects(
const std::string& lastProjectId) {
const QString path = QStringLiteral("/business/project/queryByUser?lastProjectId=%1")
.arg(QString::fromStdString(lastProjectId));
const net::ApiResponse r = api_.get(path);
if (!ok(r)) return {false, {}, errorOf(r, "listProjects failed")};
return {true, dto::parseProjects(r.data).projects, {}};
}
RepoResult<std::vector<StructNode>> ApiProjectRepository::loadStructure(const std::string& projectId) {
const QJsonObject body{{QStringLiteral("projectId"), QString::fromStdString(projectId)}};
const net::ApiResponse r =
api_.postJson(QStringLiteral("/business/projectWorkbench/queryProjectStruct"), body);
if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")};
return {true, dto::parseStructNodes(r.data.value(QStringLiteral("projectStructList")).toArray()), {}};
}
RepoResult<std::vector<DsNode>> ApiProjectRepository::loadDatasetsOfTm(const std::string& tmObjectId) {
const QString path = QStringLiteral("/business/projectWorkbench/queryDsByTmObjectId/%1")
.arg(QString::fromStdString(tmObjectId));
const net::ApiResponse r = api_.get(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetsOfTm failed")};
return {true, dto::parseDatasets(r.data.value(QStringLiteral("value")).toArray()), {}};
}
} // namespace geopro::data
```
- [ ] **Step 3: 把 api 源文件加回 `src/data/CMakeLists.txt`**
源列表改为:
```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 <QObject>
#include <QString>
#include <string>
#include <vector>
#include "repo/IProjectRepository.hpp"
namespace geopro::controller {
// 导航状态机:编排 IProjectRepository持有当前 空间/项目 状态,经信号驱动 UI。不持有 widget。
class WorkbenchNavController : public QObject {
Q_OBJECT
public:
explicit WorkbenchNavController(data::IProjectRepository& repo, QObject* parent = nullptr);
void start(); // 启动:拉空间 → 项目 → 结构
QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); }
public slots:
void switchWorkspace(const QString& tenantId);
void switchProject(const QString& projectId);
void selectTm(const QString& tmObjectId);
signals:
void busyChanged(bool busy);
void workspacesLoaded(const std::vector<geopro::data::Workspace>& list, const QString& currentId);
void projectsLoaded(const std::vector<geopro::data::ProjectSummary>& list, const QString& currentId);
void structureLoaded(const QString& projectName, const std::vector<geopro::data::StructNode>& nodes);
void datasetsLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsNode>& list);
void loadFailed(const QString& stage, const QString& message);
private:
void loadProjectsAndStructure(); // start + switchWorkspace 共用
data::IProjectRepository& repo_;
std::vector<data::ProjectSummary> lastProjects_;
std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
};
} // namespace geopro::controller
```
- [ ] **Step 2: 创建 `src/controller/WorkbenchNavController.cpp`**
```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 <QWidget>
#include <vector>
#include "repo/RepoTypes.hpp"
class QToolButton;
namespace geopro::app {
// 顶部菜单栏(静态,本轮不接真实页面)。
QWidget* buildMenuBar(QWidget* parent);
// 顶部工具条:数据驱动的工作空间/项目切换器 + 右侧图标 + 用户区。
class TopBar : public QWidget {
Q_OBJECT
public:
explicit TopBar(QWidget* parent = nullptr);
void setWorkspaces(const std::vector<data::Workspace>& list, const QString& currentId);
void setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId);
signals:
void workspaceSwitchRequested(const QString& tenantId);
void projectSwitchRequested(const QString& projectId);
private:
QToolButton* wsBtn_ = nullptr;
QToolButton* projBtn_ = nullptr;
};
} // namespace geopro::app
```
- [ ] **Step 2: 重写 `src/app/TopBar.cpp`**
保留文件原有匿名命名空间里的 `kToolIcon/kWorkspaceIcon/makeDivider/makeIconButton/buildViewMenu/buildProjectMenu/buildToolsMenu/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<data::Workspace>& list, const QString& currentId) {
auto* menu = new QMenu(wsBtn_);
auto* header = menu->addAction(QStringLiteral("切换空间"));
header->setEnabled(false);
menu->addSeparator();
QString currentName;
for (const auto& w : list) {
const QString id = QString::fromStdString(w.id);
const QString name = QString::fromStdString(w.name);
auto* a = menu->addAction(name);
a->setCheckable(true);
a->setChecked(id == currentId);
if (id == currentId) currentName = name;
QObject::connect(a, &QAction::triggered, this,
[this, id]() { emit workspaceSwitchRequested(id); });
}
if (list.empty()) {
auto* none = menu->addAction(QStringLiteral("(暂无空间)"));
none->setEnabled(false);
}
wsBtn_->setMenu(menu);
wsBtn_->setText((currentName.isEmpty() ? QStringLiteral("选择空间") : currentName) +
QStringLiteral(" ▾"));
}
void TopBar::setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId) {
auto* menu = new QMenu(projBtn_);
auto* header = menu->addAction(QStringLiteral("切换项目"));
header->setEnabled(false);
menu->addSeparator();
QString currentName;
for (const auto& p : list) {
const QString id = QString::fromStdString(p.id);
const QString name = QString::fromStdString(p.name);
auto* a = menu->addAction(name);
a->setCheckable(true);
a->setChecked(id == currentId);
if (id == currentId) currentName = name;
QObject::connect(a, &QAction::triggered, this,
[this, id]() { emit projectSwitchRequested(id); });
}
if (list.empty()) {
auto* none = menu->addAction(QStringLiteral("(暂无项目)"));
none->setEnabled(false);
}
projBtn_->setMenu(menu);
projBtn_->setText((currentName.isEmpty() ? QStringLiteral("选择项目") : currentName) +
QStringLiteral(" ▾"));
}
```
> 头文件包含:确保 TopBar.cpp 顶部已 `#include` 的 `<QActionGroup>` 可保留或删除;新增需要 `<QMenu> <QToolButton> <QHBoxLayout> <QVBoxLayout> <QLabel> <QSize> <QColor>`(原文件已包含这些)。`makeGlyph` 来自 `Glyphs.hpp`(原文件已 include
- [ ] **Step 3: 临时修正 main.cpp 调用点以保持可编译**
`main.cpp` 顶部菜单区块当前是:
```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 <QWidget>
#include <vector>
#include "repo/RepoTypes.hpp"
class QTreeWidget;
class QLabel;
namespace geopro::app {
// 被动对象树:项目根 → GS → TM叶子=TM可勾选。数据来自控制器自身不发请求。
class ObjectTreePanel : public QWidget {
Q_OBJECT
public:
explicit ObjectTreePanel(QWidget* parent = nullptr);
// 用扁平结构节点重建树(内部调 dto::buildStructTree
void setStructure(const QString& projectName, const std::vector<data::StructNode>& nodes);
void showMessage(const QString& message); // 错误/空状态占位
signals:
void tmClicked(const QString& tmObjectId);
void tmCheckToggled(const QString& tmObjectId, bool checked);
private:
QTreeWidget* tree_ = nullptr;
QLabel* hint_ = nullptr;
};
} // namespace geopro::app
```
- [ ] **Step 2: 创建 `src/app/panels/ObjectTreePanel.cpp`**
```cpp
#include "panels/ObjectTreePanel.hpp"
#include <QColor>
#include <QLabel>
#include <QSignalBlocker>
#include <QStackedLayout>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QVBoxLayout>
#include "Glyphs.hpp"
#include "dto/NavDto.hpp"
namespace geopro::app {
namespace {
// TM 节点把 tmObjectId 存在该角色GS/项目根节点为空。
constexpr int kRoleTmId = Qt::UserRole + 2;
void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes) {
for (const auto& n : nodes) {
auto* item = new QTreeWidgetItem(parent);
item->setText(0, QString::fromStdString(n.node.name));
if (n.isTm) {
item->setData(0, kRoleTmId, QString::fromStdString(n.node.id));
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(0, Qt::Unchecked); // 真实数据渲染下一轮接入,默认不勾
}
addNodes(item, n.children);
}
}
} // namespace
ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
tree_ = new QTreeWidget(this);
tree_->setHeaderHidden(true);
{
const QString openArrow = writeChevronIcon(true, QColor("#8A93A3"));
const QString closedArrow = writeChevronIcon(false, QColor("#8A93A3"));
tree_->setStyleSheet(
QStringLiteral("QTreeView::branch { background: #FFFFFF; }"
"QTreeView::branch:has-children:!has-siblings:closed,"
"QTreeView::branch:closed:has-children:has-siblings { image: url(%1); }"
"QTreeView::branch:open:has-children:!has-siblings,"
"QTreeView::branch:open:has-children:has-siblings { image: url(%2); }")
.arg(closedArrow, openArrow));
}
lay->addWidget(tree_, 1);
hint_ = new QLabel(QStringLiteral("(加载中…)"), this);
hint_->setAlignment(Qt::AlignCenter);
hint_->setStyleSheet(QStringLiteral("color:#9AA6B6; padding:16px;"));
hint_->setVisible(false);
lay->addWidget(hint_);
QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) {
const QString tmId = item->data(0, kRoleTmId).toString();
if (!tmId.isEmpty()) emit tmClicked(tmId);
});
QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* item, int) {
const QString tmId = item->data(0, kRoleTmId).toString();
if (!tmId.isEmpty())
emit tmCheckToggled(tmId, item->checkState(0) == Qt::Checked);
});
}
void ObjectTreePanel::setStructure(const QString& projectName,
const std::vector<data::StructNode>& nodes) {
const QSignalBlocker block(tree_); // 重建触发 itemChanged先屏蔽
tree_->clear();
const auto roots = data::dto::buildStructTree(nodes);
if (roots.empty()) {
showMessage(projectName.isEmpty() ? QStringLiteral("(暂无项目)")
: QStringLiteral("(该项目暂无结构)"));
return;
}
hint_->setVisible(false);
tree_->setVisible(true);
auto* rootItem = new QTreeWidgetItem(tree_);
rootItem->setText(0, projectName.isEmpty() ? QStringLiteral("项目") : projectName);
addNodes(rootItem, roots);
tree_->expandAll();
}
void ObjectTreePanel::showMessage(const QString& message) {
tree_->clear();
tree_->setVisible(false);
hint_->setText(message);
hint_->setVisible(true);
}
} // namespace geopro::app
```
> `writeChevronIcon` 来自 `Glyphs.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 4render 层Scene / GridContourActor / 相机预设)
geopro_controller # Phase 5导航编排WorkbenchNavController
)
```
- [ ] **Step 4: 构建确认通过**
Run: `cmake --preset msvc-release && cmake --build build/release --target geopro_desktop`
Expected: 编译/链接通过。
- [ ] **Step 5: Commit**
```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 <vector>
#include "model/ColorScale.hpp"
#include "model/Field.hpp"
namespace geopro::core { class GeoLocalFrame; }
namespace geopro::render { class Scene; }
class vtkRenderer;
class vtkRenderWindow;
namespace geopro::app {
// 中央视图模式:二维地图(测线红线俯视)/ 三维视图(断面墙)。
enum class ViewMode { Map2D, View3D };
// 一个待渲染剖面grid2D 测线 / 3D 帘面都用)+ colorScale3D 帘面上色)。
struct SectionInput {
geopro::core::Grid grid;
geopro::core::ColorScale colorScale;
};
// 中央场景重建(脱离对象树,按显式 sections 渲染):
// 2D = 每个 section 的 buildSurveyLine3D = 每个 section 的 buildCurtain受 showCurtain
// 下一轮接真实 DS构建 sections 后调用本函数即可render 层零改动。
void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
vtkRenderWindow* renderWindow, ViewMode mode,
const std::vector<SectionInput>& sections, bool showCurtain,
const geopro::core::GeoLocalFrame& frame, double verticalExaggeration);
} // namespace geopro::app
```
- [ ] **Step 2: 创建 `src/app/CentralScene.cpp`**
```cpp
#include "CentralScene.hpp"
#include <vtkActor.h>
#include <vtkRenderWindow.h>
#include <vtkRenderer.h>
#include "CameraPreset.hpp"
#include "Scene.hpp"
#include "actors/CurtainActor.hpp"
#include "actors/MapLineActor.hpp"
#include "geo/GeoLocalFrame.hpp"
namespace geopro::app {
void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
vtkRenderWindow* renderWindow, ViewMode mode,
const std::vector<SectionInput>& sections, bool showCurtain,
const geopro::core::GeoLocalFrame& frame, double verticalExaggeration) {
scene.clear();
const bool is2D = (mode == ViewMode::Map2D);
renderer->SetBackground(is2D ? 0.96 : 1.0, is2D ? 0.97 : 1.0, is2D ? 0.99 : 1.0);
for (const auto& s : sections) {
if (is2D) {
auto line = geopro::render::buildSurveyLine(s.grid, frame);
if (line) scene.addActor(line);
} else if (showCurtain) {
auto curtain = geopro::render::buildCurtain(s.grid, s.colorScale, frame);
if (curtain) {
curtain->SetScale(1.0, 1.0, verticalExaggeration); // 纵向夸张成墙
scene.addActor(curtain);
}
}
}
if (is2D)
geopro::render::applyTop2D(renderer);
else
geopro::render::applyFree3D(renderer);
renderer->ResetCamera();
renderWindow->Render();
}
} // namespace geopro::app
```
> 头文件名核对自现有 `main.cpp` 用法:`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::rebuildCentralSceneTask 9。本轮空 sections → 空背景占位。
// 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活spec §8.1 / §12.1)。
auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() {
geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode,
std::vector<geopro::app::SectionInput>{}, *showCurtain,
*frame, kVerticalExaggeration);
};
```
> 这样 `showVoxel/showTerrain/showSlice/slicePlane/crs/refElev/structure` 不再被 `rebuildCentral` 捕获使用;
> 它们的声明与图层勾选回调**保留**(渲染基础设施不删),仅不再产生可视效果。
并把"视图详情"浮层里**体素 / 切片 / 地形**三个勾选框本轮置灰提示(它们不绑定单 DS需独立真实数据源
见 spec §12.1 E。在三个 `chk*` 创建之后、`if (!crs)` 块附近,追加:
```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 1Task 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<geopro::data::Workspace>& list, const QString& cur) {
topBar->setWorkspaces(list, cur);
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar,
[topBar](const std::vector<geopro::data::ProjectSummary>& list,
const QString& cur) { topBar->setProjects(list, cur); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree,
[objectTree, datasetList, datasetTitle, datasetTabs](
const QString& projectName,
const std::vector<geopro::data::StructNode>& nodes) {
objectTree->setStructure(projectName, nodes);
datasetList->clear(); // 切项目清空 DS 列表
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏"));
datasetTabs->setTabText(0, QStringLiteral("数据"));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList,
[datasetList, datasetTitle, datasetTabs](
const QString&, const std::vector<geopro::data::DsNode>& list) {
geopro::app::populateDatasetList(datasetList, list);
if (datasetTitle)
datasetTitle->setText(QStringLiteral("数据集显示栏"));
datasetTabs->setTabText(
0, QStringLiteral("数据 (%1)").arg(static_cast<int>(list.size())));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::loadFailed, objectTree,
[objectTree, &window](const QString& stage, const QString& msg) {
if (stage == QStringLiteral("structure") ||
stage == QStringLiteral("projects"))
objectTree->showMessage(QStringLiteral("加载失败:%1").arg(msg));
window.statusBar()->showMessage(
QStringLiteral("加载失败(%1%2").arg(stage, msg), 8000);
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::busyChanged, &window,
[](bool busy) {
if (busy)
QApplication::setOverrideCursor(Qt::WaitCursor);
else
QApplication::restoreOverrideCursor();
});
```
> `populateDatasetList` 已在 `panels/DatasetListPanel.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 7TopBar+ Task 10接线
- 项目列表/切换 → 同上 ✅
- 对象树 项目→GS→TM叶子=TM→ Task 4buildStructTree+ Task 8ObjectTreePanel
- TM 下 DS 列表 → Task 3parseDatasets+ Task 5 + Task 10 ✅
- 失败显示错误/空状态、不回退本地样本 → Task 8showMessage+ Task 10loadFailed 接线)✅
- 中央三维编排解耦为数据驱动 helper保留可复用→ Task 9CentralScene
- 渲染占位、移除启动 demo、保留 render/LocalSampleRepository/rebuildDetail → Task 10 ✅
- 项目 crsCode 存入控制器(下一轮替换 EPSG:4547→ Task 6currentCrsCode_
- 分层:接口(net 复用)/数据(data: 仓储+dto)/逻辑(controller)/UI(app) → 各 Task 就位、依赖单向向下 ✅
- 纯逻辑单测dto + 树构建)→ Task 2/3/4 ✅;线程同步+WaitCursor → Task 10 ✅
## 下一轮(不在本计划,详见 spec §12.1
接真实 DS 渲染分四步render 层零改):**A 取数**(新增 DS 内容仓储方法dd/ert/exception/clr 接口)→
**B 映射**DTO → `core::Grid/ScatterField/ColorScale/Anomaly`,加单测)→ **C 接线**(构建 `app::SectionInput`
调 Task 9 的 `rebuildCentralScene`;真实数据触发保留的 `rebuildDetail`,替换占位)→ **D 坐标系**(用
`currentCrsCode()` 重建 `GeoLocalFrame`,替换硬编码 EPSG:4547。另异步仓储+分页、用户信息、token 过期跳登录、
体素/切片/地形真实数据源、顶部菜单接页面。