64 KiB
接入真实导航(工作空间 / 项目 / 对象树)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": <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→模型纯映射 +buildStructTreesrc/data/api/ApiProjectRepository.hpp/.cpp— 用ApiClient实现IProjectRepositorysrc/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 / StructNodesrc/data/CMakeLists.txt— 加源文件 + 链接geopro_netsrc/CMakeLists.txt—add_subdirectory(controller)(在 app 之前)src/app/CMakeLists.txt— 加panels/ObjectTreePanel.cpp+ 链接geopro_controllersrc/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 之后、命名空间右括号之前):
// 工作空间(=企业租户/空间)。ownerType: 1 个人空间 2 企业空间。
struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; };
// 项目摘要(列表用)。crsCode/crsName 为项目参考坐标系,下一轮替换硬编码 EPSG:4547。
struct ProjectSummary { std::string id, name, typeName, crsCode, crsName; int status = 0; };
// 项目结构扁平节点(仅 GS / TM)。客户端按 parentId 建树,叶子=TM。
struct StructNode { std::string id, name, parentId, typeName, confCode; int type = 0; };
- Step 2: 创建 IProjectRepository.hpp
#pragma once
#include <string>
#include <vector>
#include "repo/RepoTypes.hpp"
namespace geopro::data {
// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。
template <class T>
struct RepoResult {
bool ok = false;
T value{};
std::string error;
};
// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。
class IProjectRepository {
public:
virtual ~IProjectRepository() = default;
virtual RepoResult<std::vector<Workspace>> listWorkspaces() = 0;
virtual RepoResult<bool> switchWorkspace(const std::string& tenantId) = 0;
virtual RepoResult<std::vector<ProjectSummary>> listProjects(const std::string& lastProjectId) = 0;
virtual RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) = 0;
virtual RepoResult<std::vector<DsNode>> loadDatasetsOfTm(const std::string& tmObjectId) = 0;
};
} // namespace geopro::data
- Step 3: 验证编译(语法)
Run: cmake --build build/release --target geopro_data
Expected: 通过(仅头文件改动,geopro_data 仍按原样编译;新头未被引用属正常)。
- Step 4: Commit
git add src/data/repo/RepoTypes.hpp src/data/repo/IProjectRepository.hpp
git commit -m "feat(data): 导航模型(Workspace/ProjectSummary/StructNode) + IProjectRepository 接口"
Task 2: NavDto 脚手架 + 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
#include <gtest/gtest.h>
#include <QByteArray>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include "dto/NavDto.hpp"
using namespace geopro::data;
namespace {
QJsonArray arrOf(const char* json) {
return QJsonDocument::fromJson(QByteArray(json)).array();
}
} // namespace
TEST(NavDto, ParseWorkspacesMapsFieldsAndCurrentFlag) {
const auto arr = arrOf(R"([
{"id":"t1","name":"个人空间","ownerType":1,"isCurTenant":1},
{"id":"t2","name":"企业A","ownerType":2,"isCurTenant":0}
])");
const auto ws = dto::parseWorkspaces(arr);
ASSERT_EQ(ws.size(), 2u);
EXPECT_EQ(ws[0].id, "t1");
EXPECT_EQ(ws[0].ownerType, 1);
EXPECT_TRUE(ws[0].isCurrent);
EXPECT_FALSE(ws[1].isCurrent);
}
- Step 2: 创建
src/data/dto/NavDto.hpp(声明全部函数,便于后续任务复用)
#pragma once
#include <vector>
#include <QJsonArray>
#include <QJsonObject>
#include "repo/RepoTypes.hpp"
namespace geopro::data::dto {
// 工作空间数组(joined/list 的 data["value"])→ 模型。isCurTenant==1 → isCurrent。
std::vector<Workspace> parseWorkspaces(const QJsonArray& arr);
// 项目分页(queryByUser 的 data 对象 {hasNextPage, projectList})→ 模型。
struct ProjectPage { std::vector<ProjectSummary> projects; bool hasNextPage = false; };
ProjectPage parseProjects(const QJsonObject& data);
// 结构扁平节点数组(queryProjectStruct 的 data["projectStructList"])→ 模型。
std::vector<StructNode> parseStructNodes(const QJsonArray& arr);
// DS 聚合数组(queryDsByTmObjectId 的 data["value"])→ DsNode。ddCode → ddType。
std::vector<DsNode> parseDatasets(const QJsonArray& arr);
// 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理:项目直挂 TM、孤儿 parentId、空表。
struct StructTreeNode {
StructNode node;
bool isTm = false;
std::vector<StructTreeNode> children;
};
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat);
} // namespace geopro::data::dto
- Step 3: 创建
src/data/dto/NavDto.cpp,仅实现 parseWorkspaces(其余空桩,后续任务填)
#include "dto/NavDto.hpp"
#include <QJsonValue>
namespace geopro::data::dto {
namespace {
std::string str(const QJsonObject& o, const char* key) {
return o.value(QString::fromLatin1(key)).toString().toStdString();
}
} // namespace
std::vector<Workspace> parseWorkspaces(const QJsonArray& arr) {
std::vector<Workspace> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
Workspace w;
w.id = str(o, "id");
w.name = str(o, "name");
w.ownerType = o.value(QStringLiteral("ownerType")).toInt();
w.isCurrent = o.value(QStringLiteral("isCurTenant")).toInt() == 1;
out.push_back(std::move(w));
}
return out;
}
ProjectPage parseProjects(const QJsonObject&) { return {}; }
std::vector<StructNode> parseStructNodes(const QJsonArray&) { return {}; }
std::vector<DsNode> parseDatasets(const QJsonArray&) { return {}; }
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>&) { return {}; }
} // namespace geopro::data::dto
- Step 4: 接入
src/data/CMakeLists.txt
把 add_library(geopro_data STATIC ...) 源列表改为含新文件,并链接 geopro_net(ApiProjectRepository 后续任务用到,提前接好):
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 行。即本步源列表实际为:add_library(geopro_data STATIC parse/SampleParsers.cpp repo/LocalSampleRepository.cpp dto/NavDto.cpp)链接行已含
geopro_net(无害,net 已存在)。
- Step 5: 接入
tests/CMakeLists.txt
在 target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) 之后、target_link_libraries(geopro_tests PRIVATE geopro_data) 之前或之后,加一行:
target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp)
(geopro_tests 已链接 geopro_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
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 辅助:
namespace {
QJsonObject objOf(const char* json) {
return QJsonDocument::fromJson(QByteArray(json)).object();
}
} // namespace
TEST(NavDto, ParseProjectsMapsCrsAndPaging) {
const auto data = objOf(R"({
"hasNextPage": true,
"projectList": [
{"id":"p1","projectName":"青海湖北岸","projectTypeName":"ERT",
"referenceCRSCode":"EPSG:4547","referenceCRSName":"CGCS2000","status":1}
]
})");
const auto page = dto::parseProjects(data);
EXPECT_TRUE(page.hasNextPage);
ASSERT_EQ(page.projects.size(), 1u);
EXPECT_EQ(page.projects[0].id, "p1");
EXPECT_EQ(page.projects[0].name, "青海湖北岸");
EXPECT_EQ(page.projects[0].typeName, "ERT");
EXPECT_EQ(page.projects[0].crsCode, "EPSG:4547");
EXPECT_EQ(page.projects[0].status, 1);
}
TEST(NavDto, ParseStructNodesMapsParentAndType) {
const auto arr = arrOf(R"([
{"id":"gs1","name":"工区1","parentId":"","type":1,"typeName":"GS","confCode":""},
{"id":"tm1","name":"测线1","parentId":"gs1","type":2,"typeName":"TM","confCode":"ERT"}
])");
const auto ns = dto::parseStructNodes(arr);
ASSERT_EQ(ns.size(), 2u);
EXPECT_EQ(ns[0].id, "gs1");
EXPECT_EQ(ns[1].parentId, "gs1");
EXPECT_EQ(ns[1].confCode, "ERT");
EXPECT_EQ(ns[1].type, 2);
}
TEST(NavDto, ParseDatasetsMapsDdCodeToDdType) {
const auto arr = arrOf(R"([
{"id":"ds1","name":"批次1","ddCode":"dd_section","typeName":"剖面"}
])");
const auto ds = dto::parseDatasets(arr);
ASSERT_EQ(ds.size(), 1u);
EXPECT_EQ(ds[0].id, "ds1");
EXPECT_EQ(ds[0].name, "批次1");
EXPECT_EQ(ds[0].ddType, "dd_section");
}
- Step 2: 运行确认失败
Run: cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NavDto --output-on-failure
Expected: 三个新用例 FAIL(空桩返回空)。
- Step 3: 实现三个函数(替换 NavDto.cpp 中对应空桩)
ProjectPage parseProjects(const QJsonObject& data) {
ProjectPage page;
page.hasNextPage = data.value(QStringLiteral("hasNextPage")).toBool();
const QJsonArray list = data.value(QStringLiteral("projectList")).toArray();
page.projects.reserve(static_cast<size_t>(list.size()));
for (const QJsonValue& v : list) {
const QJsonObject o = v.toObject();
ProjectSummary p;
p.id = str(o, "id");
p.name = str(o, "projectName");
p.typeName = str(o, "projectTypeName");
p.crsCode = str(o, "referenceCRSCode");
p.crsName = str(o, "referenceCRSName");
p.status = o.value(QStringLiteral("status")).toInt();
page.projects.push_back(std::move(p));
}
return page;
}
std::vector<StructNode> parseStructNodes(const QJsonArray& arr) {
std::vector<StructNode> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
StructNode n;
n.id = str(o, "id");
n.name = str(o, "name");
n.parentId = str(o, "parentId");
n.typeName = str(o, "typeName");
n.confCode = str(o, "confCode");
n.type = o.value(QStringLiteral("type")).toInt();
out.push_back(std::move(n));
}
return out;
}
std::vector<DsNode> parseDatasets(const QJsonArray& arr) {
std::vector<DsNode> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
DsNode d;
d.id = str(o, "id");
d.name = str(o, "name");
d.ddType = str(o, "ddCode");
out.push_back(std::move(d));
}
return out;
}
- Step 4: 运行确认通过
Run: cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NavDto --output-on-failure
Expected: 全部 NavDto 用例 PASS。
- Step 5: Commit
git add src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp
git commit -m "feat(data): NavDto parseProjects/parseStructNodes/parseDatasets"
Task 4: buildStructTree 扁平→树(TDD)
Files:
-
Modify:
src/data/dto/NavDto.cpp,tests/data/test_nav_dto.cpp -
Step 1: 追加失败测试
TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) {
const std::vector<StructNode> flat = {
{"gs1", "工区1", "", "GS", "", 1},
{"tm1", "测线1", "gs1", "TM", "", 2},
{"tm2", "测线2", "gs1", "TM", "", 2},
{"tmD", "直挂测线", "", "TM", "", 2}, // TM 直挂项目(无 GS)
};
const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 2u); // gs1 + tmD
EXPECT_EQ(roots[0].node.id, "gs1");
EXPECT_FALSE(roots[0].isTm); // 非叶 = GS
ASSERT_EQ(roots[0].children.size(), 2u);
EXPECT_EQ(roots[0].children[0].node.id, "tm1");
EXPECT_TRUE(roots[0].children[0].isTm); // 叶 = TM
EXPECT_EQ(roots[1].node.id, "tmD");
EXPECT_TRUE(roots[1].isTm); // 直挂项目的叶子 = TM
}
TEST(NavDto, BuildStructTreeOrphanParentBecomesRoot) {
const std::vector<StructNode> flat = {
{"tmX", "孤儿测线", "ghost", "TM", "", 2}, // parentId 不在集合内
};
const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 1u);
EXPECT_EQ(roots[0].node.id, "tmX");
EXPECT_TRUE(roots[0].isTm);
}
TEST(NavDto, BuildStructTreeEmpty) {
EXPECT_TRUE(dto::buildStructTree({}).empty());
}
- Step 2: 运行确认失败
Run: cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R "NavDto.BuildStructTree" --output-on-failure
Expected: FAIL(空桩)。
- Step 3: 实现 buildStructTree(替换空桩;并在文件顶部加
#include <set>,#include <functional>)
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat) {
std::set<std::string> ids;
std::set<std::string> hasChild;
for (const auto& n : flat) {
ids.insert(n.id);
if (!n.parentId.empty()) hasChild.insert(n.parentId);
}
// 叶子(无子节点)= TM。
auto isLeaf = [&](const std::string& id) { return hasChild.find(id) == hasChild.end(); };
// 根层节点:parentId 为空或 parentId 不在 id 集合内(孤儿)。
auto isRootLevel = [&](const StructNode& n) {
return n.parentId.empty() || ids.find(n.parentId) == ids.end();
};
std::function<std::vector<StructTreeNode>(const std::string&, bool)> build =
[&](const std::string& parentId, bool root) {
std::vector<StructTreeNode> out;
for (const auto& n : flat) {
const bool belongs = root ? isRootLevel(n) : (n.parentId == parentId);
if (!belongs) continue;
if (n.id == parentId) continue; // 防自环
StructTreeNode t;
t.node = n;
t.isTm = isLeaf(n.id);
t.children = build(n.id, false);
out.push_back(std::move(t));
}
return out;
};
return build(std::string(), true);
}
- Step 4: 运行确认通过
Run: cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NavDto --output-on-failure
Expected: 全部 NavDto 用例 PASS。
- Step 5: Commit
git add src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp
git commit -m "feat(data): buildStructTree 扁平→树(叶子=TM,含直挂/孤儿/空表)"
Task 5: ApiProjectRepository 实现(接口层↔仓储)
Files:
- Create:
src/data/api/ApiProjectRepository.hpp,src/data/api/ApiProjectRepository.cpp - Modify:
src/data/CMakeLists.txt
无单测(依赖 live 服务器,按 spec §9 走手动联调);以"编译 + 链接通过"为本任务验收。
- Step 1: 创建
src/data/api/ApiProjectRepository.hpp
#pragma once
#include "repo/IProjectRepository.hpp"
namespace geopro::net { class ApiClient; }
namespace geopro::data {
// 用共享会话 ApiClient 实现导航仓储(同步阻塞)。token 由调用方注入 ApiClient。
class ApiProjectRepository : public IProjectRepository {
public:
explicit ApiProjectRepository(net::ApiClient& api);
RepoResult<std::vector<Workspace>> listWorkspaces() override;
RepoResult<bool> switchWorkspace(const std::string& tenantId) override;
RepoResult<std::vector<ProjectSummary>> listProjects(const std::string& lastProjectId) override;
RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) override;
RepoResult<std::vector<DsNode>> loadDatasetsOfTm(const std::string& tmObjectId) override;
private:
net::ApiClient& api_;
};
} // namespace geopro::data
- Step 2: 创建
src/data/api/ApiProjectRepository.cpp
#include "api/ApiProjectRepository.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include "ApiClient.hpp"
#include "dto/NavDto.hpp"
namespace geopro::data {
namespace {
constexpr int kCodeSuccess = 200;
bool ok(const net::ApiResponse& r) { return r.code == kCodeSuccess; }
std::string errorOf(const net::ApiResponse& r, const char* fallback) {
if (!r.msg.isEmpty()) return r.msg.toStdString();
if (!r.rawError.isEmpty()) return r.rawError.toStdString();
return fallback;
}
} // namespace
ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {}
RepoResult<std::vector<Workspace>> ApiProjectRepository::listWorkspaces() {
const net::ApiResponse r =
api_.get(QStringLiteral("/business/system/tenant/enterprise/joined/list"));
if (!ok(r)) return {false, {}, errorOf(r, "listWorkspaces failed")};
return {true, dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()), {}};
}
RepoResult<bool> ApiProjectRepository::switchWorkspace(const std::string& tenantId) {
const QString path =
QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(QString::fromStdString(tenantId));
const net::ApiResponse r = api_.postJson(path, QJsonObject{});
if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")};
return {true, true, {}};
}
RepoResult<std::vector<ProjectSummary>> ApiProjectRepository::listProjects(
const std::string& lastProjectId) {
const QString path = QStringLiteral("/business/project/queryByUser?lastProjectId=%1")
.arg(QString::fromStdString(lastProjectId));
const net::ApiResponse r = api_.get(path);
if (!ok(r)) return {false, {}, errorOf(r, "listProjects failed")};
return {true, dto::parseProjects(r.data).projects, {}};
}
RepoResult<std::vector<StructNode>> ApiProjectRepository::loadStructure(const std::string& projectId) {
const QJsonObject body{{QStringLiteral("projectId"), QString::fromStdString(projectId)}};
const net::ApiResponse r =
api_.postJson(QStringLiteral("/business/projectWorkbench/queryProjectStruct"), body);
if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")};
return {true, dto::parseStructNodes(r.data.value(QStringLiteral("projectStructList")).toArray()), {}};
}
RepoResult<std::vector<DsNode>> ApiProjectRepository::loadDatasetsOfTm(const std::string& tmObjectId) {
const QString path = QStringLiteral("/business/projectWorkbench/queryDsByTmObjectId/%1")
.arg(QString::fromStdString(tmObjectId));
const net::ApiResponse r = api_.get(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetsOfTm failed")};
return {true, dto::parseDatasets(r.data.value(QStringLiteral("value")).toArray()), {}};
}
} // namespace geopro::data
- Step 3: 把 api 源文件加回
src/data/CMakeLists.txt
源列表改为:
add_library(geopro_data STATIC
parse/SampleParsers.cpp
repo/LocalSampleRepository.cpp
dto/NavDto.cpp
api/ApiProjectRepository.cpp)
(geopro_net 链接 Task 2 已加。)
- Step 4: 构建确认通过
Run: cmake --build build/release --target geopro_data && cmake --build build/release --target geopro_tests
Expected: 编译/链接通过;NavDto 测试仍 PASS(ctest --test-dir build/release -R NavDto)。
- Step 5: Commit
git add src/data/api/ApiProjectRepository.hpp src/data/api/ApiProjectRepository.cpp src/data/CMakeLists.txt
git commit -m "feat(data): ApiProjectRepository 实现 5 个导航接口"
Task 6: WorkbenchNavController(逻辑层)
Files:
- Create:
src/controller/CMakeLists.txt,src/controller/WorkbenchNavController.hpp,src/controller/WorkbenchNavController.cpp - Modify:
src/CMakeLists.txt
无单测(依赖仓储 + Qt 事件,手动联调);以"编译 + 链接通过"验收。
- Step 1: 创建
src/controller/WorkbenchNavController.hpp
#pragma once
#include <QObject>
#include <QString>
#include <string>
#include <vector>
#include "repo/IProjectRepository.hpp"
namespace geopro::controller {
// 导航状态机:编排 IProjectRepository,持有当前 空间/项目 状态,经信号驱动 UI。不持有 widget。
class WorkbenchNavController : public QObject {
Q_OBJECT
public:
explicit WorkbenchNavController(data::IProjectRepository& repo, QObject* parent = nullptr);
void start(); // 启动:拉空间 → 项目 → 结构
QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); }
public slots:
void switchWorkspace(const QString& tenantId);
void switchProject(const QString& projectId);
void selectTm(const QString& tmObjectId);
signals:
void busyChanged(bool busy);
void workspacesLoaded(const std::vector<geopro::data::Workspace>& list, const QString& currentId);
void projectsLoaded(const std::vector<geopro::data::ProjectSummary>& list, const QString& currentId);
void structureLoaded(const QString& projectName, const std::vector<geopro::data::StructNode>& nodes);
void datasetsLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsNode>& list);
void loadFailed(const QString& stage, const QString& message);
private:
void loadProjectsAndStructure(); // start + switchWorkspace 共用
data::IProjectRepository& repo_;
std::vector<data::ProjectSummary> lastProjects_;
std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
};
} // namespace geopro::controller
- Step 2: 创建
src/controller/WorkbenchNavController.cpp
#include "WorkbenchNavController.hpp"
namespace geopro::controller {
using data::ProjectSummary;
using data::Workspace;
WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent)
: QObject(parent), repo_(repo) {}
void WorkbenchNavController::start() {
emit busyChanged(true);
const auto ws = repo_.listWorkspaces();
if (!ws.ok) {
emit busyChanged(false);
emit loadFailed(QStringLiteral("workspaces"), QString::fromStdString(ws.error));
return;
}
QString cur;
for (const auto& w : ws.value)
if (w.isCurrent) cur = QString::fromStdString(w.id);
if (cur.isEmpty() && !ws.value.empty()) cur = QString::fromStdString(ws.value.front().id);
currentWorkspaceId_ = cur.toStdString();
emit workspacesLoaded(ws.value, cur);
loadProjectsAndStructure();
emit busyChanged(false);
}
void WorkbenchNavController::loadProjectsAndStructure() {
const auto ps = repo_.listProjects(std::string());
if (!ps.ok) {
emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error));
return;
}
lastProjects_ = ps.value;
QString curP;
if (!ps.value.empty()) {
const auto& first = ps.value.front();
curP = QString::fromStdString(first.id);
currentProjectId_ = first.id;
currentProjectName_ = first.name;
currentCrsCode_ = first.crsCode;
} else {
currentProjectId_.clear();
currentProjectName_.clear();
currentCrsCode_.clear();
}
emit projectsLoaded(ps.value, curP);
if (curP.isEmpty()) {
emit structureLoaded(QString(), {}); // 暂无项目 → 空树
return;
}
const auto st = repo_.loadStructure(currentProjectId_);
if (!st.ok) {
emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error));
return;
}
emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);
}
void WorkbenchNavController::switchWorkspace(const QString& tenantId) {
if (tenantId.isEmpty()) return;
emit busyChanged(true);
const auto r = repo_.switchWorkspace(tenantId.toStdString());
if (!r.ok) {
emit busyChanged(false);
emit loadFailed(QStringLiteral("switchWorkspace"), QString::fromStdString(r.error));
return;
}
currentWorkspaceId_ = tenantId.toStdString();
loadProjectsAndStructure();
emit busyChanged(false);
}
void WorkbenchNavController::switchProject(const QString& projectId) {
if (projectId.isEmpty()) return;
emit busyChanged(true);
currentProjectId_ = projectId.toStdString();
for (const auto& p : lastProjects_)
if (p.id == currentProjectId_) {
currentProjectName_ = p.name;
currentCrsCode_ = p.crsCode;
}
const auto st = repo_.loadStructure(currentProjectId_);
if (!st.ok) {
emit busyChanged(false);
emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error));
return;
}
emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);
emit busyChanged(false);
}
void WorkbenchNavController::selectTm(const QString& tmObjectId) {
if (tmObjectId.isEmpty()) return;
emit busyChanged(true);
const auto ds = repo_.loadDatasetsOfTm(tmObjectId.toStdString());
emit busyChanged(false);
if (!ds.ok) {
emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(ds.error));
return;
}
emit datasetsLoaded(tmObjectId, ds.value);
}
} // namespace geopro::controller
- Step 3: 创建
src/controller/CMakeLists.txt
find_package(Qt6 COMPONENTS Core REQUIRED)
add_library(geopro_controller STATIC
WorkbenchNavController.cpp)
target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core)
target_compile_features(geopro_controller PUBLIC cxx_std_17)
set_target_properties(geopro_controller PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF)
- Step 4: 在
src/CMakeLists.txt启用 controller(在add_subdirectory(app)之前)
add_subdirectory(core)
add_subdirectory(data)
add_subdirectory(net)
add_subdirectory(render)
add_subdirectory(controller)
add_subdirectory(app)
- Step 5: 配置 + 构建确认通过
Run: cmake --preset msvc-release && cmake --build build/release --target geopro_controller
Expected: 编译/链接通过(含 AUTOMOC 生成 moc)。
- Step 6: Commit
git add src/controller/ src/CMakeLists.txt
git commit -m "feat(controller): WorkbenchNavController 导航状态机"
Task 7: TopBar 升级为数据驱动类(UI 层)
Files:
- Modify:
src/app/TopBar.hpp,src/app/TopBar.cpp,src/app/main.cpp
把 buildTopToolBar 自由函数替换为 TopBar 类(保留 buildMenuBar 自由函数)。本任务保持可编译;信号接线在 Task 9。
- Step 1: 重写
src/app/TopBar.hpp
#pragma once
#include <QWidget>
#include <vector>
#include "repo/RepoTypes.hpp"
class QToolButton;
namespace geopro::app {
// 顶部菜单栏(静态,本轮不接真实页面)。
QWidget* buildMenuBar(QWidget* parent);
// 顶部工具条:数据驱动的工作空间/项目切换器 + 右侧图标 + 用户区。
class TopBar : public QWidget {
Q_OBJECT
public:
explicit TopBar(QWidget* parent = nullptr);
void setWorkspaces(const std::vector<data::Workspace>& list, const QString& currentId);
void setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId);
signals:
void workspaceSwitchRequested(const QString& tenantId);
void projectSwitchRequested(const QString& projectId);
private:
QToolButton* wsBtn_ = nullptr;
QToolButton* projBtn_ = nullptr;
};
} // namespace geopro::app
- Step 2: 重写
src/app/TopBar.cpp
保留文件原有匿名命名空间里的 kToolIcon/kWorkspaceIcon/makeDivider/makeIconButton/buildViewMenu/buildProjectMenu/buildToolsMenu/buildDeviceMenu 与 buildMenuBar(不改)。仅把末尾的 buildTopToolBar(QWidget*) 函数整段替换为下面的 TopBar 类实现:
TopBar::TopBar(QWidget* parent) : QWidget(parent) {
setObjectName(QStringLiteral("appToolBar"));
setFixedHeight(56);
setStyleSheet(QStringLiteral(
"#appToolBar { background:#FFFFFF; border-bottom:1px solid #E1E6EE; }"
"#topDivider { color:#E1E6EE; }"
"#wsSwitcher { color:#1F2A3D; border:none; border-radius:8px; padding:8px 12px;"
" font-size:14px; font-weight:600; }"
"#wsSwitcher:hover { background:#EEF3FB; }"
"QToolButton#iconBtn { border:none; border-radius:8px; padding:8px; }"
"QToolButton#iconBtn:hover { background:#EEF3FB; }"
"QToolButton::menu-indicator { image:none; }"
"#avatar { background:#2D6CB5; color:#FFFFFF; border-radius:17px; font-weight:700;"
" font-size:13px; }"
"#userName { color:#1F2A3D; font-size:13px; font-weight:600; }"
"#userRole { color:#8A93A3; font-size:11px; }"));
auto* lay = new QHBoxLayout(this);
lay->setContentsMargins(14, 0, 14, 0);
lay->setSpacing(0);
// 工作空间切换器(数据驱动;初始占位文本,待 setWorkspaces 填充)。
wsBtn_ = new QToolButton(this);
wsBtn_->setObjectName(QStringLiteral("wsSwitcher"));
wsBtn_->setIcon(makeGlyph(Glyph::Workspace, QColor("#2D6CB5"), kWorkspaceIcon));
wsBtn_->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
wsBtn_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
wsBtn_->setPopupMode(QToolButton::InstantPopup);
wsBtn_->setCursor(Qt::PointingHandCursor);
wsBtn_->setText(QStringLiteral("(加载中…)"));
wsBtn_->setMenu(new QMenu(wsBtn_));
lay->addWidget(wsBtn_);
lay->addSpacing(10);
lay->addWidget(makeDivider(this));
lay->addSpacing(10);
// 项目切换器(数据驱动)。
projBtn_ = new QToolButton(this);
projBtn_->setObjectName(QStringLiteral("wsSwitcher"));
projBtn_->setIcon(makeGlyph(Glyph::Folder, QColor("#2D6CB5"), kWorkspaceIcon));
projBtn_->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
projBtn_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
projBtn_->setPopupMode(QToolButton::InstantPopup);
projBtn_->setCursor(Qt::PointingHandCursor);
projBtn_->setText(QStringLiteral("(加载中…)"));
projBtn_->setMenu(new QMenu(projBtn_));
lay->addWidget(projBtn_);
lay->addStretch();
lay->addWidget(makeIconButton(this, Glyph::Help, QStringLiteral("帮助")));
lay->addWidget(makeIconButton(this, Glyph::Bell, QStringLiteral("通知")));
lay->addWidget(makeIconButton(this, Glyph::Gear, QStringLiteral("设置")));
lay->addSpacing(10);
lay->addWidget(makeDivider(this));
lay->addSpacing(12);
// 用户区(本轮静态)。
auto* avatar = new QLabel(QStringLiteral("ZL"), this);
avatar->setObjectName(QStringLiteral("avatar"));
avatar->setFixedSize(34, 34);
avatar->setAlignment(Qt::AlignCenter);
lay->addWidget(avatar);
lay->addSpacing(8);
auto* userBox = new QWidget(this);
auto* userLay = new QVBoxLayout(userBox);
userLay->setContentsMargins(0, 0, 0, 0);
userLay->setSpacing(0);
auto* userName = new QLabel(QStringLiteral("张磊"), userBox);
userName->setObjectName(QStringLiteral("userName"));
auto* userRole = new QLabel(QStringLiteral("高级工程师"), userBox);
userRole->setObjectName(QStringLiteral("userRole"));
userLay->addWidget(userName);
userLay->addWidget(userRole);
lay->addWidget(userBox);
}
void TopBar::setWorkspaces(const std::vector<data::Workspace>& list, const QString& currentId) {
auto* menu = new QMenu(wsBtn_);
auto* header = menu->addAction(QStringLiteral("切换空间"));
header->setEnabled(false);
menu->addSeparator();
QString currentName;
for (const auto& w : list) {
const QString id = QString::fromStdString(w.id);
const QString name = QString::fromStdString(w.name);
auto* a = menu->addAction(name);
a->setCheckable(true);
a->setChecked(id == currentId);
if (id == currentId) currentName = name;
QObject::connect(a, &QAction::triggered, this,
[this, id]() { emit workspaceSwitchRequested(id); });
}
if (list.empty()) {
auto* none = menu->addAction(QStringLiteral("(暂无空间)"));
none->setEnabled(false);
}
wsBtn_->setMenu(menu);
wsBtn_->setText((currentName.isEmpty() ? QStringLiteral("选择空间") : currentName) +
QStringLiteral(" ▾"));
}
void TopBar::setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId) {
auto* menu = new QMenu(projBtn_);
auto* header = menu->addAction(QStringLiteral("切换项目"));
header->setEnabled(false);
menu->addSeparator();
QString currentName;
for (const auto& p : list) {
const QString id = QString::fromStdString(p.id);
const QString name = QString::fromStdString(p.name);
auto* a = menu->addAction(name);
a->setCheckable(true);
a->setChecked(id == currentId);
if (id == currentId) currentName = name;
QObject::connect(a, &QAction::triggered, this,
[this, id]() { emit projectSwitchRequested(id); });
}
if (list.empty()) {
auto* none = menu->addAction(QStringLiteral("(暂无项目)"));
none->setEnabled(false);
}
projBtn_->setMenu(menu);
projBtn_->setText((currentName.isEmpty() ? QStringLiteral("选择项目") : currentName) +
QStringLiteral(" ▾"));
}
头文件包含:确保 TopBar.cpp 顶部已
#include的<QActionGroup>可保留或删除;新增需要<QMenu> <QToolButton> <QHBoxLayout> <QVBoxLayout> <QLabel> <QSize> <QColor>(原文件已包含这些)。makeGlyph来自Glyphs.hpp(原文件已 include)。
- Step 3: 临时修正 main.cpp 调用点以保持可编译
main.cpp 顶部菜单区块当前是:
topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
topLayout->addWidget(geopro::app::buildTopToolBar(topChrome));
改为(Task 9 会再加信号接线):
topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
topLayout->addWidget(new geopro::app::TopBar(topChrome));
- Step 4: 构建主程序确认通过
Run: cmake --build build/release --target geopro_desktop
Expected: 编译/链接通过(TopBar 经 AUTOMOC 生成 moc)。
- Step 5: Commit
git add src/app/TopBar.hpp src/app/TopBar.cpp src/app/main.cpp
git commit -m "feat(app): TopBar 升级为数据驱动类(工作空间/项目切换信号)"
Task 8: ObjectTreePanel 被动对象树(UI 层)
Files:
-
Create:
src/app/panels/ObjectTreePanel.hpp,src/app/panels/ObjectTreePanel.cpp -
Modify:
src/app/CMakeLists.txt -
Step 1: 创建
src/app/panels/ObjectTreePanel.hpp
#pragma once
#include <QWidget>
#include <vector>
#include "repo/RepoTypes.hpp"
class QTreeWidget;
class QLabel;
namespace geopro::app {
// 被动对象树:项目根 → GS → TM(叶子=TM,可勾选)。数据来自控制器;自身不发请求。
class ObjectTreePanel : public QWidget {
Q_OBJECT
public:
explicit ObjectTreePanel(QWidget* parent = nullptr);
// 用扁平结构节点重建树(内部调 dto::buildStructTree)。
void setStructure(const QString& projectName, const std::vector<data::StructNode>& nodes);
void showMessage(const QString& message); // 错误/空状态占位
signals:
void tmClicked(const QString& tmObjectId);
void tmCheckToggled(const QString& tmObjectId, bool checked);
private:
QTreeWidget* tree_ = nullptr;
QLabel* hint_ = nullptr;
};
} // namespace geopro::app
- Step 2: 创建
src/app/panels/ObjectTreePanel.cpp
#include "panels/ObjectTreePanel.hpp"
#include <QColor>
#include <QLabel>
#include <QSignalBlocker>
#include <QStackedLayout>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QVBoxLayout>
#include "Glyphs.hpp"
#include "dto/NavDto.hpp"
namespace geopro::app {
namespace {
// TM 节点把 tmObjectId 存在该角色;GS/项目根节点为空。
constexpr int kRoleTmId = Qt::UserRole + 2;
void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes) {
for (const auto& n : nodes) {
auto* item = new QTreeWidgetItem(parent);
item->setText(0, QString::fromStdString(n.node.name));
if (n.isTm) {
item->setData(0, kRoleTmId, QString::fromStdString(n.node.id));
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(0, Qt::Unchecked); // 真实数据渲染下一轮接入,默认不勾
}
addNodes(item, n.children);
}
}
} // namespace
ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
tree_ = new QTreeWidget(this);
tree_->setHeaderHidden(true);
{
const QString openArrow = writeChevronIcon(true, QColor("#8A93A3"));
const QString closedArrow = writeChevronIcon(false, QColor("#8A93A3"));
tree_->setStyleSheet(
QStringLiteral("QTreeView::branch { background: #FFFFFF; }"
"QTreeView::branch:has-children:!has-siblings:closed,"
"QTreeView::branch:closed:has-children:has-siblings { image: url(%1); }"
"QTreeView::branch:open:has-children:!has-siblings,"
"QTreeView::branch:open:has-children:has-siblings { image: url(%2); }")
.arg(closedArrow, openArrow));
}
lay->addWidget(tree_, 1);
hint_ = new QLabel(QStringLiteral("(加载中…)"), this);
hint_->setAlignment(Qt::AlignCenter);
hint_->setStyleSheet(QStringLiteral("color:#9AA6B6; padding:16px;"));
hint_->setVisible(false);
lay->addWidget(hint_);
QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) {
const QString tmId = item->data(0, kRoleTmId).toString();
if (!tmId.isEmpty()) emit tmClicked(tmId);
});
QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* item, int) {
const QString tmId = item->data(0, kRoleTmId).toString();
if (!tmId.isEmpty())
emit tmCheckToggled(tmId, item->checkState(0) == Qt::Checked);
});
}
void ObjectTreePanel::setStructure(const QString& projectName,
const std::vector<data::StructNode>& nodes) {
const QSignalBlocker block(tree_); // 重建触发 itemChanged,先屏蔽
tree_->clear();
const auto roots = data::dto::buildStructTree(nodes);
if (roots.empty()) {
showMessage(projectName.isEmpty() ? QStringLiteral("(暂无项目)")
: QStringLiteral("(该项目暂无结构)"));
return;
}
hint_->setVisible(false);
tree_->setVisible(true);
auto* rootItem = new QTreeWidgetItem(tree_);
rootItem->setText(0, projectName.isEmpty() ? QStringLiteral("项目") : projectName);
addNodes(rootItem, roots);
tree_->expandAll();
}
void ObjectTreePanel::showMessage(const QString& message) {
tree_->clear();
tree_->setVisible(false);
hint_->setText(message);
hint_->setVisible(true);
}
} // namespace geopro::app
writeChevronIcon来自Glyphs.hpp(main.cpp 同名用法已验证存在)。
- Step 3: 接入
src/app/CMakeLists.txt
在 add_executable(geopro_desktop WIN32 ...) 源列表加 panels/ObjectTreePanel.cpp,并在 target_link_libraries 加 geopro_controller:
add_executable(geopro_desktop WIN32
main.cpp
Theme.cpp
TopBar.cpp
Glyphs.cpp
PanelHeader.cpp
login/LoginWindow.cpp
panels/AnomalyListPanel.cpp
panels/DatasetListPanel.cpp
panels/ObjectTreePanel.cpp)
link 段在 geopro_render 行后加:
geopro_render # Phase 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
git add src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp src/app/CMakeLists.txt
git commit -m "feat(app): ObjectTreePanel 被动对象树(项目→GS→TM)"
Task 9: CentralScene 数据驱动 helper(解耦中央三维编排)
Files:
- Create:
src/app/CentralScene.hpp,src/app/CentralScene.cpp - Modify:
src/app/CMakeLists.txt
把"每个剖面 section 的中央渲染"从对象树解耦为显式数据驱动 helper。本轮用空 sections(中央占位), 下一轮用真实 DS 构建 sections 调同一 helper 即复活(spec §8.1 / §12.1)。无单测(VTK 渲染,手动联调)。
- Step 1: 创建
src/app/CentralScene.hpp
#pragma once
#include <vector>
#include "model/ColorScale.hpp"
#include "model/Field.hpp"
namespace geopro::core { class GeoLocalFrame; }
namespace geopro::render { class Scene; }
class vtkRenderer;
class vtkRenderWindow;
namespace geopro::app {
// 中央视图模式:二维地图(测线红线俯视)/ 三维视图(断面墙)。
enum class ViewMode { Map2D, View3D };
// 一个待渲染剖面:grid(2D 测线 / 3D 帘面都用)+ colorScale(3D 帘面上色)。
struct SectionInput {
geopro::core::Grid grid;
geopro::core::ColorScale colorScale;
};
// 中央场景重建(脱离对象树,按显式 sections 渲染):
// 2D = 每个 section 的 buildSurveyLine;3D = 每个 section 的 buildCurtain(受 showCurtain)。
// 下一轮接真实 DS:构建 sections 后调用本函数即可,render 层零改动。
void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
vtkRenderWindow* renderWindow, ViewMode mode,
const std::vector<SectionInput>& sections, bool showCurtain,
const geopro::core::GeoLocalFrame& frame, double verticalExaggeration);
} // namespace geopro::app
- Step 2: 创建
src/app/CentralScene.cpp
#include "CentralScene.hpp"
#include <vtkActor.h>
#include <vtkRenderWindow.h>
#include <vtkRenderer.h>
#include "CameraPreset.hpp"
#include "Scene.hpp"
#include "actors/CurtainActor.hpp"
#include "actors/MapLineActor.hpp"
#include "geo/GeoLocalFrame.hpp"
namespace geopro::app {
void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
vtkRenderWindow* renderWindow, ViewMode mode,
const std::vector<SectionInput>& sections, bool showCurtain,
const geopro::core::GeoLocalFrame& frame, double verticalExaggeration) {
scene.clear();
const bool is2D = (mode == ViewMode::Map2D);
renderer->SetBackground(is2D ? 0.96 : 1.0, is2D ? 0.97 : 1.0, is2D ? 0.99 : 1.0);
for (const auto& s : sections) {
if (is2D) {
auto line = geopro::render::buildSurveyLine(s.grid, frame);
if (line) scene.addActor(line);
} else if (showCurtain) {
auto curtain = geopro::render::buildCurtain(s.grid, s.colorScale, frame);
if (curtain) {
curtain->SetScale(1.0, 1.0, verticalExaggeration); // 纵向夸张成墙
scene.addActor(curtain);
}
}
}
if (is2D)
geopro::render::applyTop2D(renderer);
else
geopro::render::applyFree3D(renderer);
renderer->ResetCamera();
renderWindow->Render();
}
} // namespace geopro::app
头文件名核对自现有
main.cpp用法: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 同处):
panels/ObjectTreePanel.cpp
CentralScene.cpp)
- Step 4: 构建确认通过
Run: cmake --build build/release --target geopro_desktop
Expected: 编译/链接通过(CentralScene 暂未被引用,单独编译即可)。
- Step 5: Commit
git add src/app/CentralScene.hpp src/app/CentralScene.cpp src/app/CMakeLists.txt
git commit -m "feat(app): CentralScene 数据驱动 helper(解耦中央三维编排,下一轮接真实DS复用)"
Task 10: main.cpp 接线(构造仓储/控制器 + 信号 + 移除启动 demo)
Files:
- Modify:
src/app/main.cpp
把对象树/数据列表/顶部切换器接到控制器;移除启动自动渲染本地 demo;真实 DS 点击 → 中央/详情占位。
- Step 1: 增加头文件 include
在 main.cpp 顶部 include 区(#include "TopBar.hpp" 附近)加:
#include "CentralScene.hpp"
#include "WorkbenchNavController.hpp"
#include "api/ApiProjectRepository.hpp"
#include "panels/ObjectTreePanel.hpp"
并把匿名命名空间里原本的本地枚举定义:
enum class ViewMode { Map2D, View3D };
替换为复用 helper 的同名枚举(保持后续 ViewMode::Map2D 等引用不变):
using geopro::app::ViewMode;
- Step 2: 修改
buildWorkbench签名,注入控制器
把:
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo)
改为:
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
geopro::controller::WorkbenchNavController& nav)
- Step 3: 用 ObjectTreePanel 取代左上内联树
定位左上 dock 构建处(当前为 auto* tree = new QTreeWidget(); ... populateTree(tree, *structure); ... 到 leftDock->setWidget(wrapWithHeader(... tree ...)))。整段替换为:
// 左上 dock:对象树(真实结构:项目根 → GS → TM)。被动视图,数据由控制器推送。
auto* objectTree = new geopro::app::ObjectTreePanel();
auto* leftDock = new ads::CDockWidget(QStringLiteral("对象显示栏"));
leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象显示栏"),
objectTree,
{{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}}));
auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
同时删除文件顶部不再使用的
populateTree、findTm、kRoleTmId三处(若编译报未使用可保留findTm/kRoleTmId,但populateTree必删以免引用旧GsNode)。 删除auto structure = std::make_shared<...>(repo.loadStructure());(真实结构改由控制器提供)。 注意:原rebuildCentrallambda 捕获了tree/structure。见 Step 4 处理。
- Step 4: 中央视图改为调 CentralScene helper(空 sections = 占位)
rebuildCentral 原实现遍历 tree 勾选项 + repo.loadGrid + 体素/切片/地形。本轮中央不接真实数据,改为
委托 Task 9 的 rebuildCentralScene,传空 sections → 空背景占位(下一轮喂真实 DS 即复活)。把
rebuildCentral lambda 整体替换为:
// 中央编排已解耦到 CentralScene::rebuildCentralScene(Task 9)。本轮空 sections → 空背景占位。
// 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活(spec §8.1 / §12.1)。
auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() {
geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode,
std::vector<geopro::app::SectionInput>{}, *showCurtain,
*frame, kVerticalExaggeration);
};
这样
showVoxel/showTerrain/showSlice/slicePlane/crs/refElev/structure不再被rebuildCentral捕获使用; 它们的声明与图层勾选回调保留(渲染基础设施不删),仅不再产生可视效果。
并把"视图详情"浮层里体素 / 切片 / 地形三个勾选框本轮置灰提示(它们不绑定单 DS,需独立真实数据源,
见 spec §12.1 E)。在三个 chk* 创建之后、if (!crs) 块附近,追加:
// 本轮中央不接真实派生层:体素/切片/地形勾选置灰,待下一轮接入对应数据源。
for (QCheckBox* c : {chkVoxel, chkSlice, chkTerrain}) {
c->setEnabled(false);
c->setToolTip(QStringLiteral("(下一轮接入真实数据源)"));
}
chkCurtain保持可用(切换showCurtain,被rebuildCentralScene使用)。
- Step 5: 删除"对象树驱动中央/数据列表"的旧连接
删除这两段(基于内联 tree 的连接,objectTree 已无 tree 指针):
-
QObject::connect(tree, &QTreeWidget::itemChanged, ...rebuildCentral...)整段。 -
QObject::connect(tree, &QTreeWidget::itemClicked, ...populateDatasetList...)整段。 -
Step 6: 数据详情 DS 点击改占位
定位 datasetList 的 itemClicked 连接(调用 loadDataset)。把其回调替换为占位(不再加载本地样本):
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
[propLabel, detailRendererPtr, detailRenderWindowPtr](QListWidgetItem* item) {
const QString name =
item->data(Qt::DisplayRole).toString().section('\n', 0, 0);
detailRendererPtr->RemoveAllViewProps();
detailRenderWindowPtr->Render();
propLabel->setText(QStringLiteral(
"数据集: %1\n(该数据集的剖面/反演渲染将在下一阶段接入 dd 接口)").arg(name));
});
保留
loadDataset/rebuildDetail定义(渲染代码保留不删),仅不再从数据列表触发它们。若编译报loadDataset未使用警告(/W4 不会因未使用 lambda 报错),无视。
- Step 7: 移除启动 demo 渲染块
删除文件中两处启动渲染:
// ── 启动默认:测线已勾选 ... rebuildCentral();之上的注释 +rebuildCentral();调用(即首帧本地渲染)。改为保留一次rebuildCentral();以建立空背景视图(保留这一行,删上面那段“启动默认”注释语义即可)。- 整段
// 启动默认:选第一个含 dd_section 的测线 ... for (const auto& gs : *structure) { ... }(依赖*structure,必删)。
结论:
rebuildCentral();保留一次(建立空视图);依赖*structure的“选第一个测线”循环整段删除。
- Step 8: 接线控制器 ↔ TopBar/ObjectTree/DatasetList
在 buildWorkbench 末尾(dock 持久化块之前)加信号接线。先把 Step 1(Task 7)里临时的 new geopro::app::TopBar(topChrome) 改为持有指针并接线。把顶部 chrome 区块替换为:
geopro::app::TopBar* topBar = nullptr;
{
auto* topChrome = new QWidget(&window);
auto* topLayout = new QVBoxLayout(topChrome);
topLayout->setContentsMargins(0, 0, 0, 0);
topLayout->setSpacing(0);
topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
topBar = new geopro::app::TopBar(topChrome);
topLayout->addWidget(topBar);
window.setMenuWidget(topChrome);
}
然后在其后加控制器接线:
// ── 控制器 ↔ UI 信号接线(导航壳)──────────────────────────────────────
QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
&geopro::controller::WorkbenchNavController::switchWorkspace);
QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav,
&geopro::controller::WorkbenchNavController::switchProject);
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::tmClicked, &nav,
&geopro::controller::WorkbenchNavController::selectTm);
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::workspacesLoaded, topBar,
[topBar](const std::vector<geopro::data::Workspace>& list, const QString& cur) {
topBar->setWorkspaces(list, cur);
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar,
[topBar](const std::vector<geopro::data::ProjectSummary>& list,
const QString& cur) { topBar->setProjects(list, cur); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree,
[objectTree, datasetList, datasetTitle, datasetTabs](
const QString& projectName,
const std::vector<geopro::data::StructNode>& nodes) {
objectTree->setStructure(projectName, nodes);
datasetList->clear(); // 切项目清空 DS 列表
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏"));
datasetTabs->setTabText(0, QStringLiteral("数据"));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList,
[datasetList, datasetTitle, datasetTabs](
const QString&, const std::vector<geopro::data::DsNode>& list) {
geopro::app::populateDatasetList(datasetList, list);
if (datasetTitle)
datasetTitle->setText(QStringLiteral("数据集显示栏"));
datasetTabs->setTabText(
0, QStringLiteral("数据 (%1)").arg(static_cast<int>(list.size())));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::loadFailed, objectTree,
[objectTree, &window](const QString& stage, const QString& msg) {
if (stage == QStringLiteral("structure") ||
stage == QStringLiteral("projects"))
objectTree->showMessage(QStringLiteral("加载失败:%1").arg(msg));
window.statusBar()->showMessage(
QStringLiteral("加载失败(%1):%2").arg(stage, msg), 8000);
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::busyChanged, &window,
[](bool busy) {
if (busy)
QApplication::setOverrideCursor(Qt::WaitCursor);
else
QApplication::restoreOverrideCursor();
});
populateDatasetList已在panels/DatasetListPanel.hpp(main.cpp 已 include)。datasetTitle/datasetTabs/datasetList为既有局部变量,确保接线代码在它们定义之后。
- Step 9: 在
main()构造仓储/控制器并启动
定位 main() 中构建工作台处:
geopro::data::LocalSampleRepository repo(
"D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/");
QMainWindow window;
...
buildWorkbench(window, repo);
window.show();
改为(api 已在上文构造并 setToken):
geopro::data::LocalSampleRepository repo(
"D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/");
// 导航仓储 + 控制器(接口/逻辑层):用同一共享会话 ApiClient。
geopro::data::ApiProjectRepository projectRepo(api);
geopro::controller::WorkbenchNavController nav(projectRepo);
QMainWindow window;
window.setWindowTitle(QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)"));
window.resize(1280, 800);
window.setMinimumSize(1024, 680);
buildWorkbench(window, repo, nav);
window.show();
nav.start(); // 进入工作台后拉真实 空间/项目/结构(show 后调用,确保 UI 已就绪)
return app.exec();
删除原先重复的
window.setWindowTitle/resize/setMinimumSize(已并入上面),避免重复。
- Step 10: 构建主程序确认通过
Run: cmake --build build/release --target geopro_desktop
Expected: 编译/链接通过。若报某变量未使用导致 /W4 /WX(本项目未开 /WX,仅告警),可忽略告警。
- Step 11: 手动联调(真实接口)
Run: 启动 build/release/src/app/geopro_desktop.exe(或用 /run)。登录后验证:
- 顶部工作空间下拉显示真实空间列表,切换触发项目刷新。
- 项目下拉显示真实项目,切换触发对象树刷新。
- 对象树显示 项目根 → GS → TM;单击 TM 左下数据列表出现真实 DS。
- 单击 DS:中央/数据详情显示占位文案、右下属性显示“将在下一阶段接入 dd 接口”。
- 断网/无数据时显示“加载失败/暂无…”而非崩溃或本地样本。
Expected: 上述行为符合预期(截图留存)。
- Step 12: Commit
git add src/app/main.cpp
git commit -m "feat(app): 工作台接入真实导航(空间/项目/对象树/DS),中央渲染占位"
自检结论(spec 覆盖核对)
- 工作空间列表/切换 → Task 5(仓储)+ Task 6(控制器)+ Task 7(TopBar)+ Task 10(接线)✅
- 项目列表/切换 → 同上 ✅
- 对象树 项目→GS→TM(叶子=TM)→ Task 4(buildStructTree)+ Task 8(ObjectTreePanel)✅
- TM 下 DS 列表 → Task 3(parseDatasets)+ Task 5 + Task 10 ✅
- 失败显示错误/空状态、不回退本地样本 → Task 8(showMessage)+ Task 10(loadFailed 接线)✅
- 中央三维编排解耦为数据驱动 helper(保留可复用)→ Task 9(CentralScene)✅
- 渲染占位、移除启动 demo、保留 render/LocalSampleRepository/rebuildDetail → Task 10 ✅
- 项目 crsCode 存入控制器(下一轮替换 EPSG:4547)→ Task 6(currentCrsCode_)✅
- 分层:接口(net 复用)/数据(data: 仓储+dto)/逻辑(controller)/UI(app) → 各 Task 就位、依赖单向向下 ✅
- 纯逻辑单测(dto + 树构建)→ Task 2/3/4 ✅;线程同步+WaitCursor → Task 10 ✅
下一轮(不在本计划,详见 spec §12.1)
接真实 DS 渲染分四步(render 层零改):A 取数(新增 DS 内容仓储方法,dd/ert/exception/clr 接口)→
B 映射(DTO → core::Grid/ScatterField/ColorScale/Anomaly,加单测)→ C 接线(构建 app::SectionInput
调 Task 9 的 rebuildCentralScene;真实数据触发保留的 rebuildDetail,替换占位)→ D 坐标系(用
currentCrsCode() 重建 GeoLocalFrame,替换硬编码 EPSG:4547)。另:异步仓储+分页、用户信息、token 过期跳登录、
体素/切片/地形真实数据源、顶部菜单接页面。