24 KiB
接入真实导航(工作空间 / 项目 / 对象树)— 设计文档
- 日期: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 的"工作空间切换 / 项目"为静态视觉壳(硬编码下拉项)。
本轮目标:把顶层导航壳接到真实后端接口,逐步替换静态数据:
- 工作空间(=企业租户/空间)列表与切换;
- 项目列表与切换;
- 对象显示栏的树形结构(项目 → GS → TM)+ 选中 TM 后其 DS 列表。
中央三维/二维渲染与"数据详情"的真实剖面/反演数据走另一批 dd/ert 接口,本轮不接。
2. 范围(已确认决策)
做(In Scope)
- 工作空间列表 / 切换(真实接口)。
- 项目列表 / 切换(真实接口)。
- 对象树:按真实结构显示 GS 层(项目根 → GS → TM);TM 在左下"数据真实显示栏"列出其 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 下数据(页签) | POST | /business/dsObject/data/page |
body {projectId, structParentId:<tmObjectId>, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize};data.list[{id, dsName, name(类型名), ddCode}] |
| TM 下文件(页签) | POST | /business/dsObject/file/page |
body 同上但 classifyTypeList:[1];项另含 file{name, size, url} |
层级确认(修正需求方假设):真实结构不是 项目→tm→ds,而是 项目 → GS(工区) → TM(测线) → DS。
queryProjectStruct返回一个扁平 parent-child 列表(仅含 GS + TM 两类节点,不含 DS),客户端按parentId自建树。- DS 不在结构列表里:按 TM 拉数据/文件两类,分别用
dsObject/data/page(classify=3)、dsObject/file/page(classify=1),传structParentId=<tmObjectId>、structParentConfType=2。(实测:queryByUser返回空,项目列表改用my/profile/queryProject;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):
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;
};
新增 DsRow{id, dsName, typeName, ddCode, fileName, fileUrl, fileSize}(数据/文件页签行通用;文件行含 file*)。DsNode 仅本地样本仓储继续用。
5.3 数据访问层 data
repo/IProjectRepository.hpp — 导航仓储抽象(同步,呼应既有 IDatasetRepository 风格;
但网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态):
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<DsRow>> loadTmRows(const std::string& projectId,
const std::string& tmObjectId,
int classifyType) = 0; // 3=数据 1=文件
};
api/ApiProjectRepository.{hpp,cpp} — 实现:持有 net::ApiClient&,
按 §3 路径发请求,把 ApiResponse 交给 dto/ 映射;网络/业务码错误 → RepoResult{ok=false, error=msg}。
判定成功:code==200(沿用登录 AuthService 的约定,业务码即成功标志)。id 进 URL 路径/查询前
经 QUrl::toPercentEncoding 百分号编码(不可信后端数据:防 ? # & / 空格 破坏 URL)。
dto/NavDto.{hpp,cpp} — 纯函数映射(无网络、可单测):
parseWorkspaces(QJsonArray) -> vector<Workspace>(isCurTenant==1 → isCurrent)。parseProjects(QJsonObject) -> {vector<ProjectSummary>, bool hasNextPage}。parseStructNodes(QJsonArray) -> vector<StructNode>。parseDsRows(QJsonArray) -> vector<DsRow>(data/file page 的data.list;name→typeName,file{name,size,url})。buildStructTree(vector<StructNode>) -> vector<StructTreeNode>:扁平→通用树(不强塞Project/Gs/Tm刚性模型, 以适配任意层级 + TM 直挂项目)。StructTreeNode{StructNode node; bool isTm; vector<StructTreeNode> children}。- 以
parentId归并;parentId为空或不在集合内(孤儿)的节点为根层。 - 叶子节点判定为 TM(
isTm=true,node.id即 tmObjectId);非叶子为 GS。 visited集防环:不可信后端数据(多节点环 / 重复 id)也不会无限递归(规约:永不信任外部数据)。- 纯函数、可单测;树→QTreeWidget 的填充由
ObjectTreePanel调用本函数完成(见 §5.5)。
- 以
5.4 逻辑层 controller/WorkbenchNavController(QObject)
唯一持有导航状态;不碰 widget;经信号把模型推给 UI、经槽接收用户意图。
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);
// 发出项目名 + 扁平结构节点;建树(buildStructTree)在 ObjectTreePanel 内完成。
void structureLoaded(const QString& projectName, const std::vector<data::StructNode>&);
void datasetsLoaded(const QString& tmObjectId, const std::vector<data::DsRow>&); // 数据页签
void filesLoaded(const QString& tmObjectId, const std::vector<data::DsRow>&); // 文件页签
void loadFailed(const QString& stage, const QString& message); // 出错→UI 空/错状态
void busyChanged(bool busy); // 同步阻塞期间置 WaitCursor
private:
void loadProjectsAndStructure(); // start + switchWorkspace 共用
data::IProjectRepository& repo_;
std::vector<data::ProjectSummary> lastProjects_; // 供 switchProject 查 name/crsCode
std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
bool busy_ = false; // 重入保护:同步请求期间拒绝再次进入
};
编排逻辑:start() → listWorkspaces(选 isCurrent/首个)→ listProjects(选首个)→ loadStructure→发扁平节点。
切空间/项目按 §6 时序。每个阶段失败 emit loadFailed(stage,msg) 并停在该阶段。
重入保护:每个公共操作入口 if (busy_) return;,并用 RAII guard 在置忙/复位时配平 busyChanged
(同步 HTTP 会泵 Qt 事件循环,快速二次点击可能重入并污染状态)。
5.5 UI 层 app(被动视图,数据驱动)
app/TopBar —— 由"自由函数返回静态 QWidget"升级为数据驱动类(QWidget 子类):
setWorkspaces(list, currentId)/setProjects(list, currentId)重建下拉项。- 信号
workspaceSwitchRequested(QString id)/projectSwitchRequested(QString id)。 - 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。
buildMenuBar不变(静态菜单本轮不接)。
app/panels/ObjectTreePanel(新增)—— 被动:setStructure(projectName, vector<StructNode>) 内部调
dto::buildStructTree 重建 QTreeWidget(项目根→GS→TM,叶子=TM 可勾选、UserRole 存 tmObjectId);
showMessage(msg) 显示空/错占位。信号 tmClicked(QString tmObjectId) / tmCheckToggled(...)
(后者为前瞻钩子,本轮无消费者)。
app/panels/DatasetListPanel —— datasetsLoaded→populateDatasetList(数据:dsName+类型名);filesLoaded→populateFileList(文件:文件名+可读大小,url 存角色备下载);空时占位。列表去隔行变色,改细分割线。
中央/详情:移除"启动自动渲染本地 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) → emit structureLoaded(name,nodes) → ObjectTreePanel.setStructure(→buildStructTree)
切空间: 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: loadTmRows(pid,tm,3)+loadTmRows(pid,tm,1) → emit datasetsLoaded + filesLoaded → 数据/文件页签
点 DS: DatasetListPanel → 中央/详情显示占位"待接入"(本轮不渲染真实数据)
7. 错误处理与边界
- 仓储层捕获网络错误(
rawError)与业务错误码,归一为RepoResult.error。 - controller 任一阶段失败 →
loadFailed(stage, msg);UI 在对应面板显示空/错状态 label + 状态栏提示,不回退本地样本。 - 空数据(无空间 / 无项目 / 无结构 / 无 DS)→ 各面板显示"暂无…"占位(识别优于回忆)。
- token 过期(业务码 401 类)→
loadFailed文案提示重新登录(本轮先提示,自动跳登录留后续)。 - 输入边界:
tmObjectId/projectId为空时短路不发请求;URL 中的 id 一律百分号编码(见 §5.3)。 - 重入:同步请求期间
busy_拒绝再次进入(避免快速点击重入污染状态,见 §5.4)。
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,使下一轮"喂真实数据"即可复活,
无需重写编排:
// src/app/CentralScene.{hpp,cpp}
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 开关 + 纵向夸张)。
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 / parseDsRows字段与name→typeName、isCurTenant→isCurrent。buildStructTree扁平→树:覆盖 项目根→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、接线信号;移除启动自动渲染 demo;DS 点击改占位- 各层
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}。
- 反演网格(#18 来源):
- 在
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 影像服务)才能复活; 与 A–D 的"按 DS 渲染剖面"是独立工作,按需另起一轮。