# 接入真实导航(工作空间 / 项目 / 对象树)— 设计文档 - 日期: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 → 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 下 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) │ │ 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 struct RepoResult { bool ok = false; T value{}; std::string error; }; class IProjectRepository { public: virtual ~IProjectRepository() = default; virtual RepoResult> listWorkspaces() = 0; virtual RepoResult switchWorkspace(const std::string& tenantId) = 0; virtual RepoResult> listProjects(const std::string& lastProjectId) = 0; virtual RepoResult> loadStructure(const std::string& projectId) = 0; virtual RepoResult> 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`(`isCurTenant==1 → isCurrent`)。 - `parseProjects(QJsonObject) -> {vector, bool hasNextPage}`。 - `parseStructNodes(QJsonArray) -> vector`。 - `parseDatasets(QJsonArray) -> vector`(`ddCode→ddType`)。 - `buildProjectTree(vector, 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&, QString currentId); void projectsLoaded(const std::vector&, QString currentId); void structureLoaded(const data::Project&); // 已建好的树 void datasetsLoaded(const QString& tmObjectId, const std::vector&); 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→TM,TM 可勾选、存 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…)直接驱动中央与数据详情。本轮真实树 id 与本地样本对不上,故: - 启动不再自动渲染本地 demo。 - 真实 DS 点击 → 中央/详情显示占位文案。 - `render/*`、`LocalSampleRepository`、`VoxelFromScatters` 等全部保留,待下轮按 dd/ert 接口复用。 - 项目 `crsCode` 由 controller 存住,下一轮替换 `main.cpp` 中硬编码 `EPSG:4547`。 ## 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`、导航模型 `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 必抽) - 测试:`tests/`(或既有测试目录)`NavDtoTest.cpp`、`BuildProjectTreeTest.cpp` **改造** - `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 真实剖面/反演/雷达数据渲染(替换占位)。 - 项目 `crsCode` 替换硬编码 `EPSG:4547`,重建 `GeoLocalFrame`。 - 异步仓储(QFuture + 取消 + 分页"加载更多")。 - 用户头像/姓名接 `auth/getUserInfo`;token 过期自动跳登录。 - 顶部菜单(视图/项目管理/业务工具/设备)接真实页面。 ```