1498 lines
58 KiB
Markdown
1498 lines
58 KiB
Markdown
# 接入真实导航(工作空间 / 项目 / 对象树)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→模型纯映射 + `buildStructTree`
|
||
- `src/data/api/ApiProjectRepository.hpp` / `.cpp` — 用 `ApiClient` 实现 `IProjectRepository`
|
||
- `src/controller/CMakeLists.txt` — `geopro_controller` 静态库(AUTOMOC ON)
|
||
- `src/controller/WorkbenchNavController.hpp` / `.cpp` — 导航状态机
|
||
- `src/app/panels/ObjectTreePanel.hpp` / `.cpp` — 被动对象树视图
|
||
- `tests/data/test_nav_dto.cpp` — DTO + 树构建单测
|
||
|
||
**改造**
|
||
- `src/data/repo/RepoTypes.hpp` — 追加 `Workspace / ProjectSummary / StructNode`
|
||
- `src/data/CMakeLists.txt` — 加源文件 + 链接 `geopro_net`
|
||
- `src/CMakeLists.txt` — `add_subdirectory(controller)`(在 app 之前)
|
||
- `src/app/CMakeLists.txt` — 加 `panels/ObjectTreePanel.cpp` + 链接 `geopro_controller`
|
||
- `src/app/TopBar.hpp` / `.cpp` — 由自由函数升级为 `TopBar` 数据驱动类
|
||
- `src/app/main.cpp` — 构造仓储/控制器、接线信号、移除启动 demo 渲染、DS 点击占位
|
||
- `tests/CMakeLists.txt` — 加 `data/test_nav_dto.cpp`
|
||
|
||
**保留不删**:`LocalSampleRepository`、`render/*`、现有详情/中央渲染代码。
|
||
|
||
---
|
||
|
||
## Task 1: 导航模型 + 仓储接口(纯声明)
|
||
|
||
**Files:**
|
||
- Modify: `src/data/repo/RepoTypes.hpp`
|
||
- Create: `src/data/repo/IProjectRepository.hpp`
|
||
|
||
纯数据结构与抽象接口,无行为,无单测;以"能编译"为验收。
|
||
|
||
- [ ] **Step 1: 追加导航模型到 RepoTypes.hpp**
|
||
|
||
把以下三个结构体加入 `namespace geopro::data {}`(放在 `Project` 之后、命名空间右括号之前):
|
||
|
||
```cpp
|
||
// 工作空间(=企业租户/空间)。ownerType: 1 个人空间 2 企业空间。
|
||
struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; };
|
||
|
||
// 项目摘要(列表用)。crsCode/crsName 为项目参考坐标系,下一轮替换硬编码 EPSG:4547。
|
||
struct ProjectSummary { std::string id, name, typeName, crsCode, crsName; int status = 0; };
|
||
|
||
// 项目结构扁平节点(仅 GS / TM)。客户端按 parentId 建树,叶子=TM。
|
||
struct StructNode { std::string id, name, parentId, typeName, confCode; int type = 0; };
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 IProjectRepository.hpp**
|
||
|
||
```cpp
|
||
#pragma once
|
||
#include <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 脚手架 + parseWorkspaces(TDD,打通测试构建)
|
||
|
||
**Files:**
|
||
- Create: `src/data/dto/NavDto.hpp`, `src/data/dto/NavDto.cpp`
|
||
- Create: `tests/data/test_nav_dto.cpp`
|
||
- Modify: `src/data/CMakeLists.txt`, `tests/CMakeLists.txt`
|
||
|
||
- [ ] **Step 1: 写失败测试 `tests/data/test_nav_dto.cpp`**
|
||
|
||
```cpp
|
||
#include <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 / parseDatasets(TDD)
|
||
|
||
**Files:**
|
||
- Modify: `src/data/dto/NavDto.cpp`, `tests/data/test_nav_dto.cpp`
|
||
|
||
- [ ] **Step 1: 追加失败测试到 `tests/data/test_nav_dto.cpp`**
|
||
|
||
文件顶部 `arrOf` 之后追加 `objOf` 辅助:
|
||
|
||
```cpp
|
||
namespace {
|
||
QJsonObject objOf(const char* json) {
|
||
return QJsonDocument::fromJson(QByteArray(json)).object();
|
||
}
|
||
} // namespace
|
||
|
||
TEST(NavDto, ParseProjectsMapsCrsAndPaging) {
|
||
const auto data = objOf(R"({
|
||
"hasNextPage": true,
|
||
"projectList": [
|
||
{"id":"p1","projectName":"青海湖北岸","projectTypeName":"ERT",
|
||
"referenceCRSCode":"EPSG:4547","referenceCRSName":"CGCS2000","status":1}
|
||
]
|
||
})");
|
||
const auto page = dto::parseProjects(data);
|
||
EXPECT_TRUE(page.hasNextPage);
|
||
ASSERT_EQ(page.projects.size(), 1u);
|
||
EXPECT_EQ(page.projects[0].id, "p1");
|
||
EXPECT_EQ(page.projects[0].name, "青海湖北岸");
|
||
EXPECT_EQ(page.projects[0].typeName, "ERT");
|
||
EXPECT_EQ(page.projects[0].crsCode, "EPSG:4547");
|
||
EXPECT_EQ(page.projects[0].status, 1);
|
||
}
|
||
|
||
TEST(NavDto, ParseStructNodesMapsParentAndType) {
|
||
const auto arr = arrOf(R"([
|
||
{"id":"gs1","name":"工区1","parentId":"","type":1,"typeName":"GS","confCode":""},
|
||
{"id":"tm1","name":"测线1","parentId":"gs1","type":2,"typeName":"TM","confCode":"ERT"}
|
||
])");
|
||
const auto ns = dto::parseStructNodes(arr);
|
||
ASSERT_EQ(ns.size(), 2u);
|
||
EXPECT_EQ(ns[0].id, "gs1");
|
||
EXPECT_EQ(ns[1].parentId, "gs1");
|
||
EXPECT_EQ(ns[1].confCode, "ERT");
|
||
EXPECT_EQ(ns[1].type, 2);
|
||
}
|
||
|
||
TEST(NavDto, ParseDatasetsMapsDdCodeToDdType) {
|
||
const auto arr = arrOf(R"([
|
||
{"id":"ds1","name":"批次1","ddCode":"dd_section","typeName":"剖面"}
|
||
])");
|
||
const auto ds = dto::parseDatasets(arr);
|
||
ASSERT_EQ(ds.size(), 1u);
|
||
EXPECT_EQ(ds[0].id, "ds1");
|
||
EXPECT_EQ(ds[0].name, "批次1");
|
||
EXPECT_EQ(ds[0].ddType, "dd_section");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 运行确认失败**
|
||
|
||
Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NavDto --output-on-failure`
|
||
Expected: 三个新用例 FAIL(空桩返回空)。
|
||
|
||
- [ ] **Step 3: 实现三个函数(替换 NavDto.cpp 中对应空桩)**
|
||
|
||
```cpp
|
||
ProjectPage parseProjects(const QJsonObject& data) {
|
||
ProjectPage page;
|
||
page.hasNextPage = data.value(QStringLiteral("hasNextPage")).toBool();
|
||
const QJsonArray list = data.value(QStringLiteral("projectList")).toArray();
|
||
page.projects.reserve(static_cast<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 4:render 层(Scene / GridContourActor / 相机预设)
|
||
geopro_controller # Phase 5:导航编排(WorkbenchNavController)
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: 构建确认通过**
|
||
|
||
Run: `cmake --preset msvc-release && cmake --build build/release --target geopro_desktop`
|
||
Expected: 编译/链接通过。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp src/app/CMakeLists.txt
|
||
git commit -m "feat(app): ObjectTreePanel 被动对象树(项目→GS→TM)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: main.cpp 接线(构造仓储/控制器 + 信号 + 移除启动 demo)
|
||
|
||
**Files:**
|
||
- Modify: `src/app/main.cpp`
|
||
|
||
把对象树/数据列表/顶部切换器接到控制器;移除启动自动渲染本地 demo;真实 DS 点击 → 中央/详情占位。
|
||
|
||
- [ ] **Step 1: 增加头文件 include**
|
||
|
||
在 main.cpp 顶部 include 区(`#include "TopBar.hpp"` 附近)加:
|
||
|
||
```cpp
|
||
#include "WorkbenchNavController.hpp"
|
||
#include "api/ApiProjectRepository.hpp"
|
||
#include "panels/ObjectTreePanel.hpp"
|
||
```
|
||
|
||
- [ ] **Step 2: 修改 `buildWorkbench` 签名,注入控制器**
|
||
|
||
把:
|
||
```cpp
|
||
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo)
|
||
```
|
||
改为:
|
||
```cpp
|
||
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
|
||
geopro::controller::WorkbenchNavController& nav)
|
||
```
|
||
|
||
- [ ] **Step 3: 用 ObjectTreePanel 取代左上内联树**
|
||
|
||
定位左上 dock 构建处(当前为 `auto* tree = new QTreeWidget(); ... populateTree(tree, *structure); ...` 到 `leftDock->setWidget(wrapWithHeader(... tree ...))`)。整段替换为:
|
||
|
||
```cpp
|
||
// 左上 dock:对象树(真实结构:项目根 → GS → TM)。被动视图,数据由控制器推送。
|
||
auto* objectTree = new geopro::app::ObjectTreePanel();
|
||
auto* leftDock = new ads::CDockWidget(QStringLiteral("对象显示栏"));
|
||
leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象显示栏"),
|
||
objectTree,
|
||
{{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}}));
|
||
auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
|
||
```
|
||
|
||
> 同时删除文件顶部不再使用的 `populateTree`、`findTm`、`kRoleTmId` 三处(若编译报未使用可保留 `findTm`/`kRoleTmId`,但 `populateTree` 必删以免引用旧 `GsNode`)。
|
||
> 删除 `auto structure = std::make_shared<...>(repo.loadStructure());`(真实结构改由控制器提供)。
|
||
> 注意:原 `rebuildCentral` lambda 捕获了 `tree`/`structure`。见 Step 4 处理。
|
||
|
||
- [ ] **Step 4: 中央视图改为占位(移除本地结构驱动)**
|
||
|
||
`rebuildCentral` 原实现遍历 `tree` 勾选项并渲染本地 grid。本轮中央不接真实数据,改为:清空场景 + 应用背景,不再依赖 `tree`/`structure`。把 `rebuildCentral` lambda 整体替换为:
|
||
|
||
```cpp
|
||
// 本轮中央视图不接真实剖面数据(下一轮接 dd 接口):仅维护视图模式背景,内容占位为空。
|
||
auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, slicePlane]() {
|
||
if (*slicePlane) { (*slicePlane)->Off(); *slicePlane = nullptr; }
|
||
scene->clear();
|
||
const bool is2D = (*viewMode == ViewMode::Map2D);
|
||
rendererPtr->SetBackground(is2D ? 0.96 : 1.0, is2D ? 0.97 : 1.0, is2D ? 0.99 : 1.0);
|
||
if (is2D)
|
||
geopro::render::applyTop2D(rendererPtr);
|
||
else
|
||
geopro::render::applyFree3D(rendererPtr);
|
||
rendererPtr->ResetCamera();
|
||
renderWindowPtr->Render();
|
||
};
|
||
```
|
||
|
||
> 这样 `showVoxel/showTerrain/crs/frame/refElev` 等捕获不再被 `rebuildCentral` 使用。它们仍被其它 lambda(图层勾选)引用——图层勾选回调保留但因中央为空而无可视效果,本轮可接受;**不删**这些变量与回调(保留渲染基础设施)。
|
||
|
||
- [ ] **Step 5: 删除"对象树驱动中央/数据列表"的旧连接**
|
||
|
||
删除这两段(基于内联 `tree` 的连接,`objectTree` 已无 `tree` 指针):
|
||
- `QObject::connect(tree, &QTreeWidget::itemChanged, ...rebuildCentral...)` 整段。
|
||
- `QObject::connect(tree, &QTreeWidget::itemClicked, ...populateDatasetList...)` 整段。
|
||
|
||
- [ ] **Step 6: 数据详情 DS 点击改占位**
|
||
|
||
定位 `datasetList` 的 `itemClicked` 连接(调用 `loadDataset`)。把其回调替换为占位(不再加载本地样本):
|
||
|
||
```cpp
|
||
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
|
||
[propLabel, detailRendererPtr, detailRenderWindowPtr](QListWidgetItem* item) {
|
||
const QString name =
|
||
item->data(Qt::DisplayRole).toString().section('\n', 0, 0);
|
||
detailRendererPtr->RemoveAllViewProps();
|
||
detailRenderWindowPtr->Render();
|
||
propLabel->setText(QStringLiteral(
|
||
"数据集: %1\n(该数据集的剖面/反演渲染将在下一阶段接入 dd 接口)").arg(name));
|
||
});
|
||
```
|
||
|
||
> 保留 `loadDataset` / `rebuildDetail` 定义(渲染代码保留不删),仅不再从数据列表触发它们。若编译报 `loadDataset` 未使用警告(/W4 不会因未使用 lambda 报错),无视。
|
||
|
||
- [ ] **Step 7: 移除启动 demo 渲染块**
|
||
|
||
删除文件中两处启动渲染:
|
||
- `// ── 启动默认:测线已勾选 ... rebuildCentral();` 之上的注释 + `rebuildCentral();` 调用(即首帧本地渲染)。改为保留一次 `rebuildCentral();` 以建立空背景视图(**保留这一行**,删上面那段“启动默认”注释语义即可)。
|
||
- 整段 `// 启动默认:选第一个含 dd_section 的测线 ... for (const auto& gs : *structure) { ... }`(依赖 `*structure`,必删)。
|
||
|
||
> 结论:`rebuildCentral();` 保留一次(建立空视图);依赖 `*structure` 的“选第一个测线”循环整段删除。
|
||
|
||
- [ ] **Step 8: 接线控制器 ↔ TopBar/ObjectTree/DatasetList**
|
||
|
||
在 `buildWorkbench` 末尾(dock 持久化块之前)加信号接线。先把 Step 1(Task 7)里临时的 `new geopro::app::TopBar(topChrome)` 改为持有指针并接线。把顶部 chrome 区块替换为:
|
||
|
||
```cpp
|
||
geopro::app::TopBar* topBar = nullptr;
|
||
{
|
||
auto* topChrome = new QWidget(&window);
|
||
auto* topLayout = new QVBoxLayout(topChrome);
|
||
topLayout->setContentsMargins(0, 0, 0, 0);
|
||
topLayout->setSpacing(0);
|
||
topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
|
||
topBar = new geopro::app::TopBar(topChrome);
|
||
topLayout->addWidget(topBar);
|
||
window.setMenuWidget(topChrome);
|
||
}
|
||
```
|
||
|
||
然后在其后加控制器接线:
|
||
|
||
```cpp
|
||
// ── 控制器 ↔ UI 信号接线(导航壳)──────────────────────────────────────
|
||
QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
|
||
&geopro::controller::WorkbenchNavController::switchWorkspace);
|
||
QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav,
|
||
&geopro::controller::WorkbenchNavController::switchProject);
|
||
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::tmClicked, &nav,
|
||
&geopro::controller::WorkbenchNavController::selectTm);
|
||
|
||
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::workspacesLoaded, topBar,
|
||
[topBar](const std::vector<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 7(TopBar)+ Task 9(接线)✅
|
||
- 项目列表/切换 → 同上 ✅
|
||
- 对象树 项目→GS→TM(叶子=TM)→ Task 4(buildStructTree)+ Task 8(ObjectTreePanel)✅
|
||
- TM 下 DS 列表 → Task 3(parseDatasets)+ Task 5 + Task 9 ✅
|
||
- 失败显示错误/空状态、不回退本地样本 → Task 8(showMessage)+ Task 9(loadFailed 接线)✅
|
||
- 渲染解耦占位、移除启动 demo、保留 render/LocalSampleRepository → Task 9 ✅
|
||
- 项目 crsCode 存入控制器(下一轮替换 EPSG:4547)→ Task 6(currentCrsCode_)✅
|
||
- 分层:接口(net 复用)/数据(data: 仓储+dto)/逻辑(controller)/UI(app) → 各 Task 就位、依赖单向向下 ✅
|
||
- 纯逻辑单测(dto + 树构建)→ Task 2/3/4 ✅;线程同步+WaitCursor → Task 9 ✅
|
||
|
||
## 下一轮(不在本计划)
|
||
dd/ert/gpr 真实渲染替换占位;crsCode 重建 GeoLocalFrame;异步仓储 + 分页“加载更多”;用户信息 `auth/getUserInfo`;token 过期自动跳登录;顶部菜单接真实页面。
|