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

346 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 接入真实导航(工作空间 / 项目 / 对象树)— 设计文档
- 日期2026-06-09
- 分支feat/m1-finishing建议拉子分支 feat/real-api-navigation
- 状态:已与需求方确认范围,待 spec 评审
---
## 1. 背景与目标
当前应用已搭好骨架:登录链路(`net::AuthService` + `net::ApiClient`共享会话、token 注入)可用;
工作台(`src/app/main.cpp::buildWorkbench`)用本地静态样本仓储 `data::LocalSampleRepository`
渲染三维/二维示例;顶部 `app::TopBar` 的"工作空间切换 / 项目"为**静态视觉壳**(硬编码下拉项)。
本轮目标:把**顶层导航壳**接到真实后端接口,逐步替换静态数据:
1. 工作空间(=企业租户/空间)列表与切换;
2. 项目列表与切换;
3. 对象显示栏的树形结构(项目 → GS → TM+ 选中 TM 后其 DS 列表。
中央三维/二维渲染与"数据详情"的**真实剖面/反演数据**走另一批 `dd/ert` 接口,**本轮不接**。
## 2. 范围(已确认决策)
**做In Scope**
- 工作空间列表 / 切换(真实接口)。
- 项目列表 / 切换(真实接口)。
- 对象树:**按真实结构显示 GS 层**(项目根 → GS → TMTM 在左下"数据真实显示栏"列出其 DS。
- 真实接口失败(断网 / token 过期 / 无数据)→ **显示错误 / 空状态****不回退本地样本**。
- 项目 `referenceCRSCode` 存入导航状态,供下一轮替换硬编码 `EPSG:4547`(本轮不改渲染)。
**不做Out of Scope留下一轮**
- 中央 2D/3D 视图、数据详情的真实数据渲染(`dd/ert/gpr` 接口)。
→ 点击真实 DS 时中央/详情显示**占位"待接入"**`render/*` 与 `LocalSampleRepository` 代码**保留不删**。
- 异步仓储QFuture/回调)—— 本轮同步阻塞 + WaitCursor与登录一致异步留 M1.5。
- 用户头像 / 姓名接真实 `auth/getUserInfo`(本轮先留静态)。
- 项目分页"加载更多"的滚动 UI接口支持 `hasNextPage`,本轮先取首页;翻页留待后续)。
## 3. 接口确认结论
网关:`http://tenant.geomative.cn/pop-api`(现有 `ApiClient` 基址business 与 admin 两份 OpenAPI 同一上游。
token 已由登录注入(`geomativeauthorization` 头),下列接口直接复用现有会话。
| 能力 | 方法 | 路径 | 关键返回 |
|---|---|---|---|
| 工作空间列表 | GET | `/business/system/tenant/enterprise/joined/list` | `[{id, name, ownerType(1个人/2企业), isCurTenant(0/1), logoPath}]` |
| 切换工作空间 | POST | `/business/system/tenant/enterprise/switch/{tenantId}` | 信封 code/msg |
| 项目列表 | GET | `/business/project/queryByUser?lastProjectId=` | `{hasNextPage, projectList:[{id, projectName, projectTypeName, referenceCRSCode, referenceCRSName, status, ...}]}`(游标分页,首页传空 lastProjectId |
| 项目结构 | POST | `/business/projectWorkbench/queryProjectStruct` | body `{projectId}`data `{projectStructList:[{id, name, parentId, type, typeId, typeName, confCode}]}` |
| TM 下 DS | GET | `/business/projectWorkbench/queryDsByTmObjectId/{tmObjectId}` | `[{id, name, ddCode, typeName}]` |
**层级确认(修正需求方假设)**:真实结构**不是** `项目→tm→ds`,而是 **`项目 → GS(工区) → TM(测线) → DS`**。
- `queryProjectStruct` 返回一个**扁平 parent-child 列表**(仅含 GS + TM 两类节点,**不含 DS**),客户端按 `parentId` 自建树。
- DS 不在结构列表里,按 TM 单独拉取(`queryDsByTmObjectId`)。
- **项目不能直接挂 DS**DS 永远挂在 TM 下。但由于是 `parentId` 扁平结构,**TM 可直接挂在项目下(无中间 GS**——这是"项目直接挂"印象的来源,但叶子仍是 TM→DS。
**节点判定**:结构列表只含 GS+TM**TM = 该节点在结构列表中无子节点(叶子)**;非叶子 = GS。
`type`(integer) / `confCode` 一并保留为辅助信号,待见到 live 数据后可固化判定规则。
## 4. 架构分层
遵循仓库既有四层(见各层 README本轮新增组件按层就位**依赖方向单向向下**UI 不直接碰 `ApiClient`
```
┌─────────────────────────────────────────────────────────────┐
│ UI 层 (src/app, 目标 src/view) — 被动视图,只渲染模型 + 发用户意图信号 │
│ TopBar(数据驱动) ObjectTreePanel DatasetListPanel(已有) │
└───────────────▲──────────────────────────┬──────────────────┘
信号(用户意图) 槽(模型数据)
┌───────────────┴──────────────────────────▼──────────────────┐
│ 逻辑层 (src/controller) — 编排状态机,无 widget │
│ WorkbenchNavController : QObject │
│ state: 当前 workspaceId / projectId / project.crsCode │
│ slots: switchWorkspace / switchProject / selectTm │
│ signals: workspacesLoaded / projectsLoaded / │
│ structureLoaded / datasetsLoaded / loadFailed │
└───────────────────────────┬──────────────────────────────────┘
IProjectRepository(同步契约)
┌───────────────────────────▼──────────────────────────────────┐
│ 数据访问层 (src/data) │
│ repo/IProjectRepository.hpp ← 导航仓储抽象(Result<T>) │
│ api/ApiProjectRepository.{h,cpp} ← 用 ApiClient 实现 │
│ dto/NavDto.{h,cpp} ← 后端 JSON → 模型 纯映射 + 扁平→树 │
└───────────────────────────┬──────────────────────────────────┘
net::ApiClient(原始 HTTP)
┌───────────────────────────▼──────────────────────────────────┐
│ 接口层 (src/net) — 复用,无改动 │
│ ApiClient(共享会话/token) ; AuthService(登录) │
└───────────────────────────────────────────────────────────────┘
模型层 (src/data/repo/RepoTypes.hpp & 新增 NavTypes) — 纯结构,被各层共享
Workspace / ProjectSummary / StructNode / Project,GsNode,TmNode,DsNode(已有)
```
依赖规则:`net` 不依赖任何上层;`data` 依赖 `net` + 模型;`controller` 依赖 `data` + 模型(不依赖 UI
`app/view` 依赖 `controller` + 模型(不依赖 `data/net` 具体类型,只经 controller 信号拿模型)。
## 5. 各层组件详细设计
### 5.1 接口层 `net`(复用,不改)
`ApiClient::get(path)` / `postJson(path, body)` 返回 `ApiResponse{httpStatus, code, data, msg, rawError}`
本轮所有业务接口经它发出token 已注入,会话已共享。
### 5.2 模型层(纯结构,无 Qt / 无 VTK
`src/data/repo/RepoTypes.hpp` 已有 `Project/GsNode/TmNode/DsNode`。新增导航模型(同文件或 `NavTypes.hpp`
```cpp
struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; };
struct ProjectSummary {
std::string id, name, typeName, crsCode, crsName;
int status = 0;
};
// 项目结构扁平节点GS / TM客户端按 parentId 建树。
struct StructNode {
std::string id, name, parentId, typeName, confCode;
int type = 0;
};
```
`DsNode{id,name,ddType}` 复用;映射时 `ddCode → ddType`
### 5.3 数据访问层 `data`
**`repo/IProjectRepository.hpp`** — 导航仓储抽象(同步,呼应既有 `IDatasetRepository` 风格;
但网络可失败,故用显式 `Result` 而非抛异常,便于 UI 出错误/空状态):
```cpp
template <class T>
struct RepoResult { bool ok = false; T value{}; std::string error; };
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;
};
```
**`api/ApiProjectRepository.{hpp,cpp}`** — 实现:持有 `net::ApiClient&`
按 §3 路径发请求,把 `ApiResponse` 交给 `dto/` 映射;网络/业务码错误 → `RepoResult{ok=false, error=msg}`
判定成功:`httpStatus==200 && code==<成功码>`(成功码沿用登录约定,实现时核对)。
**`dto/NavDto.{hpp,cpp}`** — 纯函数映射(**无网络、可单测**
- `parseWorkspaces(QJsonArray) -> vector<Workspace>``isCurTenant==1 → isCurrent`)。
- `parseProjects(QJsonObject) -> {vector<ProjectSummary>, bool hasNextPage}`
- `parseStructNodes(QJsonArray) -> vector<StructNode>`
- `parseDatasets(QJsonArray) -> vector<DsNode>``ddCode→ddType`)。
- `buildProjectTree(vector<StructNode>, projectName) -> Project`:扁平→树。
-`parentId` 归并;`parentId` 为空或不在集合内的节点挂到合成"项目根"。
- **叶子节点判定为 TM**(进 `TmNode`,携带 `confCode`/真实 id 作 tmObjectId非叶子为 GS。
- TM 的 `dss` 本轮留空DS 懒加载)。
### 5.4 逻辑层 `controller/WorkbenchNavController`QObject
唯一持有导航状态;不碰 widget经信号把模型推给 UI、经槽接收用户意图。
```cpp
class WorkbenchNavController : public QObject {
Q_OBJECT
public:
explicit WorkbenchNavController(data::IProjectRepository& repo, QObject* parent=nullptr);
void start(); // 启动:拉空间→项目→结构
public slots:
void switchWorkspace(const QString& tenantId); // 切空间→重载项目→重载结构
void switchProject(const QString& projectId); // 切项目→重载结构(清 DS/详情)
void selectTm(const QString& tmObjectId); // 选 TM→拉其 DS
signals:
void workspacesLoaded(const std::vector<data::Workspace>&, QString currentId);
void projectsLoaded(const std::vector<data::ProjectSummary>&, QString currentId);
void structureLoaded(const data::Project&); // 已建好的树
void datasetsLoaded(const QString& tmObjectId, const std::vector<data::DsNode>&);
void loadFailed(const QString& stage, const QString& message); // 出错→UI 空/错状态
void busyChanged(bool busy); // 同步阻塞期间置 WaitCursor
private:
data::IProjectRepository& repo_;
QString currentWorkspaceId_, currentProjectId_, currentCrsCode_;
};
```
编排逻辑:`start()` → `listWorkspaces`(选 isCurrent/首个)→ `listProjects`(选首个)→ `loadStructure`→建树。
切空间/项目按 §6 时序。每个阶段失败 emit `loadFailed(stage,msg)` 并停在该阶段。
### 5.5 UI 层 `app`(被动视图,数据驱动)
**`app/TopBar`** —— 由"自由函数返回静态 QWidget"升级为**数据驱动类**QWidget 子类):
- `setWorkspaces(list, currentId)` / `setProjects(list, currentId)` 重建下拉项。
- 信号 `workspaceSwitchRequested(QString id)` / `projectSwitchRequested(QString id)`
- 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。
- `buildMenuBar` 不变(静态菜单本轮不接)。
**`app/panels/ObjectTreePanel`**(新增;或先以构建函数落在 main二选一见 §11—— 被动:
`setProject(const data::Project&)` 重建 `QTreeWidget`项目根→GS→TMTM 可勾选、存 tmObjectId
信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)`。空/错状态:树区显示占位 label。
**`app/panels/DatasetListPanel`**(已有)—— `datasetsLoaded``populateDatasetList`;空时显示"暂无数据集"。
**中央/详情**:移除"启动自动渲染本地 demo"DS 点击 → 详情面板与中央视图显示占位文案
"该数据集渲染将在下一阶段接入 dd 接口"。渲染代码保留。
## 6. 数据流 / 交互时序
```
启动(登录后):
main 构造 ApiClient → ApiProjectRepository → WorkbenchNavController → TopBar/ObjectTreePanel
controller.start():
listWorkspaces → emit workspacesLoaded → TopBar.setWorkspaces
listProjects(empty) → emit projectsLoaded → TopBar.setProjects
loadStructure(currentProject) → buildProjectTree → emit structureLoaded → ObjectTreePanel.setProject
切空间: TopBar.workspaceSwitchRequested(id)
→ controller.switchWorkspace: switchWorkspace(id) → listProjects → 选首个 → loadStructure
→ emit projectsLoaded + structureLoaded清空 DS 列表/详情占位
切项目: TopBar.projectSwitchRequested(id)
→ controller.switchProject: loadStructure(id) → emit structureLoaded清空 DS 列表/详情占位
选 TM: ObjectTreePanel.tmClicked(tmObjectId)
→ controller.selectTm: loadDatasetsOfTm → emit datasetsLoaded → DatasetListPanel 填充
点 DS: DatasetListPanel → 中央/详情显示占位"待接入"(本轮不渲染真实数据)
```
## 7. 错误处理与边界
- 仓储层捕获网络错误(`rawError`)与业务错误码,归一为 `RepoResult.error`
- controller 任一阶段失败 → `loadFailed(stage, msg)`UI 在对应面板显示空/错状态 label + 状态栏提示,**不回退本地样本**。
- 空数据(无空间 / 无项目 / 无结构 / 无 DS→ 各面板显示"暂无…"占位(识别优于回忆)。
- token 过期(业务码 401 类)→ `loadFailed` 文案提示重新登录(本轮先提示,自动跳登录留后续)。
- 输入边界:`tmObjectId` / `projectId` 为空时短路不发请求。
## 8. 渲染解耦
现状:对象树(本地 grid1/grid2…直接驱动中央与数据详情`rebuildCentral`/`rebuildDetail` 都是
`main.cpp` 内捕获了本地 `tree`/`structure` 的 lambda。本轮真实树 id 与本地样本对不上,故解耦:
- 启动不再自动渲染本地 demo真实 DS 点击 → 中央/详情显示占位文案。
- `render/*`、`LocalSampleRepository`、`VoxelFromScatters` 等全部保留,待下轮按 dd/ert 接口复用。
- 项目 `crsCode` 由 controller 存住,下一轮替换 `main.cpp` 中硬编码 `EPSG:4547`
### 8.1 中央三维编排:保留并解耦为数据驱动 helper关键改动
`rebuildCentral` 直接读对象树 + `repo.loadGrid`,与本地样本强耦合、无法复用到真实 DS。
**本轮把"每个剖面 section 的中央渲染"抽成显式数据驱动的 helper**,使下一轮"喂真实数据"即可复活,
无需重写编排:
```cpp
// src/app/CentralScene.{hpp,cpp}
namespace geopro::app {
enum class ViewMode { Map2D, View3D };
// 一个待渲染剖面grid2D 测线 / 3D 帘面都用)+ colorScale3D 帘面上色用)。
struct SectionInput {
geopro::core::Grid grid;
geopro::core::ColorScale colorScale;
};
// 中央场景重建(脱离对象树,按显式 sections 渲染):
// 2D = 每个 section 的 buildSurveyLine红线俯视
// 3D = 每个 section 的 buildCurtain断面墙受 showCurtain 开关 + 纵向夸张)。
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);
}
```
- **本轮**`main.cpp` 用**空 `sections`** 调用该 helper → 中央为空背景(占位)。视图 2D/3D 切换、帘面勾选仍走它,只是无内容。
- **下一轮**`main.cpp` 用真实 DS 数据构建 `std::vector<SectionInput>` 再调同一 helper —— 编排零改动。
- **`rebuildDetail`(数据详情:#18/#17/异常/电极)**:保留在 `main.cpp`(暂不触发),下一轮改触发条件即复活。
- **体素 / 切片 / 地形**:是 demo 专属派生层(来自两条交叉本地剖面散点 / 本地 DEM**不绑定单个 DS**
不纳入 `rebuildCentralScene`。本轮移除其 `main.cpp` 内联编排(`render/` 函数保留),其"视图详情"勾选项
本轮置灰并提示"(下一轮接入)"。它们的复活属独立未来工作(需真实体素/地形数据源),见 §12.1。
## 9. 测试策略
依既有无测试桩 + 依赖 live 服务器的现实,聚焦**纯逻辑单测**GoogleTest + CTest
- `dto/NavDto` 映射:喂样本 JSON取自 OpenAPI example / 手造)验证
`parseWorkspaces / parseProjects / parseStructNodes / parseDatasets` 字段与 `ddCode→ddType`、`isCurTenant→isCurrent`。
- `buildProjectTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS、孤儿 parentId、空列表 等场景。
- 不做 live 集成 / E2E无桩、依赖真实后端。控制器/UI 信号联动靠手动联调验证。
- 目标纯逻辑文件dto + tree builder覆盖率优先达标UI/网络 IO 不计入。
## 10. 线程 / 性能
- 同步阻塞 UI 线程(`ApiClient` 用 QEventLoop+ `busyChanged``Qt::WaitCursor`,与现有登录一致。
- 切空间/项目可能稍慢但可接受MVP。异步QFuture/取消)留 M1.5,届时 `IProjectRepository` 契约可平滑改造。
## 11. 文件清单
**新增**
- `src/data/repo/IProjectRepository.hpp`(含 `RepoResult<T>`、导航模型 `Workspace/ProjectSummary/StructNode`,或拆 `NavTypes.hpp`
- `src/data/api/ApiProjectRepository.{hpp,cpp}`
- `src/data/dto/NavDto.{hpp,cpp}`
- `src/controller/WorkbenchNavController.{hpp,cpp}`
- `src/app/panels/ObjectTreePanel.{hpp,cpp}`(若不抽,则树构建函数留在 main但 TopBar 必抽)
- `src/app/CentralScene.{hpp,cpp}`(中央三维编排的数据驱动 helper见 §8.1
- 测试:`tests/data/test_nav_dto.cpp`NavDto 映射 + buildStructTree
**改造**
- `src/app/TopBar.{hpp,cpp}` — 升级为数据驱动类 + 信号
- `src/app/main.cpp` — 构造 repo/controller、接线信号移除启动自动渲染 demoDS 点击改占位
- 各层 `CMakeLists.txt` — 新增源文件 + `controller` 目标接入构建controller 需 `Q_OBJECT`AUTOMOC ON
**保留不删**`LocalSampleRepository`、`render/*`、`VoxelFromScatters`、现有详情/中央渲染代码。
## 12. 未决 / 下一轮(概览)
- dd/ert/gpr 真实剖面/反演/雷达数据渲染(替换占位)—— 详见 §12.1。
- 项目 `crsCode` 替换硬编码 `EPSG:4547`,重建 `GeoLocalFrame`
- 异步仓储QFuture + 取消 + 分页"加载更多")。
- 用户头像/姓名接 `auth/getUserInfo`token 过期自动跳登录。
- 顶部菜单(视图/项目管理/业务工具/设备)接真实页面。
### 12.1 下一轮:对接真实 DS 数据要做什么
本轮已把导航与渲染解耦、并保留 render 层与数据驱动 helper§8.1)。下一轮把"占位"换成真实剖面/反演,
**render 层函数原样复用**,只需补"取数 → 映射 → 接线 → 坐标系"四件事:
**A. 取数:新增 DS 内容仓储(接口/数据层)**
-`IProjectRepository`(或新建 `IDatasetContentRepository`)补方法,按 DS 拉真实内容。候选接口:
- 反演网格(#18 来源):`GET /business/dd/ert/inversion/rows/{dsObjectId}`、`horizontal/rows/{dsObjectId}`、
`dynamic/form/{dsObjectId}`(按实际返回选定网格来源)。
- 原始散点(#17 来源):`GET /business/dd/ert/inversion/getErtRawDataScatterGraph/{dsObjectId}`
`dd/indicator/currentmethod/scatter/graph/{dsObjectId}`
- 异常:`GET /business/exception/queryException/{dsId}`。
- 色阶:`GET /business/clr/colorGradation/queryCLRColorGradation/{projectId}` /
`lvlTemplate/queryLVLTemplate/{projectId}`
-`ApiProjectRepository` 实现;返回 `RepoResult<T>`
**B. 映射DTO → 现有 core 模型(数据层 `dto/`**
- 把上述接口的 JSON 映射成**现有类型**`core::Grid`、`core::ScatterField`、`core::ColorScale`、`core::Anomaly`。
(这是新增解析,每个 dd 接口形状不同;参考 `data/parse/SampleParsers` 对本地样本的映射约定:
`v``[j=y][i=x]`、east/north 名值约定、纵向夸张统一常量等。)
- 单测:喂样本 JSON 验证映射(沿用本轮 NavDto 测试套路)。
**C. 接线:复活中央 + 详情编排UI 层,零改 render**
- 中央三维:`controller.selectDataset(dsId)` → 取反演网格 + 色阶 → 构建 `std::vector<app::SectionInput>`
**本轮已写好的** `app::rebuildCentralScene(...)`§8.1)。勾选多 TM/DS 即多 section 共存。
- 数据详情:用真实 `Grid/ScatterField/ColorScale/Anomaly` 触发**本轮保留的** `rebuildDetail`(改其触发为真实数据)。
- 把本轮的"占位文案"替换为真实渲染调用。
**D. 坐标系:用真实项目 CRS**
- 用本轮控制器已存的 `currentCrsCode()` 重建 `GeoLocalFrame``CrsTransform`,替换 `main.cpp` 硬编码 `EPSG:4547`
切项目时重建世界系,保证多视图配准。
**E.(可选)体素 / 切片 / 地形**
- 这些是不绑定单 DS 的派生层,需各自的真实数据源(多剖面体素插值 / DEM 影像服务)才能复活;
与 AD 的"按 DS 渲染剖面"是独立工作,按需另起一轮。
```