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

393 lines
28 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**
- 工作空间列表 / 切换(真实接口)。
- 项目列表 / 切换(真实接口):下拉显示首页项目(首页 10项目数超过首页 → 下拉底部「全部项目…」打开
**项目列表弹窗**(名称/类型过滤 + 分页 + 8 列表格:序号/名称/编号/状态/类型/业主/负责人/创建时间;点项目名切换并关弹窗)。
- 对象树:**按真实结构显示 GS 层**(项目根 → GS → TMTM 在左下"数据真实显示栏"列出其 DS。
- DS **数据/文件两个页签**接真实分页接口(每页 5每行显示"名称 / 创建时间 · 类型(数据)或大小(文件)"
超过首页 → 列表末尾「加载更多」追加下一页。
- 真实接口失败(断网 / token 过期 / 无数据)→ **显示错误 / 空状态****不回退本地样本**。
- 项目 `referenceCRSCode` 存入导航状态,供下一轮替换硬编码 `EPSG:4547`(本轮不改渲染)。
**不做Out of Scope留下一轮**
- 中央 2D/3D 视图、数据详情的真实数据渲染(`dd/ert/gpr` 接口)。
→ 点击真实 DS 时中央/详情显示**占位"待接入"**`render/*` 与 `LocalSampleRepository` 代码**保留不删**。
- 异步仓储QFuture/回调)—— 本轮同步阻塞 + WaitCursor与登录一致异步留 M1.5。
- 用户头像 / 姓名接真实 `auth/getUserInfo`(本轮先留静态)。
- 文件下载(文件页签已展示文件名/大小、下载 `url` 已存入列表项备用,实际下载动作留后续)。
## 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 |
| 项目分页 | POST | `/business/my/profile/project/page` | body `{projectName(名称模糊,可空), projectTypeId(类型过滤,可空), pageNo, pageSize}``data{list:[{id, projectName, projectCode, status, projectTypeId, projectTypeName, ownerCompanyName, responsiblePersonName, createTime, referenceCRSCode}], total}`(实测 `queryByUser`/`queryProject` 返回空或不带分页,弃用) |
| 项目类型列表 | GET | `/business/project/type/list` | `data.value:[{id, name}]`(弹窗"项目类型"过滤下拉) |
| 项目结构 | GET | `/business/projectStruct/queryProjectStruct/{projectId}` | `data.value:[{id, name, parentId, type(1项目/2TM), typeId, typeName, confCode}]`(仅项目根+TM不含 DS |
| TM 下数据(页签) | POST | `/business/dsObject/data/page` | body `{projectId, structParentId:<tmObjectId>, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize}``data{list:[{id, dsName, name(类型名), ddCode, createTime}], total}` |
| TM 下文件(页签) | POST | `/business/dsObject/file/page` | body 同上但 `classifyTypeList:[1]`;项另含 `createTime``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`
```cpp
struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; };
struct ProjectSummary {
std::string id, name, typeName, crsCode, crsName;
std::string code, projectTypeId, ownerCompany, responsiblePerson, createTime; // 弹窗 8 列用
int status = 0; // 1=未开始 2=进行中(其余显示数字)
};
struct ProjectType { std::string id, name; }; // 类型过滤下拉
struct ProjectListPage { std::vector<ProjectSummary> rows; int total = 0; }; // 项目分页结果
// 项目结构扁平节点GS / TM客户端按 parentId 建树。
struct StructNode {
std::string id, name, parentId, typeName, confCode;
int type = 0;
};
```
新增 `DsRow{id, dsName, typeName, ddCode, createTime, fileName, fileUrl, fileSize}`(数据/文件页签行通用;文件行含 file*
+ `DsPage{rows, total}`(分页结果)。`DsNode` 仅本地样本仓储继续用。
### 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<ProjectListPage> pageProjects(const std::string& nameFilter,
const std::string& typeId, int pageNo,
int pageSize) = 0;
virtual RepoResult<std::vector<ProjectType>> listProjectTypes() = 0;
virtual RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) = 0;
// 按 TM 分页拉数据/文件行classifyType 3=数据 1=文件pageNo 从 1 起pageSize 固定 5。
virtual RepoResult<DsPage> loadTmRows(const std::string& projectId, const std::string& tmObjectId,
int classifyType, int pageNo) = 0;
};
```
**`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`)。
- `parseProjectList(QJsonArray) -> vector<ProjectSummary>` / `parseProjectPage(QJsonObject) -> ProjectListPage{rows,total}`project/page
- `parseProjectTypes(QJsonArray) -> vector<ProjectType>`type/list
- `parseStructNodes(QJsonArray) -> vector<StructNode>`
- `parseDsRows(QJsonArray) -> vector<DsRow>` / `parseDsPage(QJsonObject) -> DsPage{rows,total}`data/file page`name→typeName`、`createTime`、`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、经槽接收用户意图。
```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 首页(数据+文件)
void loadMoreData(); // 数据页签"加载更多"→下一页追加
void loadMoreFiles(); // 文件页签"加载更多"→下一页追加
signals:
void workspacesLoaded(const std::vector<data::Workspace>&, QString currentId);
void projectsLoaded(const std::vector<data::ProjectSummary>&, QString currentId, int total); // total 判断"全部项目"入口
// 发出项目名 + 扁平结构节点建树buildStructTree在 ObjectTreePanel 内完成。
void structureLoaded(const QString& projectName, const std::vector<data::StructNode>&);
// total=总数、append=是否追加(加载更多 true / 首页 false
void datasetsLoaded(const QString& tmObjectId, const std::vector<data::DsRow>&, int total, bool append);
void filesLoaded(const QString& tmObjectId, const std::vector<data::DsRow>&, int total, bool append);
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_;
std::string currentTmId_; // 加载更多用:当前选中 TM
int dataPageNo_ = 0, filePageNo_ = 0, dataTotal_ = 0, fileTotal_ = 0; // 数据/文件分页游标
bool busy_ = false; // 重入保护:同步请求期间拒绝再次进入
};
```
编排逻辑:`start()` → `listWorkspaces`(选 isCurrent/首个)→ `pageProjects`(首页 10选首个)→ `loadStructure`→发扁平节点。
`selectTm` 拉数据/文件首页(每页 5`loadMoreData/Files` 递增页码追加。`switchWorkspace` 成功后用返回的新 accessToken 重注入 ApiClient否则后续请求仍落旧空间
切空间/项目按 §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, hasMore)` 重建下拉项;`hasMore` 时下拉底部加「全部项目…」。
- `setProjectButtonText(name)` —— 弹窗切换项目后更新项目按钮文字。
- 信号 `workspaceSwitchRequested(id)` / `projectSwitchRequested(id)` / `allProjectsRequested()`(打开项目弹窗)。
- 工作空间/项目下拉用互斥 `QActionGroup`(避免"多选"),选中即更新按钮文字。
- 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。`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 存角色备下载);空时占位。列表去隔行变色,改细分割线。
超过首页 → main 在列表末尾插入「加载更多(已/共」item角色 `kDsLoadMoreRole`),点击 → `loadMoreData()`/`loadMoreFiles()` 追加。
**`app/ProjectListDialog`**(新增 QDialog—— 项目列表弹窗:顶部 项目名搜索框 + 类型下拉(`listProjectTypes`,含"全部类型") + 搜索/重置;
中部 8 列表格(序号/项目名称/项目编号/项目状态/项目类型/业主单位/负责人/创建时间);底部 上/下一页 + "共 N 条 第 x/y 页"。
持有 `IProjectRepository&` 自行分页(`pageProjects`,每页 20状态列 1=未开始/2=进行中(其余数字)。点项目名列 → emit `projectChosen(id,name)` + `accept()`
`TopBar::allProjectsRequested` 触发main 创建并 `exec()`;选中 → `nav.switchProject(id)` + `TopBar::setProjectButtonText(name)`
**中央/详情**:移除"启动自动渲染本地 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 列表/详情占位
全部项目: TopBar.allProjectsRequested → main 打开 ProjectListDialogpageProjects 分页 + 名称/类型过滤)
→ 点项目名 → projectChosen(id,name) → nav.switchProject(id) + TopBar.setProjectButtonText(name) → 关弹窗
选 TM: ObjectTreePanel.tmClicked(tmObjectId)
→ controller.selectTm: loadTmRows(pid,tm,3,1)+loadTmRows(pid,tm,1,1) → emit datasetsLoaded/filesLoaded(total,append=false) → 数据/文件页签
加载更多: 点列表末尾"加载更多" → controller.loadMoreData/Files → emit …(total,append=true) → 追加
点 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**,使下一轮"喂真实数据"即可复活,
无需重写编排:
```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 / 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
- `src/app/ProjectListDialog.{hpp,cpp}`(项目列表弹窗,见 §5.5
- 测试:`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 渲染剖面"是独立工作,按需另起一轮。
```