From 3be4cdbddeb7d259cb4ad193fbf10b4b3936fa87 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 10:13:46 +0800 Subject: [PATCH 01/25] =?UTF-8?q?docs:=20=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=AF=BC=E8=88=AA(=E5=B7=A5=E4=BD=9C=E7=A9=BA?= =?UTF-8?q?=E9=97=B4/=E9=A1=B9=E7=9B=AE/=E5=AF=B9=E8=B1=A1=E6=A0=91)=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-09-real-api-navigation-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-real-api-navigation-design.md diff --git a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md new file mode 100644 index 0000000..790baf3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md @@ -0,0 +1,272 @@ +# 接入真实导航(工作空间 / 项目 / 对象树)— 设计文档 + +- 日期: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 过期自动跳登录。 +- 顶部菜单(视图/项目管理/业务工具/设备)接真实页面。 +``` From 3992a49c8fe8320b19566c63954d98053b698c76 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 10:13:46 +0800 Subject: [PATCH 02/25] =?UTF-8?q?docs:=20=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=AF=BC=E8=88=AA(=E5=B7=A5=E4=BD=9C=E7=A9=BA?= =?UTF-8?q?=E9=97=B4/=E9=A1=B9=E7=9B=AE/=E5=AF=B9=E8=B1=A1=E6=A0=91)=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-09-real-api-navigation-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-real-api-navigation-design.md diff --git a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md new file mode 100644 index 0000000..790baf3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md @@ -0,0 +1,272 @@ +# 接入真实导航(工作空间 / 项目 / 对象树)— 设计文档 + +- 日期: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 过期自动跳登录。 +- 顶部菜单(视图/项目管理/业务工具/设备)接真实页面。 +``` From 46358f2964b379d14a3a5a01652e3df1f7a4a999 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 10:28:16 +0800 Subject: [PATCH 03/25] =?UTF-8?q?docs:=20=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=AF=BC=E8=88=AA=20=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92(plan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-09-real-api-navigation.md | 1497 +++++++++++++++++ 1 file changed, 1497 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-real-api-navigation.md diff --git a/docs/superpowers/plans/2026-06-09-real-api-navigation.md b/docs/superpowers/plans/2026-06-09-real-api-navigation.md new file mode 100644 index 0000000..59d6eab --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-real-api-navigation.md @@ -0,0 +1,1497 @@ +# 接入真实导航(工作空间 / 项目 / 对象树)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": }`。 + - 数组型接口(`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` +- `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 +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::data { + +// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。 +template +struct RepoResult { + bool ok = false; + T value{}; + std::string error; +}; + +// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。 +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; +}; + +} // 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 +#include +#include +#include +#include + +#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 +#include +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::data::dto { + +// 工作空间数组(joined/list 的 data["value"])→ 模型。isCurTenant==1 → isCurrent。 +std::vector parseWorkspaces(const QJsonArray& arr); + +// 项目分页(queryByUser 的 data 对象 {hasNextPage, projectList})→ 模型。 +struct ProjectPage { std::vector projects; bool hasNextPage = false; }; +ProjectPage parseProjects(const QJsonObject& data); + +// 结构扁平节点数组(queryProjectStruct 的 data["projectStructList"])→ 模型。 +std::vector parseStructNodes(const QJsonArray& arr); + +// DS 聚合数组(queryDsByTmObjectId 的 data["value"])→ DsNode。ddCode → ddType。 +std::vector parseDatasets(const QJsonArray& arr); + +// 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理:项目直挂 TM、孤儿 parentId、空表。 +struct StructTreeNode { + StructNode node; + bool isTm = false; + std::vector children; +}; +std::vector buildStructTree(const std::vector& flat); + +} // namespace geopro::data::dto +``` + +- [ ] **Step 3: 创建 `src/data/dto/NavDto.cpp`,仅实现 parseWorkspaces(其余空桩,后续任务填)** + +```cpp +#include "dto/NavDto.hpp" + +#include + +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 parseWorkspaces(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 parseStructNodes(const QJsonArray&) { return {}; } +std::vector parseDatasets(const QJsonArray&) { return {}; } +std::vector buildStructTree(const std::vector&) { 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(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 parseStructNodes(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 parseDatasets(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 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 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 `, `#include `)** + +```cpp +std::vector buildStructTree(const std::vector& flat) { + std::set ids; + std::set 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(const std::string&, bool)> build = + [&](const std::string& parentId, bool root) { + std::vector 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> listWorkspaces() override; + RepoResult switchWorkspace(const std::string& tenantId) override; + RepoResult> listProjects(const std::string& lastProjectId) override; + RepoResult> loadStructure(const std::string& projectId) override; + RepoResult> 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 +#include +#include + +#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> 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 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> 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> 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> 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 +#include +#include +#include + +#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& list, const QString& currentId); + void projectsLoaded(const std::vector& list, const QString& currentId); + void structureLoaded(const QString& projectName, const std::vector& nodes); + void datasetsLoaded(const QString& tmObjectId, const std::vector& list); + void loadFailed(const QString& stage, const QString& message); + +private: + void loadProjectsAndStructure(); // start + switchWorkspace 共用 + + data::IProjectRepository& repo_; + std::vector 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 +#include +#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& list, const QString& currentId); + void setProjects(const std::vector& 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& 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& 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` 的 `` 可保留或删除;新增需要 ` `(原文件已包含这些)。`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 +#include +#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& 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 +#include +#include +#include +#include +#include +#include + +#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& 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& 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& list, const QString& cur) { + topBar->setWorkspaces(list, cur); + }); + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar, + [topBar](const std::vector& 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& 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& list) { + geopro::app::populateDatasetList(datasetList, list); + if (datasetTitle) + datasetTitle->setText(QStringLiteral("数据集显示栏")); + datasetTabs->setTabText( + 0, QStringLiteral("数据 (%1)").arg(static_cast(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 过期自动跳登录;顶部菜单接真实页面。 From 1fd8bb4d638bd52c1572ec009fa0db2a250d3ab1 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 10:49:31 +0800 Subject: [PATCH 04/25] =?UTF-8?q?docs:=20=E4=BF=9D=E7=95=99=E5=B9=B6?= =?UTF-8?q?=E8=A7=A3=E8=80=A6=E4=B8=AD=E5=A4=AE=E4=B8=89=E7=BB=B4=E7=BC=96?= =?UTF-8?q?=E6=8E=92(CentralScene=20helper)=20+=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E4=B8=8B=E4=B8=80=E8=BD=AE=E5=AF=B9=E6=8E=A5=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?DS=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-09-real-api-navigation.md | 193 +++++++++++++++--- .../2026-06-09-real-api-navigation-design.md | 85 +++++++- 2 files changed, 249 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-06-09-real-api-navigation.md b/docs/superpowers/plans/2026-06-09-real-api-navigation.md index 59d6eab..2890d05 100644 --- a/docs/superpowers/plans/2026-06-09-real-api-navigation.md +++ b/docs/superpowers/plans/2026-06-09-real-api-navigation.md @@ -35,6 +35,7 @@ - `src/controller/CMakeLists.txt` — `geopro_controller` 静态库(AUTOMOC ON) - `src/controller/WorkbenchNavController.hpp` / `.cpp` — 导航状态机 - `src/app/panels/ObjectTreePanel.hpp` / `.cpp` — 被动对象树视图 +- `src/app/CentralScene.hpp` / `.cpp` — 中央三维编排的数据驱动 helper(脱离对象树,下一轮接真实 DS 复用) - `tests/data/test_nav_dto.cpp` — DTO + 树构建单测 **改造** @@ -1247,7 +1248,128 @@ git commit -m "feat(app): ObjectTreePanel 被动对象树(项目→GS→TM)" --- -## Task 9: main.cpp 接线(构造仓储/控制器 + 信号 + 移除启动 demo) +## Task 9: CentralScene 数据驱动 helper(解耦中央三维编排) + +**Files:** +- Create: `src/app/CentralScene.hpp`, `src/app/CentralScene.cpp` +- Modify: `src/app/CMakeLists.txt` + +把"每个剖面 section 的中央渲染"从对象树解耦为显式数据驱动 helper。本轮用空 sections(中央占位), +下一轮用真实 DS 构建 sections 调同一 helper 即复活(spec §8.1 / §12.1)。无单测(VTK 渲染,手动联调)。 + +- [ ] **Step 1: 创建 `src/app/CentralScene.hpp`** + +```cpp +#pragma once +#include + +#include "model/ColorScale.hpp" +#include "model/Field.hpp" + +namespace geopro::core { class GeoLocalFrame; } +namespace geopro::render { class Scene; } +class vtkRenderer; +class vtkRenderWindow; + +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)。 +// 下一轮接真实 DS:构建 sections 后调用本函数即可,render 层零改动。 +void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, + vtkRenderWindow* renderWindow, ViewMode mode, + const std::vector& sections, bool showCurtain, + const geopro::core::GeoLocalFrame& frame, double verticalExaggeration); + +} // namespace geopro::app +``` + +- [ ] **Step 2: 创建 `src/app/CentralScene.cpp`** + +```cpp +#include "CentralScene.hpp" + +#include +#include +#include + +#include "CameraPreset.hpp" +#include "Scene.hpp" +#include "actors/CurtainActor.hpp" +#include "actors/MapLineActor.hpp" +#include "geo/GeoLocalFrame.hpp" + +namespace geopro::app { + +void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, + vtkRenderWindow* renderWindow, ViewMode mode, + const std::vector& sections, bool showCurtain, + const geopro::core::GeoLocalFrame& frame, double verticalExaggeration) { + scene.clear(); + const bool is2D = (mode == ViewMode::Map2D); + renderer->SetBackground(is2D ? 0.96 : 1.0, is2D ? 0.97 : 1.0, is2D ? 0.99 : 1.0); + + for (const auto& s : sections) { + if (is2D) { + auto line = geopro::render::buildSurveyLine(s.grid, frame); + if (line) scene.addActor(line); + } else if (showCurtain) { + auto curtain = geopro::render::buildCurtain(s.grid, s.colorScale, frame); + if (curtain) { + curtain->SetScale(1.0, 1.0, verticalExaggeration); // 纵向夸张成墙 + scene.addActor(curtain); + } + } + } + + if (is2D) + geopro::render::applyTop2D(renderer); + else + geopro::render::applyFree3D(renderer); + renderer->ResetCamera(); + renderWindow->Render(); +} + +} // namespace geopro::app +``` + +> 头文件名核对自现有 `main.cpp` 用法:`buildSurveyLine`→`actors/MapLineActor.hpp`, +> `buildCurtain`→`actors/CurtainActor.hpp`,`applyTop2D/applyFree3D`→`CameraPreset.hpp`, +> `Scene`→`Scene.hpp`。这些符号来自 `geopro_render`(app 已链接)。 + +- [ ] **Step 3: 接入 `src/app/CMakeLists.txt` 源列表** + +在 `add_executable(geopro_desktop WIN32 ...)` 源列表加 `CentralScene.cpp`(与 Task 8 的 `panels/ObjectTreePanel.cpp` 同处): + +```cmake + panels/ObjectTreePanel.cpp + CentralScene.cpp) +``` + +- [ ] **Step 4: 构建确认通过** + +Run: `cmake --build build/release --target geopro_desktop` +Expected: 编译/链接通过(CentralScene 暂未被引用,单独编译即可)。 + +- [ ] **Step 5: Commit** + +```bash +git add src/app/CentralScene.hpp src/app/CentralScene.cpp src/app/CMakeLists.txt +git commit -m "feat(app): CentralScene 数据驱动 helper(解耦中央三维编排,下一轮接真实DS复用)" +``` + +--- + +## Task 10: main.cpp 接线(构造仓储/控制器 + 信号 + 移除启动 demo) **Files:** - Modify: `src/app/main.cpp` @@ -1259,11 +1381,21 @@ git commit -m "feat(app): ObjectTreePanel 被动对象树(项目→GS→TM)" 在 main.cpp 顶部 include 区(`#include "TopBar.hpp"` 附近)加: ```cpp +#include "CentralScene.hpp" #include "WorkbenchNavController.hpp" #include "api/ApiProjectRepository.hpp" #include "panels/ObjectTreePanel.hpp" ``` +并把匿名命名空间里原本的本地枚举定义: +```cpp +enum class ViewMode { Map2D, View3D }; +``` +替换为复用 helper 的同名枚举(保持后续 `ViewMode::Map2D` 等引用不变): +```cpp +using geopro::app::ViewMode; +``` + - [ ] **Step 2: 修改 `buildWorkbench` 签名,注入控制器** 把: @@ -1294,27 +1426,37 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re > 删除 `auto structure = std::make_shared<...>(repo.loadStructure());`(真实结构改由控制器提供)。 > 注意:原 `rebuildCentral` lambda 捕获了 `tree`/`structure`。见 Step 4 处理。 -- [ ] **Step 4: 中央视图改为占位(移除本地结构驱动)** +- [ ] **Step 4: 中央视图改为调 CentralScene helper(空 sections = 占位)** -`rebuildCentral` 原实现遍历 `tree` 勾选项并渲染本地 grid。本轮中央不接真实数据,改为:清空场景 + 应用背景,不再依赖 `tree`/`structure`。把 `rebuildCentral` lambda 整体替换为: +`rebuildCentral` 原实现遍历 `tree` 勾选项 + `repo.loadGrid` + 体素/切片/地形。本轮中央不接真实数据,改为 +委托 Task 9 的 `rebuildCentralScene`,传**空 sections** → 空背景占位(下一轮喂真实 DS 即复活)。把 +`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(); + // 中央编排已解耦到 CentralScene::rebuildCentralScene(Task 9)。本轮空 sections → 空背景占位。 + // 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活(spec §8.1 / §12.1)。 + auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() { + geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode, + std::vector{}, *showCurtain, + *frame, kVerticalExaggeration); }; ``` -> 这样 `showVoxel/showTerrain/crs/frame/refElev` 等捕获不再被 `rebuildCentral` 使用。它们仍被其它 lambda(图层勾选)引用——图层勾选回调保留但因中央为空而无可视效果,本轮可接受;**不删**这些变量与回调(保留渲染基础设施)。 +> 这样 `showVoxel/showTerrain/showSlice/slicePlane/crs/refElev/structure` 不再被 `rebuildCentral` 捕获使用; +> 它们的声明与图层勾选回调**保留**(渲染基础设施不删),仅不再产生可视效果。 + +并把"视图详情"浮层里**体素 / 切片 / 地形**三个勾选框本轮置灰提示(它们不绑定单 DS,需独立真实数据源, +见 spec §12.1 E)。在三个 `chk*` 创建之后、`if (!crs)` 块附近,追加: + +```cpp + // 本轮中央不接真实派生层:体素/切片/地形勾选置灰,待下一轮接入对应数据源。 + for (QCheckBox* c : {chkVoxel, chkSlice, chkTerrain}) { + c->setEnabled(false); + c->setToolTip(QStringLiteral("(下一轮接入真实数据源)")); + } +``` + +> `chkCurtain` 保持可用(切换 `showCurtain`,被 `rebuildCentralScene` 使用)。 - [ ] **Step 5: 删除"对象树驱动中央/数据列表"的旧连接** @@ -1483,15 +1625,20 @@ git commit -m "feat(app): 工作台接入真实导航(空间/项目/对象树/ ## 自检结论(spec 覆盖核对) -- 工作空间列表/切换 → Task 5(仓储)+ Task 6(控制器)+ Task 7(TopBar)+ Task 9(接线)✅ +- 工作空间列表/切换 → Task 5(仓储)+ Task 6(控制器)+ Task 7(TopBar)+ Task 10(接线)✅ - 项目列表/切换 → 同上 ✅ - 对象树 项目→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 ✅ +- TM 下 DS 列表 → Task 3(parseDatasets)+ Task 5 + Task 10 ✅ +- 失败显示错误/空状态、不回退本地样本 → Task 8(showMessage)+ Task 10(loadFailed 接线)✅ +- 中央三维编排解耦为数据驱动 helper(保留可复用)→ Task 9(CentralScene)✅ +- 渲染占位、移除启动 demo、保留 render/LocalSampleRepository/rebuildDetail → Task 10 ✅ - 项目 crsCode 存入控制器(下一轮替换 EPSG:4547)→ Task 6(currentCrsCode_)✅ - 分层:接口(net 复用)/数据(data: 仓储+dto)/逻辑(controller)/UI(app) → 各 Task 就位、依赖单向向下 ✅ -- 纯逻辑单测(dto + 树构建)→ Task 2/3/4 ✅;线程同步+WaitCursor → Task 9 ✅ +- 纯逻辑单测(dto + 树构建)→ Task 2/3/4 ✅;线程同步+WaitCursor → Task 10 ✅ -## 下一轮(不在本计划) -dd/ert/gpr 真实渲染替换占位;crsCode 重建 GeoLocalFrame;异步仓储 + 分页“加载更多”;用户信息 `auth/getUserInfo`;token 过期自动跳登录;顶部菜单接真实页面。 +## 下一轮(不在本计划,详见 spec §12.1) +接真实 DS 渲染分四步(render 层零改):**A 取数**(新增 DS 内容仓储方法,dd/ert/exception/clr 接口)→ +**B 映射**(DTO → `core::Grid/ScatterField/ColorScale/Anomaly`,加单测)→ **C 接线**(构建 `app::SectionInput` +调 Task 9 的 `rebuildCentralScene`;真实数据触发保留的 `rebuildDetail`,替换占位)→ **D 坐标系**(用 +`currentCrsCode()` 重建 `GeoLocalFrame`,替换硬编码 EPSG:4547)。另:异步仓储+分页、用户信息、token 过期跳登录、 +体素/切片/地形真实数据源、顶部菜单接页面。 diff --git a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md index 790baf3..a676e02 100644 --- a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md +++ b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md @@ -228,12 +228,48 @@ private: - 输入边界:`tmObjectId` / `projectId` 为空时短路不发请求。 ## 8. 渲染解耦 -现状:对象树(本地 grid1/grid2…)直接驱动中央与数据详情。本轮真实树 id 与本地样本对不上,故: -- 启动不再自动渲染本地 demo。 -- 真实 DS 点击 → 中央/详情显示占位文案。 + +现状:对象树(本地 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 }; + +// 一个待渲染剖面: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& sections, bool showCurtain, + const geopro::core::GeoLocalFrame& frame, double verticalExaggeration); +} +``` + +- **本轮**:`main.cpp` 用**空 `sections`** 调用该 helper → 中央为空背景(占位)。视图 2D/3D 切换、帘面勾选仍走它,只是无内容。 +- **下一轮**:`main.cpp` 用真实 DS 数据构建 `std::vector` 再调同一 helper —— 编排零改动。 +- **`rebuildDetail`(数据详情:#18/#17/异常/电极)**:保留在 `main.cpp`(暂不触发),下一轮改触发条件即复活。 +- **体素 / 切片 / 地形**:是 demo 专属派生层(来自两条交叉本地剖面散点 / 本地 DEM),**不绑定单个 DS**, + 不纳入 `rebuildCentralScene`。本轮移除其 `main.cpp` 内联编排(`render/` 函数保留),其"视图详情"勾选项 + 本轮置灰并提示"(下一轮接入)"。它们的复活属独立未来工作(需真实体素/地形数据源),见 §12.1。 + ## 9. 测试策略 依既有无测试桩 + 依赖 live 服务器的现实,聚焦**纯逻辑单测**(GoogleTest + CTest): - `dto/NavDto` 映射:喂样本 JSON(取自 OpenAPI example / 手造)验证 @@ -254,7 +290,8 @@ private: - `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/CentralScene.{hpp,cpp}`(中央三维编排的数据驱动 helper,见 §8.1) +- 测试:`tests/data/test_nav_dto.cpp`(NavDto 映射 + buildStructTree) **改造** - `src/app/TopBar.{hpp,cpp}` — 升级为数据驱动类 + 信号 @@ -263,10 +300,46 @@ private: **保留不删**:`LocalSampleRepository`、`render/*`、`VoxelFromScatters`、现有详情/中央渲染代码。 -## 12. 未决 / 下一轮 -- dd/ert/gpr 真实剖面/反演/雷达数据渲染(替换占位)。 +## 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`。 + +**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::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 渲染剖面"是独立工作,按需另起一轮。 ``` From fc458ec702005b0899a5faa14d1bad3fc27435a9 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 10:56:51 +0800 Subject: [PATCH 05/25] =?UTF-8?q?feat(data):=20=E5=AF=BC=E8=88=AA=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B(Workspace/ProjectSummary/StructNode)=20+=20IProjectRe?= =?UTF-8?q?pository=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/repo/IProjectRepository.hpp | 27 +++++++++++++++++++++++++++ src/data/repo/RepoTypes.hpp | 9 +++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/data/repo/IProjectRepository.hpp diff --git a/src/data/repo/IProjectRepository.hpp b/src/data/repo/IProjectRepository.hpp new file mode 100644 index 0000000..e064370 --- /dev/null +++ b/src/data/repo/IProjectRepository.hpp @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::data { + +// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。 +template +struct RepoResult { + bool ok = false; + T value{}; + std::string error; +}; + +// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。 +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; +}; + +} // namespace geopro::data diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index b671c54..f78aeff 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -6,4 +6,13 @@ struct DsNode { std::string id, name, ddType; }; struct TmNode { std::string id, name, confCode; std::vector dss; }; struct GsNode { std::string id, name; std::vector tms; }; struct Project { std::string id, name; std::vector gss; }; + +// 工作空间(=企业租户/空间)。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; }; } // namespace geopro::data From bf67c01ac96e5a8d535458c89bbb30ed28e7828d Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 11:00:39 +0800 Subject: [PATCH 06/25] =?UTF-8?q?feat(data):=20NavDto=20=E8=84=9A=E6=89=8B?= =?UTF-8?q?=E6=9E=B6=20+=20parseWorkspaces=EF=BC=88=E5=90=AB=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8E=A5=E5=85=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/CMakeLists.txt | 5 +++-- src/data/dto/NavDto.cpp | 33 +++++++++++++++++++++++++++++++++ src/data/dto/NavDto.hpp | 30 ++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/data/test_nav_dto.cpp | 28 ++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/data/dto/NavDto.cpp create mode 100644 src/data/dto/NavDto.hpp create mode 100644 tests/data/test_nav_dto.cpp diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 71d062a..b513412 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -2,8 +2,9 @@ find_package(nlohmann_json CONFIG REQUIRED) find_package(Qt6 COMPONENTS Core REQUIRED) add_library(geopro_data STATIC parse/SampleParsers.cpp - repo/LocalSampleRepository.cpp) + repo/LocalSampleRepository.cpp + dto/NavDto.cpp) target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(geopro_data PUBLIC geopro_core Qt6::Core PRIVATE nlohmann_json::nlohmann_json) +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) diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp new file mode 100644 index 0000000..57a9046 --- /dev/null +++ b/src/data/dto/NavDto.cpp @@ -0,0 +1,33 @@ +#include "dto/NavDto.hpp" + +#include + +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 parseWorkspaces(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 parseStructNodes(const QJsonArray&) { return {}; } +std::vector parseDatasets(const QJsonArray&) { return {}; } +std::vector buildStructTree(const std::vector&) { return {}; } + +} // namespace geopro::data::dto diff --git a/src/data/dto/NavDto.hpp b/src/data/dto/NavDto.hpp new file mode 100644 index 0000000..20141c8 --- /dev/null +++ b/src/data/dto/NavDto.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::data::dto { + +// 工作空间数组(joined/list 的 data["value"])→ 模型。isCurTenant==1 → isCurrent。 +std::vector parseWorkspaces(const QJsonArray& arr); + +// 项目分页(queryByUser 的 data 对象 {hasNextPage, projectList})→ 模型。 +struct ProjectPage { std::vector projects; bool hasNextPage = false; }; +ProjectPage parseProjects(const QJsonObject& data); + +// 结构扁平节点数组(queryProjectStruct 的 data["projectStructList"])→ 模型。 +std::vector parseStructNodes(const QJsonArray& arr); + +// DS 聚合数组(queryDsByTmObjectId 的 data["value"])→ DsNode。ddCode → ddType。 +std::vector parseDatasets(const QJsonArray& arr); + +// 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理:项目直挂 TM、孤儿 parentId、空表。 +struct StructTreeNode { + StructNode node; + bool isTm = false; + std::vector children; +}; +std::vector buildStructTree(const std::vector& flat); + +} // namespace geopro::data::dto diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 06b572b..7296aaf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -36,6 +36,7 @@ target_link_libraries(geopro_tests PRIVATE geopro_core) target_sources(geopro_tests PRIVATE data/test_parsers.cpp) target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) +target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) target_link_libraries(geopro_tests PRIVATE geopro_data) # net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp new file mode 100644 index 0000000..5f5b72a --- /dev/null +++ b/tests/data/test_nav_dto.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include +#include + +#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); +} From a32bd763da69921093db93dbd617c49f512953a4 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 11:16:36 +0800 Subject: [PATCH 07/25] feat(data): NavDto parseProjects/parseStructNodes/parseDatasets --- src/data/dto/NavDto.cpp | 53 ++++++++++++++++++++++++++++++++++--- tests/data/test_nav_dto.cpp | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 57a9046..57f43b1 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -25,9 +25,56 @@ std::vector parseWorkspaces(const QJsonArray& arr) { return out; } -ProjectPage parseProjects(const QJsonObject&) { return {}; } -std::vector parseStructNodes(const QJsonArray&) { return {}; } -std::vector parseDatasets(const QJsonArray&) { return {}; } +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(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 parseStructNodes(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 parseDatasets(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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; +} + std::vector buildStructTree(const std::vector&) { return {}; } } // namespace geopro::data::dto diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 5f5b72a..02cfc3e 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -26,3 +26,51 @@ TEST(NavDto, ParseWorkspacesMapsFieldsAndCurrentFlag) { EXPECT_TRUE(ws[0].isCurrent); EXPECT_FALSE(ws[1].isCurrent); } + +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"); +} From 2bc22a55d60edf297cb305c662b42c17d15e0660 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 11:33:14 +0800 Subject: [PATCH 08/25] =?UTF-8?q?feat(data):=20buildStructTree=20=E6=89=81?= =?UTF-8?q?=E5=B9=B3=E2=86=92=E6=A0=91=EF=BC=88=E5=8F=B6=E5=AD=90=3DTM?= =?UTF-8?q?=EF=BC=8C=E5=90=AB=E7=9B=B4=E6=8C=82/=E5=AD=A4=E5=84=BF/?= =?UTF-8?q?=E7=A9=BA=E8=A1=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/dto/NavDto.cpp | 34 +++++++++++++++++++++++++++++++++- tests/data/test_nav_dto.cpp | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 57f43b1..2241804 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -2,6 +2,9 @@ #include +#include +#include + namespace geopro::data::dto { namespace { @@ -75,6 +78,35 @@ std::vector parseDatasets(const QJsonArray& arr) { return out; } -std::vector buildStructTree(const std::vector&) { return {}; } +std::vector buildStructTree(const std::vector& flat) { + std::set ids; + std::set 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(const std::string&, bool)> build = + [&](const std::string& parentId, bool root) { + std::vector 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); +} } // namespace geopro::data::dto diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 02cfc3e..878901f 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -74,3 +74,35 @@ TEST(NavDto, ParseDatasetsMapsDdCodeToDdType) { EXPECT_EQ(ds[0].name, "批次1"); EXPECT_EQ(ds[0].ddType, "dd_section"); } + +TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) { + const std::vector 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 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()); +} From 695aa8c3101885456a548e7e38e1e9d11a5bfa7d Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 11:38:02 +0800 Subject: [PATCH 09/25] =?UTF-8?q?fix(data):=20buildStructTree=20=E7=94=A8?= =?UTF-8?q?=20visited=20=E9=9B=86=E9=98=B2=E7=8E=AF=EF=BC=88=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E4=BF=A1=E7=BB=93=E6=9E=84=E6=95=B0=E6=8D=AE=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=97=A0=E9=99=90=E9=80=92=E5=BD=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/dto/NavDto.cpp | 6 +++++- tests/data/test_nav_dto.cpp | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 2241804..e4a45f2 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -91,13 +91,17 @@ std::vector buildStructTree(const std::vector& flat) auto isRootLevel = [&](const StructNode& n) { return n.parentId.empty() || ids.find(n.parentId) == ids.end(); }; + // visited 防环:每个 id 最多进树一次。对正常树(单父)等价于原逻辑; + // 对不可信后端数据的多节点环 / 重复 id 环,避免无限递归(规约:永不信任外部数据)。 + std::set visited; std::function(const std::string&, bool)> build = [&](const std::string& parentId, bool root) { std::vector out; for (const auto& n : flat) { const bool belongs = root ? isRootLevel(n) : (n.parentId == parentId); if (!belongs) continue; - if (n.id == parentId) continue; // 防自环 + if (visited.count(n.id)) continue; // 已进树 → 跳过,防环/防重复 + visited.insert(n.id); StructTreeNode t; t.node = n; t.isTm = isLeaf(n.id); diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 878901f..e7e4368 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -106,3 +106,16 @@ TEST(NavDto, BuildStructTreeOrphanParentBecomesRoot) { TEST(NavDto, BuildStructTreeEmpty) { EXPECT_TRUE(dto::buildStructTree({}).empty()); } + +TEST(NavDto, BuildStructTreeHandlesCycleWithoutInfiniteRecursion) { + // 不可信数据:重复 id 形成可达环(R→X→Y→重复X…)。必须终止、不崩。 + const std::vector flat = { + {"R", "根", "", "GS", "", 1}, + {"X", "x", "R", "GS", "", 1}, + {"Y", "y", "X", "GS", "", 1}, + {"X", "x2", "Y", "TM", "", 2}, // 重复 id X,父=Y → 若不防环将无限递归 + }; + const auto roots = dto::buildStructTree(flat); // 不挂起即通过 + ASSERT_EQ(roots.size(), 1u); + EXPECT_EQ(roots[0].node.id, "R"); +} From fa4bbf08b3f288f731faac43d2edb7287f37e3cb Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 11:40:40 +0800 Subject: [PATCH 10/25] =?UTF-8?q?feat(data):=20ApiProjectRepository=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=205=20=E4=B8=AA=E5=AF=BC=E8=88=AA=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/CMakeLists.txt | 3 +- src/data/api/ApiProjectRepository.cpp | 66 +++++++++++++++++++++++++++ src/data/api/ApiProjectRepository.hpp | 23 ++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/data/api/ApiProjectRepository.cpp create mode 100644 src/data/api/ApiProjectRepository.hpp diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index b513412..803e557 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -3,7 +3,8 @@ find_package(Qt6 COMPONENTS Core REQUIRED) add_library(geopro_data STATIC parse/SampleParsers.cpp repo/LocalSampleRepository.cpp - dto/NavDto.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) diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp new file mode 100644 index 0000000..39f383d --- /dev/null +++ b/src/data/api/ApiProjectRepository.cpp @@ -0,0 +1,66 @@ +#include "api/ApiProjectRepository.hpp" + +#include +#include +#include + +#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> 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 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> 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> 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> 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 diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp new file mode 100644 index 0000000..b52ec51 --- /dev/null +++ b/src/data/api/ApiProjectRepository.hpp @@ -0,0 +1,23 @@ +#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> listWorkspaces() override; + RepoResult switchWorkspace(const std::string& tenantId) override; + RepoResult> listProjects(const std::string& lastProjectId) override; + RepoResult> loadStructure(const std::string& projectId) override; + RepoResult> loadDatasetsOfTm(const std::string& tmObjectId) override; + +private: + net::ApiClient& api_; +}; + +} // namespace geopro::data From f4ca9bcd38d6b8cb0cb92d6d799641037383b547 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 11:44:31 +0800 Subject: [PATCH 11/25] =?UTF-8?q?feat(controller):=20WorkbenchNavControlle?= =?UTF-8?q?r=20=E5=AF=BC=E8=88=AA=E7=8A=B6=E6=80=81=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CMakeLists.txt | 1 + src/controller/CMakeLists.txt | 7 ++ src/controller/WorkbenchNavController.cpp | 108 ++++++++++++++++++++++ src/controller/WorkbenchNavController.hpp | 42 +++++++++ 4 files changed, 158 insertions(+) create mode 100644 src/controller/CMakeLists.txt create mode 100644 src/controller/WorkbenchNavController.cpp create mode 100644 src/controller/WorkbenchNavController.hpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index cf87a09..46b32b6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,4 +12,5 @@ add_subdirectory(core) add_subdirectory(data) add_subdirectory(net) add_subdirectory(render) +add_subdirectory(controller) add_subdirectory(app) diff --git a/src/controller/CMakeLists.txt b/src/controller/CMakeLists.txt new file mode 100644 index 0000000..45f34d7 --- /dev/null +++ b/src/controller/CMakeLists.txt @@ -0,0 +1,7 @@ +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) diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp new file mode 100644 index 0000000..4783d07 --- /dev/null +++ b/src/controller/WorkbenchNavController.cpp @@ -0,0 +1,108 @@ +#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 diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp new file mode 100644 index 0000000..c96d7d1 --- /dev/null +++ b/src/controller/WorkbenchNavController.hpp @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include +#include + +#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& list, const QString& currentId); + void projectsLoaded(const std::vector& list, const QString& currentId); + void structureLoaded(const QString& projectName, const std::vector& nodes); + void datasetsLoaded(const QString& tmObjectId, const std::vector& list); + void loadFailed(const QString& stage, const QString& message); + +private: + void loadProjectsAndStructure(); // start + switchWorkspace 共用 + + data::IProjectRepository& repo_; + std::vector lastProjects_; + std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; +}; + +} // namespace geopro::controller From 6e78e50b0bd2096ad178bb53f20c77a3b18d8988 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 11:48:40 +0800 Subject: [PATCH 12/25] =?UTF-8?q?feat(app):=20TopBar=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E6=95=B0=E6=8D=AE=E9=A9=B1=E5=8A=A8=E7=B1=BB=EF=BC=88?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=A9=BA=E9=97=B4/=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E4=BF=A1=E5=8F=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/TopBar.cpp | 147 ++++++++++++++++++++++++++------------------- src/app/TopBar.hpp | 34 +++++++---- src/app/main.cpp | 2 +- 3 files changed, 108 insertions(+), 75 deletions(-) diff --git a/src/app/TopBar.cpp b/src/app/TopBar.cpp index 57e08d9..97e35a3 100644 --- a/src/app/TopBar.cpp +++ b/src/app/TopBar.cpp @@ -127,12 +127,10 @@ QWidget* buildMenuBar(QWidget* parent) return mb; } -QWidget* buildTopToolBar(QWidget* parent) -{ - auto* bar = new QWidget(parent); - bar->setObjectName(QStringLiteral("appToolBar")); - bar->setFixedHeight(56); - bar->setStyleSheet(QStringLiteral( +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;" @@ -146,81 +144,56 @@ QWidget* buildTopToolBar(QWidget* parent) "#userName { color:#1F2A3D; font-size:13px; font-weight:600; }" "#userRole { color:#8A93A3; font-size:11px; }")); - auto* lay = new QHBoxLayout(bar); + auto* lay = new QHBoxLayout(this); lay->setContentsMargins(14, 0, 14, 0); lay->setSpacing(0); - // ── 工作空间切换(最左):显示当前空间,点击下拉切换 ── - auto* wsBtn = new QToolButton(bar); - 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); - - auto* wsMenu = new QMenu(bar); - auto* wsHeader = wsMenu->addAction(QStringLiteral("切换空间")); - wsHeader->setEnabled(false); - wsMenu->addSeparator(); - auto* wsGroup = new QActionGroup(bar); - wsGroup->setExclusive(true); - const QStringList spaces = {QStringLiteral("个人工作空间"), QStringLiteral("勘探一队"), - QStringLiteral("研究院共享")}; - for (const auto& s : spaces) { - auto* a = wsMenu->addAction(s); - a->setCheckable(true); - wsGroup->addAction(a); - if (s == spaces.front()) a->setChecked(true); - QObject::connect(a, &QAction::triggered, wsBtn, - [wsBtn, s]() { wsBtn->setText(s + QStringLiteral(" ▾")); }); - } - wsBtn->setMenu(wsMenu); - wsBtn->setText(spaces.front() + QStringLiteral(" ▾")); - lay->addWidget(wsBtn); + // 工作空间切换器(数据驱动;初始占位文本,待 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(bar)); + lay->addWidget(makeDivider(this)); lay->addSpacing(10); - // ── 项目选择器(与工作空间切换同款样式:无边框 + 图标 + 文本 + 下拉)── - auto* projBtn = new QToolButton(bar); - 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); - auto* projMenu = new QMenu(bar); - auto* projHeader = projMenu->addAction(QStringLiteral("切换项目")); - projHeader->setEnabled(false); - projMenu->addSeparator(); - auto* projCur = projMenu->addAction(QStringLiteral("青海湖北岸勘探项目")); - projCur->setCheckable(true); - projCur->setChecked(true); - projBtn->setMenu(projMenu); - projBtn->setText(QStringLiteral("青海湖北岸勘探项目 青海·海北州 ▾")); - lay->addWidget(projBtn); + // 项目切换器(数据驱动)。 + 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(bar, Glyph::Help, QStringLiteral("帮助"))); - lay->addWidget(makeIconButton(bar, Glyph::Bell, QStringLiteral("通知"))); - lay->addWidget(makeIconButton(bar, Glyph::Gear, QStringLiteral("设置"))); + 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(bar)); + lay->addWidget(makeDivider(this)); lay->addSpacing(12); - // ── 用户:圆形头像 + 姓名/职务 ── - auto* avatar = new QLabel(QStringLiteral("ZL"), bar); + // 用户区(本轮静态)。 + 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(bar); + auto* userBox = new QWidget(this); auto* userLay = new QVBoxLayout(userBox); userLay->setContentsMargins(0, 0, 0, 0); userLay->setSpacing(0); @@ -231,8 +204,56 @@ QWidget* buildTopToolBar(QWidget* parent) userLay->addWidget(userName); userLay->addWidget(userRole); lay->addWidget(userBox); +} - return bar; +void TopBar::setWorkspaces(const std::vector& 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& 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(" ▾")); } } // namespace geopro::app diff --git a/src/app/TopBar.hpp b/src/app/TopBar.hpp index 6f4a3e0..3937f9f 100644 --- a/src/app/TopBar.hpp +++ b/src/app/TopBar.hpp @@ -1,19 +1,31 @@ #pragma once +#include +#include +#include "repo/RepoTypes.hpp" -// 顶部应用区(对齐原型,静态视觉壳): -// - buildMenuBar:最上方的菜单栏(视图 / 项目管理 / 业务工具 / 设备,含多级子菜单)。 -// - buildTopToolBar:菜单栏下方的工具条(工作空间切换 + 项目选择 + 帮助/通知/设置 + 用户)。 -// 调用方将两者纵向堆叠后经 QMainWindow::setMenuWidget 挂到主窗口顶部。 -// 菜单/按钮当前为静态占位,后续接真实页面与数据。 - -class QWidget; +class QToolButton; namespace geopro::app { -// 顶部菜单栏(返回 QWidget*,内部是 QMenuBar;调用方放在最上一行)。 -QWidget* buildMenuBar(QWidget* parent = nullptr); +// 顶部菜单栏(静态,本轮不接真实页面)。 +QWidget* buildMenuBar(QWidget* parent); -// 菜单栏下方的工具条(工作空间/项目/帮助/通知/设置/用户)。 -QWidget* buildTopToolBar(QWidget* parent = nullptr); +// 顶部工具条:数据驱动的工作空间/项目切换器 + 右侧图标 + 用户区。 +class TopBar : public QWidget { + Q_OBJECT +public: + explicit TopBar(QWidget* parent = nullptr); + + void setWorkspaces(const std::vector& list, const QString& currentId); + void setProjects(const std::vector& 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 diff --git a/src/app/main.cpp b/src/app/main.cpp index e77585c..2fee684 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -790,7 +790,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re topLayout->setContentsMargins(0, 0, 0, 0); topLayout->setSpacing(0); topLayout->addWidget(geopro::app::buildMenuBar(topChrome)); - topLayout->addWidget(geopro::app::buildTopToolBar(topChrome)); + topLayout->addWidget(new geopro::app::TopBar(topChrome)); window.setMenuWidget(topChrome); } From c78022a6b6c73ed3d3a9186e8932ab4dddfd3bf7 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 11:52:31 +0800 Subject: [PATCH 13/25] =?UTF-8?q?feat(app):=20ObjectTreePanel=20=E8=A2=AB?= =?UTF-8?q?=E5=8A=A8=E5=AF=B9=E8=B1=A1=E6=A0=91=EF=BC=88=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E2=86=92GS=E2=86=92TM=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 4 +- src/app/panels/ObjectTreePanel.cpp | 95 ++++++++++++++++++++++++++++++ src/app/panels/ObjectTreePanel.hpp | 30 ++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/app/panels/ObjectTreePanel.cpp create mode 100644 src/app/panels/ObjectTreePanel.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index dff8680..2215046 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -21,7 +21,8 @@ add_executable(geopro_desktop WIN32 PanelHeader.cpp login/LoginWindow.cpp panels/AnomalyListPanel.cpp - panels/DatasetListPanel.cpp) + panels/DatasetListPanel.cpp + panels/ObjectTreePanel.cpp) target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) @@ -34,6 +35,7 @@ target_link_libraries(geopro_desktop PRIVATE geopro_data # Phase 2:本地样本仓储(对象树 / 网格 / 色阶) geopro_net # Phase 3:登录(验证码 + RSA + login2) geopro_render # Phase 4:render 层(Scene / GridContourActor / 相机预设) + geopro_controller # Phase 5:导航编排(WorkbenchNavController) ) vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES}) diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp new file mode 100644 index 0000000..39aabe6 --- /dev/null +++ b/src/app/panels/ObjectTreePanel.cpp @@ -0,0 +1,95 @@ +#include "panels/ObjectTreePanel.hpp" + +#include +#include +#include +#include +#include +#include + +#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& 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& 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 diff --git a/src/app/panels/ObjectTreePanel.hpp b/src/app/panels/ObjectTreePanel.hpp new file mode 100644 index 0000000..58d54a8 --- /dev/null +++ b/src/app/panels/ObjectTreePanel.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#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& 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 From 6241eb3a7e8e34fc69e3946a4a529b13af3a809e Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 11:54:48 +0800 Subject: [PATCH 14/25] =?UTF-8?q?feat(app):=20CentralScene=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=A9=B1=E5=8A=A8=20helper=EF=BC=88=E8=A7=A3=E8=80=A6?= =?UTF-8?q?=E4=B8=AD=E5=A4=AE=E4=B8=89=E7=BB=B4=E7=BC=96=E6=8E=92=EF=BC=8C?= =?UTF-8?q?=E4=B8=8B=E4=B8=80=E8=BD=AE=E6=8E=A5=E7=9C=9F=E5=AE=9EDS?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 3 ++- src/app/CentralScene.cpp | 44 ++++++++++++++++++++++++++++++++++++++++ src/app/CentralScene.hpp | 31 ++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/app/CentralScene.cpp create mode 100644 src/app/CentralScene.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 2215046..f398122 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -22,7 +22,8 @@ add_executable(geopro_desktop WIN32 login/LoginWindow.cpp panels/AnomalyListPanel.cpp panels/DatasetListPanel.cpp - panels/ObjectTreePanel.cpp) + panels/ObjectTreePanel.cpp + CentralScene.cpp) target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/app/CentralScene.cpp b/src/app/CentralScene.cpp new file mode 100644 index 0000000..2579151 --- /dev/null +++ b/src/app/CentralScene.cpp @@ -0,0 +1,44 @@ +#include "CentralScene.hpp" + +#include +#include +#include + +#include "CameraPreset.hpp" +#include "Scene.hpp" +#include "actors/CurtainActor.hpp" +#include "actors/MapLineActor.hpp" +#include "geo/GeoLocalFrame.hpp" + +namespace geopro::app { + +void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, + vtkRenderWindow* renderWindow, ViewMode mode, + const std::vector& sections, bool showCurtain, + const geopro::core::GeoLocalFrame& frame, double verticalExaggeration) { + scene.clear(); + const bool is2D = (mode == ViewMode::Map2D); + renderer->SetBackground(is2D ? 0.96 : 1.0, is2D ? 0.97 : 1.0, is2D ? 0.99 : 1.0); + + for (const auto& s : sections) { + if (is2D) { + auto line = geopro::render::buildSurveyLine(s.grid, frame); + if (line) scene.addActor(line); + } else if (showCurtain) { + auto curtain = geopro::render::buildCurtain(s.grid, s.colorScale, frame); + if (curtain) { + curtain->SetScale(1.0, 1.0, verticalExaggeration); // 纵向夸张成墙 + scene.addActor(curtain); + } + } + } + + if (is2D) + geopro::render::applyTop2D(renderer); + else + geopro::render::applyFree3D(renderer); + renderer->ResetCamera(); + renderWindow->Render(); +} + +} // namespace geopro::app diff --git a/src/app/CentralScene.hpp b/src/app/CentralScene.hpp new file mode 100644 index 0000000..1e75032 --- /dev/null +++ b/src/app/CentralScene.hpp @@ -0,0 +1,31 @@ +#pragma once +#include + +#include "model/ColorScale.hpp" +#include "model/Field.hpp" + +namespace geopro::core { class GeoLocalFrame; } +namespace geopro::render { class Scene; } +class vtkRenderer; +class vtkRenderWindow; + +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)。 +// 下一轮接真实 DS:构建 sections 后调用本函数即可,render 层零改动。 +void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, + vtkRenderWindow* renderWindow, ViewMode mode, + const std::vector& sections, bool showCurtain, + const geopro::core::GeoLocalFrame& frame, double verticalExaggeration); + +} // namespace geopro::app From 405fb2ae4ff42f471e1bb2b59ef85f2ac2a8e859 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 12:01:30 +0800 Subject: [PATCH 15/25] =?UTF-8?q?feat(app):=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E=E5=AF=BC=E8=88=AA=EF=BC=88?= =?UTF-8?q?=E7=A9=BA=E9=97=B4/=E9=A1=B9=E7=9B=AE/=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E6=A0=91/DS=EF=BC=89=EF=BC=8C=E4=B8=AD=E5=A4=AE=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.cpp | 300 ++++++++++++++--------------------------------- 1 file changed, 88 insertions(+), 212 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 2fee684..0e8156a 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -60,6 +60,10 @@ #include "PanelHeader.hpp" #include "Theme.hpp" #include "TopBar.hpp" +#include "CentralScene.hpp" +#include "WorkbenchNavController.hpp" +#include "api/ApiProjectRepository.hpp" +#include "panels/ObjectTreePanel.hpp" #include "login/LoginWindow.hpp" #include "panels/AnomalyListPanel.hpp" #include "panels/DatasetListPanel.hpp" @@ -95,41 +99,6 @@ namespace { -// 角色:树 TM 项存 tmId(UserRole+2);数据列表 DS 项的 dsId/ddType 由 panels/DatasetListPanel 定义。 -constexpr int kRoleTmId = Qt::UserRole + 2; - -// 从对象结构树构建 QTreeWidget:GS → TM 两层(对齐原型;DS=采集批次在左下「数据列表」,不进树)。 -// TM(测线) 项可勾选(复选框):勾选驱动该测线的 dd_section 在中央场景显示;UserRole+2 存 tmId。 -// 含 dd_section 的测线默认勾选,启动即显示。 -void populateTree(QTreeWidget* tree, const std::vector& gss) -{ - for (const auto& gs : gss) { - auto* gsItem = new QTreeWidgetItem(tree); - gsItem->setText(0, QString::fromStdString(gs.name)); - for (const auto& tm : gs.tms) { - auto* tmItem = new QTreeWidgetItem(gsItem); - tmItem->setText(0, QString::fromStdString(tm.name)); - tmItem->setData(0, kRoleTmId, QString::fromStdString(tm.id)); - tmItem->setFlags(tmItem->flags() | Qt::ItemIsUserCheckable); - const bool hasSection = - std::any_of(tm.dss.begin(), tm.dss.end(), - [](const geopro::data::DsNode& d) { return d.ddType == "dd_section"; }); - tmItem->setCheckState(0, hasSection ? Qt::Checked : Qt::Unchecked); - } - } - tree->expandAll(); -} - -// 在结构中按 tmId 查 TM;找不到返回 nullptr。 -const geopro::data::TmNode* findTm(const std::vector& gss, - const std::string& tmId) -{ - for (const auto& gs : gss) - for (const auto& tm : gs.tms) - if (tm.id == tmId) return &tm; - return nullptr; -} - // 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。 std::string readPem(const std::string& path) { @@ -150,7 +119,7 @@ double median(std::vector v) } // 当前中央视图(默认二维地图)。二维地图=测线红线俯视;三维视图=断面墙。 -enum class ViewMode { Map2D, View3D }; +using geopro::app::ViewMode; // 数据详情显示内容(默认网格数据)。网格数据=#18 banded;原数据=#17 散点(对齐原型命名)。 enum class DetailMode { Section18, Scatter17 }; @@ -168,7 +137,8 @@ constexpr const char* kWgs84 = "EPSG:4326"; // 在给定 QMainWindow 上构建 M1 工作台。 // repo 生命周期须覆盖到事件循环结束(由调用方保证)。 -void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo) +void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo, + geopro::controller::WorkbenchNavController& nav) { // ── 世界系:启动取一次 grid1 的 lat/lon,用中位数作 GeoLocalFrame 原点 ── // 全项目共享(shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。 @@ -291,6 +261,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re chkTerrain->setEnabled(false); chkTerrain->setToolTip(tip); chkSlice->setEnabled(false); chkSlice->setToolTip(tip); } + // 本轮中央不接真实派生层:体素/切片/地形勾选置灰,待下一轮接入对应数据源。 + for (QCheckBox* c : {chkVoxel, chkSlice, chkTerrain}) { + c->setEnabled(false); + c->setToolTip(QStringLiteral("(下一轮接入真实数据源)")); + } layerLayout->addWidget(layerTitle); layerLayout->addWidget(chkCurtain); layerLayout->addWidget(chkVoxel); @@ -351,29 +326,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 放在中央视图下方。 dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea); - // 项目结构(GS→TM→DS):取一次共享,供树/中央/数据列表查 TM 的数据集。 - auto structure = std::make_shared>(repo.loadStructure()); - - // 左上 dock:对象树(GS→TM,测线复选)。表头交给自绘 PanelHeader,隐藏树自带列头(避免双标题)。 - auto* tree = new QTreeWidget(); - tree->setHeaderHidden(true); - populateTree(tree, *structure); - // 选中行高亮不覆盖左侧缩进/折叠箭头列:给 branch 设白底(与树底一致),并用生成的箭头图片 - // 保留展开/折叠图标(直接给 branch 设背景会触发 Qt 不再画默认箭头的陷阱)。 - { - const QString openArrow = geopro::app::writeChevronIcon(true, QColor("#8A93A3")); - const QString closedArrow = geopro::app::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)); - } + // 左上 dock:对象树(真实结构:项目根 → GS → TM)。被动视图,数据由控制器推送。 + auto* objectTree = new geopro::app::ObjectTreePanel(); auto* leftDock = new ads::CDockWidget(QStringLiteral("对象显示栏")); - leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象显示栏"), tree, + leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象显示栏"), + objectTree, {{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}})); auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock); @@ -438,128 +395,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (auto* bar = area->titleBar()) bar->setVisible(false); } - // ── 中央视图重建(核心)───────────────────────────────────────────── - // 按勾选的测线(TM)整体重建:scene.clear() → 对每个勾选 TM 的 dd_section 加对应 actor。 - // 二维地图 = buildSurveyLine(红线俯视,浅底背景)+ applyTop2D。 - // 三维视图 = buildCurtain(断面墙)SetScale(1,1,kCurtainZScale) + applyFree3D(白底)。 - // frame/structure 全局共享;切视图/勾选变化都调用此函数重建当前视图。 - auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, &repo, frame, tree, - structure, showCurtain, showVoxel, showTerrain, showSlice, slicePlane, - crs, refElev]() { - // 先拆除上次的切片 widget(独立于 scene actor,须显式关闭),再按条件重建。 - 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); - - // 渲染单个 dd_section 数据集:二维=测线线;三维=帘面(受「帘面」图层开关控制)。 - auto renderSection = [&](const std::string& id) { - const auto g = repo.loadGrid(id); - if (is2D) { - auto line = geopro::render::buildSurveyLine(g, *frame); - if (line) scene->addActor(line); - } else if (*showCurtain) { - const auto cs = repo.loadColorScale(id); - auto curtain = geopro::render::buildCurtain(g, cs, *frame); - if (curtain) { - curtain->SetScale(1.0, 1.0, kCurtainZScale); // 纵向夸张成墙 - scene->addActor(curtain); - } - } - }; - - // 遍历对象树收集所有勾选的测线(TM),渲染其 dd_section 数据集(可多条共存)。 - QList stack; - for (int i = 0; i < tree->topLevelItemCount(); ++i) stack.append(tree->topLevelItem(i)); - while (!stack.isEmpty()) { - QTreeWidgetItem* cur = stack.takeFirst(); - for (int i = 0; i < cur->childCount(); ++i) stack.append(cur->child(i)); - - const QString tmId = cur->data(0, kRoleTmId).toString(); - if (tmId.isEmpty()) continue; // GS 节点忽略 - if (cur->checkState(0) != Qt::Checked) continue; // 仅显示勾选的测线 - const auto* tm = findTm(*structure, tmId.toStdString()); - if (!tm) continue; - for (const auto& ds : tm->dss) - if (ds.ddType == "dd_section") renderSection(ds.id); - } - - // 三维「体素 / 切片」图层:两交叉测线散点经 CRS 配准 IDW 成体素。 - // 体素=GPU 体绘制(与帘面同纵向夸张);切片=vtkImagePlaneWidget 在体素 image 上交互拖切面。 - // 注:切片 widget 作用于 image 原始米坐标(无 actor 夸张),与夸张后的体绘制存在纵向比例差 - // (spec M-3 Z 基准统一待办);切片本身演示 dd_slice 交互正确。 - if (!is2D && (*showVoxel || *showSlice) && crs) { - const auto profs = repo.loadVoxelScatters(); - const auto vcs = repo.loadScatterColorScale("grid1"); - // 纵向夸张烤进 image(zDisplayScale=kCurtainZScale),使体绘制/切片/帘面纵向一致。 - auto vr = geopro::render::buildVoxelFromScatters(profs, vcs, *crs, *frame, 1.0, 0.5, 2.0, - 4.0, kCurtainZScale); - if (vr.valid()) { - if (*showVoxel) { - rendererPtr->AddVolume(vr.volume); // 夸张已烤进 image,无需 actor SetScale - } - vtkRenderWindowInteractor* interactor = renderWindowPtr->GetInteractor(); - if (*showSlice && interactor) { - const std::vector stops = vcs.stopValues(); - const double vmn = stops.size() >= 2 ? stops.front() : 0.0; - const double vmx = stops.size() >= 2 ? stops.back() : 1.0; - auto lut = geopro::render::buildLut(vcs, vmn, vmx, 256); - int dims[3] = {1, 1, 1}; - vr.image->GetDimensions(dims); - auto plane = vtkSmartPointer::New(); - plane->SetInteractor(interactor); - plane->SetInputData(vr.image); - plane->SetPlaneOrientationToXAxes(); - plane->SetSliceIndex(dims[0] / 2); - plane->SetLookupTable(lut); - plane->DisplayTextOn(); - // 左键拖动=移动切面(默认左键是取值光标十字,不直观);中键仍可取值。 - plane->SetLeftButtonAction(vtkImagePlaneWidget::VTK_SLICE_MOTION_ACTION); - plane->SetMiddleButtonAction(vtkImagePlaneWidget::VTK_CURSOR_ACTION); - plane->On(); - *slicePlane = plane; - } - } - } - - // 三维「地形」图层:GDAL 读 DEM(高程)+影像(EPSG:3857),重投影到世界系,warp 面 + 纹理。 - if (!is2D && *showTerrain && crs) { - // zOffset=refElev 使地形落在测线地表高程附近(不按绝对高程浮空);zScale=1 真实起伏。 - auto terr = geopro::render::buildTerrain(repo.demPath(), repo.imagePath(), *frame, - refElev, 1.0); - if (terr) scene->addActor(terr); - } - - if (is2D) - geopro::render::applyTop2D(rendererPtr); - else - geopro::render::applyFree3D(rendererPtr); - rendererPtr->ResetCamera(); - renderWindowPtr->Render(); + // 中央编排已解耦到 CentralScene::rebuildCentralScene(数据驱动)。本轮空 sections → 空背景占位。 + // 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活。 + auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() { + geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode, + std::vector{}, *showCurtain, + *frame, kCurtainZScale); }; - // 勾选/取消某测线(TM) → 重建当前视图内容(勾的才显示;可多条共存)。 - QObject::connect(tree, &QTreeWidget::itemChanged, tree, - [rebuildCentral](QTreeWidgetItem* item, int) { - if (item->data(0, kRoleTmId).toString().isEmpty()) return; // GS 忽略 - rebuildCentral(); - }); - - // 单击测线(TM) → 左下数据列表填充其采集批次(数据集) + 动态标题 + 数据 Tab 数量。 - QObject::connect(tree, &QTreeWidget::itemClicked, tree, - [structure, datasetList, datasetTitle, datasetTabs](QTreeWidgetItem* item, int) { - const QString tmId = item->data(0, kRoleTmId).toString(); - if (tmId.isEmpty()) return; // GS 节点无数据集 - const auto* tm = findTm(*structure, tmId.toStdString()); - if (!tm) return; - geopro::app::populateDatasetList(datasetList, tm->dss); - if (datasetTitle) - datasetTitle->setText(QStringLiteral("数据集显示栏 · %1").arg(item->text(0))); - datasetTabs->setTabText( - 0, QStringLiteral("数据 (%1)").arg(static_cast(tm->dss.size()))); - }); - // ── 数据详情共享状态 + 重建 ────────────────────────────────────────── // 当前选中数据集 id(空=未选)与详情显示模式(反演剖面/原数据);切模式或换选中都重建。 auto currentDsId = std::make_shared(); @@ -656,15 +499,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re .arg(anomalies.size())); }; - // ── 单击左下数据列表的采集批次(DS) → 加载到数据详情/异常/属性 ── + // ── 单击左下数据列表的采集批次(DS) → 占位(真实剖面/反演渲染下一阶段接 dd 接口)── QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, - [loadDataset](QListWidgetItem* item) { - const QString dsId = item->data(geopro::app::kDsIdRole).toString(); - const QString ddType = item->data(geopro::app::kDsDdTypeRole).toString(); - if (ddType != "dd_section") return; // 仅剖面网格有详情图 + [propLabel, detailRendererPtr, detailRenderWindowPtr](QListWidgetItem* item) { const QString name = item->data(Qt::DisplayRole).toString().section('\n', 0, 0); - loadDataset(dsId, name); + detailRendererPtr->RemoveAllViewProps(); + detailRenderWindowPtr->Render(); + propLabel->setText(QStringLiteral( + "数据集: %1\n(该数据集的剖面/反演渲染将在下一阶段接入 dd 接口)").arg(name)); }); // ── 异常列表勾选(显隐) → 更新隐藏集 → 重建数据详情 ── @@ -755,45 +598,72 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re rebuildCentral(); }); - // ── 启动默认:测线已勾选,但 itemChanged 在 connect 之前触发故未渲染;这里重建一次中央内容。 + // ── 启动:建立一次空背景中央视图(真实 sections 数据由下一轮接入)。 rebuildCentral(); - // 启动默认:选第一个含 dd_section 的测线 → 填充数据列表 + 加载其首个 dd_section 详情(对齐原型)。 - for (const auto& gs : *structure) { - const geopro::data::TmNode* picked = nullptr; - for (const auto& tm : gs.tms) { - const bool hasSection = - std::any_of(tm.dss.begin(), tm.dss.end(), - [](const geopro::data::DsNode& d) { return d.ddType == "dd_section"; }); - if (hasSection) { picked = &tm; break; } - } - if (!picked) continue; - geopro::app::populateDatasetList(datasetList, picked->dss); - if (datasetTitle) - datasetTitle->setText( - QStringLiteral("数据集显示栏 · %1").arg(QString::fromStdString(picked->name))); - datasetTabs->setTabText( - 0, QStringLiteral("数据 (%1)").arg(static_cast(picked->dss.size()))); - for (const auto& ds : picked->dss) - if (ds.ddType == "dd_section") { - loadDataset(QString::fromStdString(ds.id), QString::fromStdString(ds.name)); - break; - } - break; - } - // 顶部应用区(静态视觉壳,对齐原型):上=菜单栏(视图/项目管理/业务工具/设备), // 下=工具条(工作空间切换 + 项目 + 帮助/通知/设置 + 用户)。纵向堆叠后挂到主窗口顶部。 + 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)); - topLayout->addWidget(new geopro::app::TopBar(topChrome)); + topBar = new geopro::app::TopBar(topChrome); + topLayout->addWidget(topBar); window.setMenuWidget(topChrome); } + // ── 控制器 ↔ 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& list, const QString& cur) { + topBar->setWorkspaces(list, cur); + }); + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar, + [topBar](const std::vector& 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& 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& list) { + geopro::app::populateDatasetList(datasetList, list); + if (datasetTitle) + datasetTitle->setText(QStringLiteral("数据集显示栏")); + datasetTabs->setTabText( + 0, QStringLiteral("数据 (%1)").arg(static_cast(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(); + }); + // 底部状态栏:常驻显示坐标系与世界系原点(wayfinding:用户随时知道当前空间基准)。 window.statusBar()->showMessage( QStringLiteral("就绪 | 坐标系 %1 | 世界系原点 %2, %3") @@ -851,13 +721,19 @@ int main(int argc, char* argv[]) 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); + buildWorkbench(window, repo, nav); window.show(); + nav.start(); // 进入工作台后拉真实 空间/项目/结构 + return app.exec(); } From 601706d1207809e353901eca4108c1fb6a8452a0 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 12:15:04 +0800 Subject: [PATCH 16/25] =?UTF-8?q?fix:=20=E4=BB=A3=E7=A0=81=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E6=95=B4=E6=94=B9=EF=BC=88=E6=8E=A7=E5=88=B6=E5=99=A8?= =?UTF-8?q?=E9=98=B2=E9=87=8D=E5=85=A5=20+=20URL=20=E7=99=BE=E5=88=86?= =?UTF-8?q?=E5=8F=B7=E7=BC=96=E7=A0=81=20+=20=E6=B5=8B=E8=AF=95/=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E5=AE=8C=E5=96=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.cpp | 1 + src/app/panels/ObjectTreePanel.hpp | 1 + src/controller/WorkbenchNavController.cpp | 39 ++++++++++++++--------- src/controller/WorkbenchNavController.hpp | 1 + src/data/api/ApiProjectRepository.cpp | 13 ++++++-- tests/data/test_nav_dto.cpp | 7 ++++ 6 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 0e8156a..8884de5 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -498,6 +498,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re .arg(name).arg(g.nx()).arg(g.ny()).arg(g.vmin).arg(g.vmax) .arg(anomalies.size())); }; + (void)loadDataset; // 暂未触发:保留待下一轮真实 DS 详情渲染复用 // ── 单击左下数据列表的采集批次(DS) → 占位(真实剖面/反演渲染下一阶段接 dd 接口)── QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, diff --git a/src/app/panels/ObjectTreePanel.hpp b/src/app/panels/ObjectTreePanel.hpp index 58d54a8..3621386 100644 --- a/src/app/panels/ObjectTreePanel.hpp +++ b/src/app/panels/ObjectTreePanel.hpp @@ -20,6 +20,7 @@ public: signals: void tmClicked(const QString& tmObjectId); + // 前瞻钩子:勾选驱动中央渲染留待下一轮接真实 DS(本轮暂无消费者)。 void tmCheckToggled(const QString& tmObjectId, bool checked); private: diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 4783d07..6e42046 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -8,11 +8,27 @@ using data::Workspace; WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent) : QObject(parent), repo_(repo) {} +namespace { +// RAII:进入公共导航操作时置忙(驱动等待光标),任何返回路径都复位——保证 busyChanged 配平。 +struct BusyGuard { + WorkbenchNavController* self; + bool* busy; + BusyGuard(WorkbenchNavController* s, bool* b) : self(s), busy(b) { + *busy = true; + emit self->busyChanged(true); + } + ~BusyGuard() { + *busy = false; + emit self->busyChanged(false); + } +}; +} // namespace + void WorkbenchNavController::start() { - emit busyChanged(true); + if (busy_) return; + BusyGuard guard(this, &busy_); const auto ws = repo_.listWorkspaces(); if (!ws.ok) { - emit busyChanged(false); emit loadFailed(QStringLiteral("workspaces"), QString::fromStdString(ws.error)); return; } @@ -22,9 +38,7 @@ void WorkbenchNavController::start() { 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() { @@ -61,22 +75,20 @@ void WorkbenchNavController::loadProjectsAndStructure() { } void WorkbenchNavController::switchWorkspace(const QString& tenantId) { - if (tenantId.isEmpty()) return; - emit busyChanged(true); + if (tenantId.isEmpty() || busy_) return; + BusyGuard guard(this, &busy_); 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); + if (projectId.isEmpty() || busy_) return; + BusyGuard guard(this, &busy_); currentProjectId_ = projectId.toStdString(); for (const auto& p : lastProjects_) if (p.id == currentProjectId_) { @@ -85,19 +97,16 @@ void WorkbenchNavController::switchProject(const QString& projectId) { } 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); + if (tmObjectId.isEmpty() || busy_) return; + BusyGuard guard(this, &busy_); const auto ds = repo_.loadDatasetsOfTm(tmObjectId.toStdString()); - emit busyChanged(false); if (!ds.ok) { emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(ds.error)); return; diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index c96d7d1..dc5c08e 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -35,6 +35,7 @@ private: void loadProjectsAndStructure(); // start + switchWorkspace 共用 data::IProjectRepository& repo_; + bool busy_ = false; std::vector lastProjects_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; }; diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index 39f383d..0c9fdd8 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "ApiClient.hpp" #include "dto/NavDto.hpp" @@ -19,6 +20,11 @@ std::string errorOf(const net::ApiResponse& r, const char* fallback) { if (!r.rawError.isEmpty()) return r.rawError.toStdString(); return fallback; } + +// 后端 id 进 URL 前做百分号编码(不可信外部数据:防 ? # & / 空格 破坏路径/查询)。 +QString enc(const std::string& s) { + return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); +} } // namespace ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {} @@ -32,7 +38,7 @@ RepoResult> ApiProjectRepository::listWorkspaces() { RepoResult ApiProjectRepository::switchWorkspace(const std::string& tenantId) { const QString path = - QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(QString::fromStdString(tenantId)); + QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId)); const net::ApiResponse r = api_.postJson(path, QJsonObject{}); if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")}; return {true, true, {}}; @@ -41,7 +47,8 @@ RepoResult ApiProjectRepository::switchWorkspace(const std::string& tenant RepoResult> ApiProjectRepository::listProjects( const std::string& lastProjectId) { const QString path = QStringLiteral("/business/project/queryByUser?lastProjectId=%1") - .arg(QString::fromStdString(lastProjectId)); + .arg(enc(lastProjectId)); + // 本轮仅取首页;hasNextPage 暂不跟进(分页"加载更多"留下一轮)。 const net::ApiResponse r = api_.get(path); if (!ok(r)) return {false, {}, errorOf(r, "listProjects failed")}; return {true, dto::parseProjects(r.data).projects, {}}; @@ -57,7 +64,7 @@ RepoResult> ApiProjectRepository::loadStructure(const st RepoResult> ApiProjectRepository::loadDatasetsOfTm(const std::string& tmObjectId) { const QString path = QStringLiteral("/business/projectWorkbench/queryDsByTmObjectId/%1") - .arg(QString::fromStdString(tmObjectId)); + .arg(enc(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()), {}}; diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index e7e4368..312a1fa 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -119,3 +119,10 @@ TEST(NavDto, BuildStructTreeHandlesCycleWithoutInfiniteRecursion) { ASSERT_EQ(roots.size(), 1u); EXPECT_EQ(roots[0].node.id, "R"); } + +TEST(NavDto, ParseProjectsEmptyAndMissingListGraceful) { + EXPECT_TRUE(dto::parseProjects(objOf(R"({})")).projects.empty()); + EXPECT_FALSE(dto::parseProjects(objOf(R"({"hasNextPage":false})")).hasNextPage); + const auto p = dto::parseProjects(objOf(R"({"projectList":[]})")); + EXPECT_TRUE(p.projects.empty()); +} From 1f1cf5cd3c2fc209a514f69a472e2820b2b7dd7c Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 12:17:11 +0800 Subject: [PATCH 17/25] =?UTF-8?q?docs(spec):=20=E5=AF=B9=E9=BD=90=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=EF=BC=88buildStructTree/StructTreeNode=20+=20structur?= =?UTF-8?q?eLoaded=20=E6=89=81=E5=B9=B3=E8=8A=82=E7=82=B9=20+=20=E9=98=B2?= =?UTF-8?q?=E9=87=8D=E5=85=A5/URL=E7=BC=96=E7=A0=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-09-real-api-navigation-design.md | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md index a676e02..aa063a2 100644 --- a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md +++ b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md @@ -140,17 +140,20 @@ public: **`api/ApiProjectRepository.{hpp,cpp}`** — 实现:持有 `net::ApiClient&`, 按 §3 路径发请求,把 `ApiResponse` 交给 `dto/` 映射;网络/业务码错误 → `RepoResult{ok=false, error=msg}`。 -判定成功:`httpStatus==200 && code==<成功码>`(成功码沿用登录约定,实现时核对)。 +判定成功:`code==200`(沿用登录 `AuthService` 的约定,业务码即成功标志)。id 进 URL 路径/查询前 +经 `QUrl::toPercentEncoding` 百分号编码(不可信后端数据:防 `? # & /` 空格 破坏 URL)。 **`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 懒加载)。 +- `buildStructTree(vector) -> vector`:扁平→**通用树**(不强塞 `Project/Gs/Tm` 刚性模型, + 以适配任意层级 + TM 直挂项目)。`StructTreeNode{StructNode node; bool isTm; vector children}`。 + - 以 `parentId` 归并;`parentId` 为空或不在集合内(孤儿)的节点为根层。 + - **叶子节点判定为 TM**(`isTm=true`,`node.id` 即 tmObjectId);非叶子为 GS。 + - `visited` 集防环:不可信后端数据(多节点环 / 重复 id)也不会无限递归(规约:永不信任外部数据)。 + - 纯函数、可单测;树→QTreeWidget 的填充由 `ObjectTreePanel` 调用本函数完成(见 §5.5)。 ### 5.4 逻辑层 `controller/WorkbenchNavController`(QObject) 唯一持有导航状态;不碰 widget;经信号把模型推给 UI、经槽接收用户意图。 @@ -168,17 +171,23 @@ public slots: signals: void workspacesLoaded(const std::vector&, QString currentId); void projectsLoaded(const std::vector&, QString currentId); - void structureLoaded(const data::Project&); // 已建好的树 + // 发出项目名 + 扁平结构节点;建树(buildStructTree)在 ObjectTreePanel 内完成。 + void structureLoaded(const QString& projectName, const std::vector&); void datasetsLoaded(const QString& tmObjectId, const std::vector&); void loadFailed(const QString& stage, const QString& message); // 出错→UI 空/错状态 void busyChanged(bool busy); // 同步阻塞期间置 WaitCursor private: + void loadProjectsAndStructure(); // start + switchWorkspace 共用 data::IProjectRepository& repo_; - QString currentWorkspaceId_, currentProjectId_, currentCrsCode_; + std::vector lastProjects_; // 供 switchProject 查 name/crsCode + std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; + bool busy_ = false; // 重入保护:同步请求期间拒绝再次进入 }; ``` -编排逻辑:`start()` → `listWorkspaces`(选 isCurrent/首个)→ `listProjects`(选首个)→ `loadStructure`→建树。 +编排逻辑:`start()` → `listWorkspaces`(选 isCurrent/首个)→ `listProjects`(选首个)→ `loadStructure`→发扁平节点。 切空间/项目按 §6 时序。每个阶段失败 emit `loadFailed(stage,msg)` 并停在该阶段。 +**重入保护**:每个公共操作入口 `if (busy_) return;`,并用 RAII guard 在置忙/复位时配平 `busyChanged` +(同步 HTTP 会泵 Qt 事件循环,快速二次点击可能重入并污染状态)。 ### 5.5 UI 层 `app`(被动视图,数据驱动) @@ -188,9 +197,10 @@ private: - 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。 - `buildMenuBar` 不变(静态菜单本轮不接)。 -**`app/panels/ObjectTreePanel`**(新增;或先以构建函数落在 main,二选一见 §11)—— 被动: -`setProject(const data::Project&)` 重建 `QTreeWidget`(项目根→GS→TM,TM 可勾选、存 tmObjectId); -信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)`。空/错状态:树区显示占位 label。 +**`app/panels/ObjectTreePanel`**(新增)—— 被动:`setStructure(projectName, vector)` 内部调 +`dto::buildStructTree` 重建 `QTreeWidget`(项目根→GS→TM,叶子=TM 可勾选、`UserRole` 存 tmObjectId); +`showMessage(msg)` 显示空/错占位。信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)` +(后者为前瞻钩子,本轮无消费者)。 **`app/panels/DatasetListPanel`**(已有)—— `datasetsLoaded` → `populateDatasetList`;空时显示"暂无数据集"。 @@ -205,7 +215,7 @@ private: controller.start(): listWorkspaces → emit workspacesLoaded → TopBar.setWorkspaces listProjects(empty) → emit projectsLoaded → TopBar.setProjects - loadStructure(currentProject) → buildProjectTree → emit structureLoaded → ObjectTreePanel.setProject + loadStructure(currentProject) → emit structureLoaded(name,nodes) → ObjectTreePanel.setStructure(→buildStructTree) 切空间: TopBar.workspaceSwitchRequested(id) → controller.switchWorkspace: switchWorkspace(id) → listProjects → 选首个 → loadStructure @@ -225,7 +235,8 @@ private: - controller 任一阶段失败 → `loadFailed(stage, msg)`;UI 在对应面板显示空/错状态 label + 状态栏提示,**不回退本地样本**。 - 空数据(无空间 / 无项目 / 无结构 / 无 DS)→ 各面板显示"暂无…"占位(识别优于回忆)。 - token 过期(业务码 401 类)→ `loadFailed` 文案提示重新登录(本轮先提示,自动跳登录留后续)。 -- 输入边界:`tmObjectId` / `projectId` 为空时短路不发请求。 +- 输入边界:`tmObjectId` / `projectId` 为空时短路不发请求;URL 中的 id 一律百分号编码(见 §5.3)。 +- 重入:同步请求期间 `busy_` 拒绝再次进入(避免快速点击重入污染状态,见 §5.4)。 ## 8. 渲染解耦 @@ -274,7 +285,7 @@ void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, 依既有无测试桩 + 依赖 live 服务器的现实,聚焦**纯逻辑单测**(GoogleTest + CTest): - `dto/NavDto` 映射:喂样本 JSON(取自 OpenAPI example / 手造)验证 `parseWorkspaces / parseProjects / parseStructNodes / parseDatasets` 字段与 `ddCode→ddType`、`isCurTenant→isCurrent`。 -- `buildProjectTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS)、孤儿 parentId、空列表 等场景。 +- `buildStructTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS)、孤儿 parentId、空列表、防环 等场景。 - 不做 live 集成 / E2E(无桩、依赖真实后端)。控制器/UI 信号联动靠手动联调验证。 - 目标:纯逻辑文件(dto + tree builder)覆盖率优先达标;UI/网络 IO 不计入。 From 60d46cf1db4b01c743192f4168219ecd71f9eb14 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 13:58:59 +0800 Subject: [PATCH 18/25] =?UTF-8?q?fix(nav):=20=E5=AE=9E=E6=B5=8B=E6=95=B4?= =?UTF-8?q?=E6=94=B9=E2=80=94=E2=80=94=E9=A1=B9=E7=9B=AE=E7=94=A8my/profil?= =?UTF-8?q?e/queryProject=E3=80=81=E5=88=87=E6=8D=A2=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E9=87=8D=E6=B3=A8=E5=85=A5token=E3=80=81=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E6=8C=89type=E5=BB=BA=E6=A0=91(=E8=BF=87=E6=BB=A4DS)=E3=80=81?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E4=BA=92=E6=96=A5=E3=80=81=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E9=A1=B9=E7=9B=AE=E6=A0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/TopBar.cpp | 18 +++++++-- src/app/panels/ObjectTreePanel.cpp | 4 +- src/data/api/ApiProjectRepository.cpp | 23 ++++++----- src/data/dto/NavDto.cpp | 58 +++++++++++++++------------ src/data/dto/NavDto.hpp | 3 ++ tests/data/test_nav_dto.cpp | 29 ++++++++++++++ 6 files changed, 91 insertions(+), 44 deletions(-) diff --git a/src/app/TopBar.cpp b/src/app/TopBar.cpp index 97e35a3..c1df2af 100644 --- a/src/app/TopBar.cpp +++ b/src/app/TopBar.cpp @@ -211,6 +211,8 @@ void TopBar::setWorkspaces(const std::vector& list, const QStri auto* header = menu->addAction(QStringLiteral("切换空间")); header->setEnabled(false); menu->addSeparator(); + auto* group = new QActionGroup(menu); + group->setExclusive(true); // 互斥:只一个勾选,避免“多选” QString currentName; for (const auto& w : list) { const QString id = QString::fromStdString(w.id); @@ -218,9 +220,12 @@ void TopBar::setWorkspaces(const std::vector& list, const QStri auto* a = menu->addAction(name); a->setCheckable(true); a->setChecked(id == currentId); + group->addAction(a); if (id == currentId) currentName = name; - QObject::connect(a, &QAction::triggered, this, - [this, id]() { emit workspaceSwitchRequested(id); }); + QObject::connect(a, &QAction::triggered, this, [this, id, name]() { + wsBtn_->setText(name + QStringLiteral(" ▾")); // 立即反馈 + emit workspaceSwitchRequested(id); + }); } if (list.empty()) { auto* none = menu->addAction(QStringLiteral("(暂无空间)")); @@ -236,6 +241,8 @@ void TopBar::setProjects(const std::vector& list, const QS auto* header = menu->addAction(QStringLiteral("切换项目")); header->setEnabled(false); menu->addSeparator(); + auto* group = new QActionGroup(menu); + group->setExclusive(true); QString currentName; for (const auto& p : list) { const QString id = QString::fromStdString(p.id); @@ -243,9 +250,12 @@ void TopBar::setProjects(const std::vector& list, const QS auto* a = menu->addAction(name); a->setCheckable(true); a->setChecked(id == currentId); + group->addAction(a); if (id == currentId) currentName = name; - QObject::connect(a, &QAction::triggered, this, - [this, id]() { emit projectSwitchRequested(id); }); + QObject::connect(a, &QAction::triggered, this, [this, id, name]() { + projBtn_->setText(name + QStringLiteral(" ▾")); + emit projectSwitchRequested(id); + }); } if (list.empty()) { auto* none = menu->addAction(QStringLiteral("(暂无项目)")); diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp index 39aabe6..4d14f23 100644 --- a/src/app/panels/ObjectTreePanel.cpp +++ b/src/app/panels/ObjectTreePanel.cpp @@ -79,9 +79,7 @@ void ObjectTreePanel::setStructure(const QString& projectName, } hint_->setVisible(false); tree_->setVisible(true); - auto* rootItem = new QTreeWidgetItem(tree_); - rootItem->setText(0, projectName.isEmpty() ? QStringLiteral("项目") : projectName); - addNodes(rootItem, roots); + addNodes(tree_->invisibleRootItem(), roots); // 结构已含项目根节点,直接渲染 tree_->expandAll(); } diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index 0c9fdd8..1629256 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -41,25 +41,26 @@ RepoResult ApiProjectRepository::switchWorkspace(const std::string& tenant QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId)); const net::ApiResponse r = api_.postJson(path, QJsonObject{}); if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")}; + // 切换空间返回新 accessToken:必须重新注入,后续请求才落到新空间。 + const QString token = r.data.value(QStringLiteral("accessToken")).toString(); + if (!token.isEmpty()) api_.setToken(token); return {true, true, {}}; } -RepoResult> ApiProjectRepository::listProjects( - const std::string& lastProjectId) { - const QString path = QStringLiteral("/business/project/queryByUser?lastProjectId=%1") - .arg(enc(lastProjectId)); - // 本轮仅取首页;hasNextPage 暂不跟进(分页"加载更多"留下一轮)。 - const net::ApiResponse r = api_.get(path); +RepoResult> ApiProjectRepository::listProjects(const std::string&) { + // 我的工作台项目列表(当前空间全部项目)。queryByUser 实测为空,故用此接口。 + const net::ApiResponse r = api_.get(QStringLiteral("/business/my/profile/queryProject")); if (!ok(r)) return {false, {}, errorOf(r, "listProjects failed")}; - return {true, dto::parseProjects(r.data).projects, {}}; + return {true, dto::parseProjectList(r.data.value(QStringLiteral("value")).toArray()), {}}; } RepoResult> 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); + // 项目结构(项目根 + GS + TM;不含 DS)。比 projectWorkbench 干净。 + const QString path = + QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId)); + const net::ApiResponse r = api_.get(path); if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")}; - return {true, dto::parseStructNodes(r.data.value(QStringLiteral("projectStructList")).toArray()), {}}; + return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}}; } RepoResult> ApiProjectRepository::loadDatasetsOfTm(const std::string& tmObjectId) { diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index e4a45f2..098d4ae 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -11,6 +11,17 @@ namespace { std::string str(const QJsonObject& o, const char* key) { return o.value(QString::fromLatin1(key)).toString().toStdString(); } + +ProjectSummary parseProjectItem(const QJsonObject& o) { + 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(); + return p; +} } // namespace std::vector parseWorkspaces(const QJsonArray& arr) { @@ -33,20 +44,17 @@ ProjectPage parseProjects(const QJsonObject& data) { page.hasNextPage = data.value(QStringLiteral("hasNextPage")).toBool(); const QJsonArray list = data.value(QStringLiteral("projectList")).toArray(); page.projects.reserve(static_cast(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)); - } + for (const QJsonValue& v : list) page.projects.push_back(parseProjectItem(v.toObject())); return page; } +std::vector parseProjectList(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(arr.size())); + for (const QJsonValue& v : arr) out.push_back(parseProjectItem(v.toObject())); + return out; +} + std::vector parseStructNodes(const QJsonArray& arr) { std::vector out; out.reserve(static_cast(arr.size())); @@ -79,32 +87,30 @@ std::vector parseDatasets(const QJsonArray& arr) { } std::vector buildStructTree(const std::vector& flat) { + // 过滤 DS(type==3):DS 不进对象树(按 TM 单独拉取到数据列表)。 + std::vector nodes; + nodes.reserve(flat.size()); + for (const auto& n : flat) + if (n.type != 3) nodes.push_back(n); + std::set ids; - std::set 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 集合内(孤儿)。 + for (const auto& n : nodes) ids.insert(n.id); + // 根层:parentId 为空 / "0" / 不在集合内(孤儿)。 auto isRootLevel = [&](const StructNode& n) { - return n.parentId.empty() || ids.find(n.parentId) == ids.end(); + return n.parentId.empty() || n.parentId == "0" || ids.find(n.parentId) == ids.end(); }; - // visited 防环:每个 id 最多进树一次。对正常树(单父)等价于原逻辑; - // 对不可信后端数据的多节点环 / 重复 id 环,避免无限递归(规约:永不信任外部数据)。 - std::set visited; + std::set visited; // 防环:每个 id 最多进树一次。 std::function(const std::string&, bool)> build = [&](const std::string& parentId, bool root) { std::vector out; - for (const auto& n : flat) { + for (const auto& n : nodes) { const bool belongs = root ? isRootLevel(n) : (n.parentId == parentId); if (!belongs) continue; - if (visited.count(n.id)) continue; // 已进树 → 跳过,防环/防重复 + if (visited.count(n.id)) continue; visited.insert(n.id); StructTreeNode t; t.node = n; - t.isTm = isLeaf(n.id); + t.isTm = (n.type == 2); // type: 1=项目根 2=TM(测线) 3=DS(已过滤) t.children = build(n.id, false); out.push_back(std::move(t)); } diff --git a/src/data/dto/NavDto.hpp b/src/data/dto/NavDto.hpp index 20141c8..79955b9 100644 --- a/src/data/dto/NavDto.hpp +++ b/src/data/dto/NavDto.hpp @@ -13,6 +13,9 @@ std::vector parseWorkspaces(const QJsonArray& arr); struct ProjectPage { std::vector projects; bool hasNextPage = false; }; ProjectPage parseProjects(const QJsonObject& data); +// my/profile/queryProject 的 data["value"] 数组 → 模型(与 parseProjects 同字段映射)。 +std::vector parseProjectList(const QJsonArray& arr); + // 结构扁平节点数组(queryProjectStruct 的 data["projectStructList"])→ 模型。 std::vector parseStructNodes(const QJsonArray& arr); diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 312a1fa..0c515ef 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -126,3 +126,32 @@ TEST(NavDto, ParseProjectsEmptyAndMissingListGraceful) { const auto p = dto::parseProjects(objOf(R"({"projectList":[]})")); EXPECT_TRUE(p.projects.empty()); } + +TEST(NavDto, ParseProjectListArrayMapsItem) { + const auto arr = arrOf(R"([ + {"id":"p1","projectName":"演示","projectTypeName":"ERT","referenceCRSCode":"EPSG:4547","status":1} + ])"); + const auto v = dto::parseProjectList(arr); + ASSERT_EQ(v.size(), 1u); + EXPECT_EQ(v[0].id, "p1"); + EXPECT_EQ(v[0].name, "演示"); + EXPECT_EQ(v[0].crsCode, "EPSG:4547"); +} + +TEST(NavDto, BuildStructTreeDropsDsAndTmStaysLeaf) { + // 真实形态:项目(1) → TM(2) → DS(3)。DS 不进树;带 DS 子节点的 TM 仍是 TM 叶子。 + const std::vector flat = { + {"P", "项目", "0", "PRJ", "", 1}, + {"T1", "ERT1", "P", "ERT", "ERT", 2}, + {"D1", "批次1","T1", "", "", 3}, // DS:应被过滤 + {"T2", "ERT2", "P", "ERT", "ERT", 2}, + }; + const auto roots = dto::buildStructTree(flat); + ASSERT_EQ(roots.size(), 1u); // 仅项目根(parentId "0") + EXPECT_EQ(roots[0].node.id, "P"); + EXPECT_FALSE(roots[0].isTm); // 项目根 type1 + ASSERT_EQ(roots[0].children.size(), 2u); // T1、T2(D1 被过滤) + EXPECT_EQ(roots[0].children[0].node.id, "T1"); + EXPECT_TRUE(roots[0].children[0].isTm); // TM type2 + EXPECT_TRUE(roots[0].children[0].children.empty()); // DS 不进树 +} From 5b18dc44aeae2ed5c722fc706e66f023b6cf9120 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 14:31:25 +0800 Subject: [PATCH 19/25] =?UTF-8?q?style(app):=20=E6=95=B0=E6=8D=AE=E9=9B=86?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8E=BB=E9=9A=94=E8=A1=8C=E5=8F=98=E8=89=B2?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E4=B8=BA=E7=BB=86=E5=88=86=E5=89=B2=E7=BA=BF?= =?UTF-8?q?+hover/=E9=80=89=E4=B8=AD=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 8884de5..82b68e8 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -337,7 +337,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 左下 dock:数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。 auto* datasetTabs = new QTabWidget(); auto* datasetList = new QListWidget(); - datasetList->setAlternatingRowColors(true); + // 简洁分割:去隔行变色,改为 item 间极淡分割线 + 内边距 + hover/选中反馈(专业、不误导)。 + datasetList->setStyleSheet(QStringLiteral( + "QListWidget{ background:#FFFFFF; border:none; outline:none; }" + "QListWidget::item{ padding:9px 12px; border-bottom:1px solid #EEF1F5; color:#1F2A3D; }" + "QListWidget::item:hover{ background:#F5F8FD; }" + "QListWidget::item:selected{ background:#EAF1FB; color:#1F2A3D; }")); datasetTabs->addTab(datasetList, QStringLiteral("数据")); auto* fileList = new QListWidget(); // M1 文件 tab 占位 { // 空状态引导:M1 暂无文件来源,给出说明而非空白面板(识别优先于回忆)。 From 839e5c3487bba168186f7ba1ae671a4f1d0a9dda Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 14:54:32 +0800 Subject: [PATCH 20/25] =?UTF-8?q?feat(nav):=20=E6=95=B0=E6=8D=AE/=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=A1=B5=E7=AD=BE=E6=8E=A5=20data-page/file-page?= =?UTF-8?q?=EF=BC=88=E6=8C=89TM+classifyType=E6=8B=89=E5=8F=96=EF=BC=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=A1=B5=E7=AD=BE=E5=B1=95=E7=A4=BA=E5=90=8D?= =?UTF-8?q?/=E5=A4=A7=E5=B0=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.cpp | 27 ++++++------ src/app/panels/DatasetListPanel.cpp | 50 +++++++++++++++-------- src/app/panels/DatasetListPanel.hpp | 13 +++--- src/controller/WorkbenchNavController.cpp | 16 ++++++-- src/controller/WorkbenchNavController.hpp | 3 +- src/data/api/ApiProjectRepository.cpp | 21 +++++++--- src/data/api/ApiProjectRepository.hpp | 4 +- src/data/dto/NavDto.cpp | 15 ++++--- src/data/dto/NavDto.hpp | 4 +- src/data/repo/IProjectRepository.hpp | 5 ++- src/data/repo/RepoTypes.hpp | 7 ++++ tests/data/test_nav_dto.cpp | 31 +++++++++----- 12 files changed, 129 insertions(+), 67 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 82b68e8..2842a80 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -344,13 +344,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re "QListWidget::item:hover{ background:#F5F8FD; }" "QListWidget::item:selected{ background:#EAF1FB; color:#1F2A3D; }")); datasetTabs->addTab(datasetList, QStringLiteral("数据")); - auto* fileList = new QListWidget(); // M1 文件 tab 占位 - { // 空状态引导:M1 暂无文件来源,给出说明而非空白面板(识别优先于回忆)。 - auto* hint = new QListWidgetItem(QStringLiteral("(M1 暂无关联文件)"), fileList); - hint->setFlags(Qt::NoItemFlags); - hint->setForeground(QColor("#9AA6B6")); - hint->setTextAlignment(Qt::AlignCenter); - } + auto* fileList = new QListWidget(); + fileList->setStyleSheet(datasetList->styleSheet()); // 与数据页签同款简洁分割 datasetTabs->addTab(fileList, QStringLiteral("文件")); auto* datasetDock = new ads::CDockWidget(QStringLiteral("数据真实显示栏")); auto* datasetBox = wrapWithHeader( @@ -637,23 +632,31 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re [topBar](const std::vector& list, const QString& cur) { topBar->setProjects(list, cur); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree, - [objectTree, datasetList, datasetTitle, datasetTabs]( + [objectTree, datasetList, fileList, datasetTitle, datasetTabs]( const QString& projectName, const std::vector& nodes) { objectTree->setStructure(projectName, nodes); - datasetList->clear(); // 切项目清空 DS 列表 + datasetList->clear(); + fileList->clear(); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏")); datasetTabs->setTabText(0, QStringLiteral("数据")); + datasetTabs->setTabText(1, QStringLiteral("文件")); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList, [datasetList, datasetTitle, datasetTabs]( - const QString&, const std::vector& list) { + const QString&, const std::vector& list) { geopro::app::populateDatasetList(datasetList, list); - if (datasetTitle) - datasetTitle->setText(QStringLiteral("数据集显示栏")); + if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏")); datasetTabs->setTabText( 0, QStringLiteral("数据 (%1)").arg(static_cast(list.size()))); }); + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::filesLoaded, fileList, + [fileList, datasetTabs](const QString&, + const std::vector& list) { + geopro::app::populateFileList(fileList, list); + datasetTabs->setTabText( + 1, QStringLiteral("文件 (%1)").arg(static_cast(list.size()))); + }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::loadFailed, objectTree, [objectTree, &window](const QString& stage, const QString& msg) { if (stage == QStringLiteral("structure") || diff --git a/src/app/panels/DatasetListPanel.cpp b/src/app/panels/DatasetListPanel.cpp index 386137a..f1c6f4c 100644 --- a/src/app/panels/DatasetListPanel.cpp +++ b/src/app/panels/DatasetListPanel.cpp @@ -1,5 +1,6 @@ #include "panels/DatasetListPanel.hpp" +#include #include #include #include @@ -7,30 +8,43 @@ namespace geopro::app { namespace { - -// dd 类型 → 中文标注。 -QString ddTypeLabel(const std::string& ddType) -{ - if (ddType == "dd_section") return QStringLiteral("剖面网格"); - if (ddType == "dd_voxel") return QStringLiteral("体素"); - return QString::fromStdString(ddType); +QString humanSize(long long b) { + if (b < 1024) return QStringLiteral("%1 B").arg(b); + const double kb = b / 1024.0; + if (kb < 1024.0) return QStringLiteral("%1 KB").arg(kb, 0, 'f', 1); + return QStringLiteral("%1 MB").arg(kb / 1024.0, 0, 'f', 1); } - } // namespace -void populateDatasetList(QListWidget* list, const std::vector& dss) -{ +void populateDatasetList(QListWidget* list, const std::vector& rows) { if (!list) return; list->clear(); - for (const auto& ds : dss) { - const QString name = QString::fromStdString(ds.name); - const QString label = ddTypeLabel(ds.ddType); - QString text = name; - if (!label.isEmpty()) text += QStringLiteral("\n%1").arg(label); - + for (const auto& d : rows) { + QString text = QString::fromStdString(d.dsName); + if (!d.typeName.empty()) text += QStringLiteral("\n%1").arg(QString::fromStdString(d.typeName)); auto* item = new QListWidgetItem(text, list); - item->setData(kDsIdRole, QString::fromStdString(ds.id)); - item->setData(kDsDdTypeRole, QString::fromStdString(ds.ddType)); + item->setData(kDsIdRole, QString::fromStdString(d.id)); + item->setData(kDsDdTypeRole, QString::fromStdString(d.ddCode)); + } +} + +void populateFileList(QListWidget* list, const std::vector& rows) { + if (!list) return; + list->clear(); + if (rows.empty()) { + auto* hint = new QListWidgetItem(QStringLiteral("(暂无文件)"), list); + hint->setFlags(Qt::NoItemFlags); + hint->setForeground(QColor("#9AA6B6")); + hint->setTextAlignment(Qt::AlignCenter); + return; + } + for (const auto& d : rows) { + const QString fname = + d.fileName.empty() ? QString::fromStdString(d.dsName) : QString::fromStdString(d.fileName); + const QString text = fname + QStringLiteral("\n%1").arg(humanSize(d.fileSize)); + auto* item = new QListWidgetItem(text, list); + item->setData(kDsIdRole, QString::fromStdString(d.id)); + item->setData(kDsFileUrlRole, QString::fromStdString(d.fileUrl)); } } diff --git a/src/app/panels/DatasetListPanel.hpp b/src/app/panels/DatasetListPanel.hpp index f2a61e6..da04fb5 100644 --- a/src/app/panels/DatasetListPanel.hpp +++ b/src/app/panels/DatasetListPanel.hpp @@ -8,12 +8,13 @@ class QListWidget; namespace geopro::app { // 数据列表条目角色(与 main.cpp 树一致:Qt::UserRole=dsId、+1=ddType)。 -constexpr int kDsIdRole = 0x0100; // Qt::UserRole -constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1 +constexpr int kDsIdRole = 0x0100; // Qt::UserRole +constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1 +constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2(文件下载 url,备用) -// 用某测线(TM)的数据集(采集批次)填充 QListWidget(对齐原型左下「数据真实显示栏」)。 -// 每条目 = 名称 +(ddType 标注);UserRole 存 dsId、+1 存 ddType(供单击驱动数据详情)。 -// 清空旧条目后重填。 -void populateDatasetList(QListWidget* list, const std::vector& dss); +// 数据页签:每条 = dsName +(类型名);UserRole 存 dsId、+1 存 ddCode。 +void populateDatasetList(QListWidget* list, const std::vector& rows); +// 文件页签:每条 = 文件名 +(可读大小);UserRole 存 dsId、+2 存文件 url。空时显示占位。 +void populateFileList(QListWidget* list, const std::vector& rows); } // namespace geopro::app diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 6e42046..03b060c 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -106,12 +106,20 @@ void WorkbenchNavController::switchProject(const QString& projectId) { void WorkbenchNavController::selectTm(const QString& tmObjectId) { if (tmObjectId.isEmpty() || busy_) return; BusyGuard guard(this, &busy_); - const auto ds = repo_.loadDatasetsOfTm(tmObjectId.toStdString()); - if (!ds.ok) { - emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(ds.error)); + const std::string pid = currentProjectId_; + const std::string tm = tmObjectId.toStdString(); + const auto data = repo_.loadTmRows(pid, tm, 3); // 数据 + if (!data.ok) { + emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(data.error)); return; } - emit datasetsLoaded(tmObjectId, ds.value); + emit datasetsLoaded(tmObjectId, data.value); + const auto files = repo_.loadTmRows(pid, tm, 1); // 文件 + if (!files.ok) { + emit loadFailed(QStringLiteral("files"), QString::fromStdString(files.error)); + return; + } + emit filesLoaded(tmObjectId, files.value); } } // namespace geopro::controller diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index dc5c08e..e676685 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -28,7 +28,8 @@ signals: void workspacesLoaded(const std::vector& list, const QString& currentId); void projectsLoaded(const std::vector& list, const QString& currentId); void structureLoaded(const QString& projectName, const std::vector& nodes); - void datasetsLoaded(const QString& tmObjectId, const std::vector& list); + void datasetsLoaded(const QString& tmObjectId, const std::vector& list); + void filesLoaded(const QString& tmObjectId, const std::vector& list); void loadFailed(const QString& stage, const QString& message); private: diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index 1629256..e6c406c 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -63,12 +63,21 @@ RepoResult> ApiProjectRepository::loadStructure(const st return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}}; } -RepoResult> ApiProjectRepository::loadDatasetsOfTm(const std::string& tmObjectId) { - const QString path = QStringLiteral("/business/projectWorkbench/queryDsByTmObjectId/%1") - .arg(enc(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()), {}}; +RepoResult> ApiProjectRepository::loadTmRows(const std::string& projectId, + const std::string& tmObjectId, + int classifyType) { + const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") + : QStringLiteral("/business/dsObject/data/page"); + const QJsonObject body{ + {QStringLiteral("projectId"), QString::fromStdString(projectId)}, + {QStringLiteral("structParentId"), QString::fromStdString(tmObjectId)}, + {QStringLiteral("structParentConfType"), 2}, + {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, + {QStringLiteral("pageNo"), 1}, + {QStringLiteral("pageSize"), 100}}; + const net::ApiResponse r = api_.postJson(path, body); + if (!ok(r)) return {false, {}, errorOf(r, "loadTmRows failed")}; + return {true, dto::parseDsRows(r.data.value(QStringLiteral("list")).toArray()), {}}; } } // namespace geopro::data diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index b52ec51..354e350 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -14,7 +14,9 @@ public: RepoResult switchWorkspace(const std::string& tenantId) override; RepoResult> listProjects(const std::string& lastProjectId) override; RepoResult> loadStructure(const std::string& projectId) override; - RepoResult> loadDatasetsOfTm(const std::string& tmObjectId) override; + RepoResult> loadTmRows(const std::string& projectId, + const std::string& tmObjectId, + int classifyType) override; private: net::ApiClient& api_; diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 098d4ae..48e5bb8 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -72,15 +72,20 @@ std::vector parseStructNodes(const QJsonArray& arr) { return out; } -std::vector parseDatasets(const QJsonArray& arr) { - std::vector out; +std::vector parseDsRows(const QJsonArray& arr) { + std::vector out; out.reserve(static_cast(arr.size())); for (const QJsonValue& v : arr) { const QJsonObject o = v.toObject(); - DsNode d; + DsRow d; d.id = str(o, "id"); - d.name = str(o, "name"); - d.ddType = str(o, "ddCode"); + d.dsName = str(o, "dsName"); + d.typeName = str(o, "name"); // 注意:name 字段=ds类型名 + d.ddCode = str(o, "ddCode"); + const QJsonObject f = o.value(QStringLiteral("file")).toObject(); + d.fileName = str(f, "name"); + d.fileUrl = str(f, "url"); + d.fileSize = static_cast(f.value(QStringLiteral("size")).toDouble()); out.push_back(std::move(d)); } return out; diff --git a/src/data/dto/NavDto.hpp b/src/data/dto/NavDto.hpp index 79955b9..3d36326 100644 --- a/src/data/dto/NavDto.hpp +++ b/src/data/dto/NavDto.hpp @@ -19,8 +19,8 @@ std::vector parseProjectList(const QJsonArray& arr); // 结构扁平节点数组(queryProjectStruct 的 data["projectStructList"])→ 模型。 std::vector parseStructNodes(const QJsonArray& arr); -// DS 聚合数组(queryDsByTmObjectId 的 data["value"])→ DsNode。ddCode → ddType。 -std::vector parseDatasets(const QJsonArray& arr); +// data/page / file/page 的 data["list"] 数组 → DsRow(数据行无 file;文件行含 file{name,size,url})。 +std::vector parseDsRows(const QJsonArray& arr); // 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理:项目直挂 TM、孤儿 parentId、空表。 struct StructTreeNode { diff --git a/src/data/repo/IProjectRepository.hpp b/src/data/repo/IProjectRepository.hpp index e064370..1f46922 100644 --- a/src/data/repo/IProjectRepository.hpp +++ b/src/data/repo/IProjectRepository.hpp @@ -21,7 +21,10 @@ public: 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; + // 按 TM 拉数据集/文件行:classifyType 3=数据(data/page) 1=文件(file/page)。 + virtual RepoResult> loadTmRows(const std::string& projectId, + const std::string& tmObjectId, + int classifyType) = 0; }; } // namespace geopro::data diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index f78aeff..4307e93 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -3,6 +3,13 @@ #include namespace geopro::data { struct DsNode { std::string id, name, ddType; }; + +// data/page 或 file/page 的一条 ds。数据行只用 dsName/typeName/ddCode;文件行另含 file*。 +struct DsRow { + std::string id, dsName, typeName, ddCode; + std::string fileName, fileUrl; + long long fileSize = 0; +}; struct TmNode { std::string id, name, confCode; std::vector dss; }; struct GsNode { std::string id, name; std::vector tms; }; struct Project { std::string id, name; std::vector gss; }; diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 0c515ef..918b384 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -64,17 +64,6 @@ TEST(NavDto, ParseStructNodesMapsParentAndType) { 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"); -} - TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) { const std::vector flat = { {"gs1", "工区1", "", "GS", "", 1}, @@ -155,3 +144,23 @@ TEST(NavDto, BuildStructTreeDropsDsAndTmStaysLeaf) { EXPECT_TRUE(roots[0].children[0].isTm); // TM type2 EXPECT_TRUE(roots[0].children[0].children.empty()); // DS 不进树 } + +TEST(NavDto, ParseDsRowsDataAndFile) { + const auto d = dto::parseDsRows(arrOf(R"([ + {"id":"d1","dsName":"ERT1-WS","name":"电阻率数据","ddCode":"dd_inversion_data"} + ])")); + ASSERT_EQ(d.size(), 1u); + EXPECT_EQ(d[0].id, "d1"); + EXPECT_EQ(d[0].dsName, "ERT1-WS"); + EXPECT_EQ(d[0].typeName, "电阻率数据"); + EXPECT_TRUE(d[0].fileName.empty()); + + const auto f = dto::parseDsRows(arrOf(R"([ + {"id":"f1","dsName":"ERT1-WS.xlsx","name":"","ddCode":"dd_file", + "file":{"name":"ERT1-WS.xlsx","size":62760,"url":"/common/file/x.xlsx"}} + ])")); + ASSERT_EQ(f.size(), 1u); + EXPECT_EQ(f[0].fileName, "ERT1-WS.xlsx"); + EXPECT_EQ(f[0].fileSize, 62760); + EXPECT_EQ(f[0].fileUrl, "/common/file/x.xlsx"); +} From 7cdc7b8077bcfdcdd0d52851b86b43ea4835b24e Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 14:58:55 +0800 Subject: [PATCH 21/25] =?UTF-8?q?docs(spec):=20=E5=AF=B9=E9=BD=90=E6=95=B0?= =?UTF-8?q?=E6=8D=AE/=E6=96=87=E4=BB=B6=E9=A1=B5=E7=AD=BE=E6=8E=A5=20data-?= =?UTF-8?q?page/file-page=20+=20DsRow/loadTmRows/filesLoaded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-09-real-api-navigation-design.md | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md index aa063a2..0af9ee9 100644 --- a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md +++ b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md @@ -47,11 +47,12 @@ token 已由登录注入(`geomativeauthorization` 头),下列接口直接 | 切换工作空间 | 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 下数据(页签) | POST | `/business/dsObject/data/page` | body `{projectId, structParentId:, 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 单独拉取(`queryDsByTmObjectId`)。 +- DS 不在结构列表里:按 TM 拉数据/文件两类,分别用 `dsObject/data/page`(classify=3)、`dsObject/file/page`(classify=1),传 `structParentId=`、`structParentConfType=2`。(实测:`queryByUser` 返回空,项目列表改用 `my/profile/queryProject`;`queryDsByTmObjectId` 字段不全已弃用。) - **项目不能直接挂 DS**;DS 永远挂在 TM 下。但由于是 `parentId` 扁平结构,**TM 可直接挂在项目下(无中间 GS)**——这是"项目直接挂"印象的来源,但叶子仍是 TM→DS。 **节点判定**:结构列表只含 GS+TM,故 **TM = 该节点在结构列表中无子节点(叶子)**;非叶子 = GS。 @@ -116,7 +117,7 @@ struct StructNode { int type = 0; }; ``` -`DsNode{id,name,ddType}` 复用;映射时 `ddCode → ddType`。 +新增 `DsRow{id, dsName, typeName, ddCode, fileName, fileUrl, fileSize}`(数据/文件页签行通用;文件行含 file*)。`DsNode` 仅本地样本仓储继续用。 ### 5.3 数据访问层 `data` @@ -134,7 +135,9 @@ public: 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; + virtual RepoResult> loadTmRows(const std::string& projectId, + const std::string& tmObjectId, + int classifyType) = 0; // 3=数据 1=文件 }; ``` @@ -147,7 +150,7 @@ public: - `parseWorkspaces(QJsonArray) -> vector`(`isCurTenant==1 → isCurrent`)。 - `parseProjects(QJsonObject) -> {vector, bool hasNextPage}`。 - `parseStructNodes(QJsonArray) -> vector`。 -- `parseDatasets(QJsonArray) -> vector`(`ddCode→ddType`)。 +- `parseDsRows(QJsonArray) -> vector`(data/file page 的 `data.list`;`name→typeName`,`file{name,size,url}`)。 - `buildStructTree(vector) -> vector`:扁平→**通用树**(不强塞 `Project/Gs/Tm` 刚性模型, 以适配任意层级 + TM 直挂项目)。`StructTreeNode{StructNode node; bool isTm; vector children}`。 - 以 `parentId` 归并;`parentId` 为空或不在集合内(孤儿)的节点为根层。 @@ -173,7 +176,8 @@ signals: void projectsLoaded(const std::vector&, QString currentId); // 发出项目名 + 扁平结构节点;建树(buildStructTree)在 ObjectTreePanel 内完成。 void structureLoaded(const QString& projectName, const std::vector&); - void datasetsLoaded(const QString& tmObjectId, const std::vector&); + void datasetsLoaded(const QString& tmObjectId, const std::vector&); // 数据页签 + void filesLoaded(const QString& tmObjectId, const std::vector&); // 文件页签 void loadFailed(const QString& stage, const QString& message); // 出错→UI 空/错状态 void busyChanged(bool busy); // 同步阻塞期间置 WaitCursor private: @@ -202,7 +206,7 @@ private: `showMessage(msg)` 显示空/错占位。信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)` (后者为前瞻钩子,本轮无消费者)。 -**`app/panels/DatasetListPanel`**(已有)—— `datasetsLoaded` → `populateDatasetList`;空时显示"暂无数据集"。 +**`app/panels/DatasetListPanel`** —— `datasetsLoaded`→`populateDatasetList`(数据:dsName+类型名);`filesLoaded`→`populateFileList`(文件:文件名+可读大小,url 存角色备下载);空时占位。列表去隔行变色,改细分割线。 **中央/详情**:移除"启动自动渲染本地 demo";DS 点击 → 详情面板与中央视图显示占位文案 "该数据集渲染将在下一阶段接入 dd 接口"。渲染代码保留。 @@ -225,7 +229,7 @@ private: → controller.switchProject: loadStructure(id) → emit structureLoaded;清空 DS 列表/详情占位 选 TM: ObjectTreePanel.tmClicked(tmObjectId) - → controller.selectTm: loadDatasetsOfTm → emit datasetsLoaded → DatasetListPanel 填充 + → controller.selectTm: loadTmRows(pid,tm,3)+loadTmRows(pid,tm,1) → emit datasetsLoaded + filesLoaded → 数据/文件页签 点 DS: DatasetListPanel → 中央/详情显示占位"待接入"(本轮不渲染真实数据) ``` @@ -284,7 +288,7 @@ void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, ## 9. 测试策略 依既有无测试桩 + 依赖 live 服务器的现实,聚焦**纯逻辑单测**(GoogleTest + CTest): - `dto/NavDto` 映射:喂样本 JSON(取自 OpenAPI example / 手造)验证 - `parseWorkspaces / parseProjects / parseStructNodes / parseDatasets` 字段与 `ddCode→ddType`、`isCurTenant→isCurrent`。 + `parseWorkspaces / parseProjects / parseStructNodes / parseDsRows` 字段与 `name→typeName`、`isCurTenant→isCurrent`。 - `buildStructTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS)、孤儿 parentId、空列表、防环 等场景。 - 不做 live 集成 / E2E(无桩、依赖真实后端)。控制器/UI 信号联动靠手动联调验证。 - 目标:纯逻辑文件(dto + tree builder)覆盖率优先达标;UI/网络 IO 不计入。 From ee8342f4bf8ea2374a55f77ebee9640a47b2421b Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 15:29:42 +0800 Subject: [PATCH 22/25] =?UTF-8?q?feat(nav):=20ds=E6=95=B0=E6=8D=AE/?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=A1=B5=E7=AD=BE=E5=88=9B=E5=BB=BA=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=98=BE=E7=A4=BA=20+=20=E5=8A=A0=E8=BD=BD=E6=9B=B4?= =?UTF-8?q?=E5=A4=9A=E5=88=86=E9=A1=B5=EF=BC=88loadTmRows=E5=88=86?= =?UTF-8?q?=E9=A1=B5+total=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.cpp | 51 +++++++++++++++++++---- src/app/panels/DatasetListPanel.cpp | 19 +++++---- src/app/panels/DatasetListPanel.hpp | 5 ++- src/controller/WorkbenchNavController.cpp | 46 ++++++++++++++++---- src/controller/WorkbenchNavController.hpp | 13 +++++- src/data/api/ApiProjectRepository.cpp | 10 ++--- src/data/api/ApiProjectRepository.hpp | 5 +-- src/data/dto/NavDto.cpp | 8 ++++ src/data/dto/NavDto.hpp | 3 ++ src/data/repo/IProjectRepository.hpp | 8 ++-- src/data/repo/RepoTypes.hpp | 3 +- tests/data/test_nav_dto.cpp | 11 ++++- 12 files changed, 139 insertions(+), 43 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 2842a80..48cfa4c 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -502,7 +502,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 单击左下数据列表的采集批次(DS) → 占位(真实剖面/反演渲染下一阶段接 dd 接口)── QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, - [propLabel, detailRendererPtr, detailRenderWindowPtr](QListWidgetItem* item) { + [propLabel, detailRendererPtr, detailRenderWindowPtr, &nav](QListWidgetItem* item) { + if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { + nav.loadMoreData(); + return; + } const QString name = item->data(Qt::DisplayRole).toString().section('\n', 0, 0); detailRendererPtr->RemoveAllViewProps(); @@ -617,6 +621,23 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re } // ── 控制器 ↔ UI 信号接线(导航壳)────────────────────────────────────── + // "加载更多"行:列表末尾若已加载数 < 总数,放一行可点击的"加载更多(已/共)"。 + auto removeLoadMore = [](QListWidget* lw) { + if (lw->count() > 0 && + lw->item(lw->count() - 1)->data(geopro::app::kDsLoadMoreRole).toBool()) + delete lw->takeItem(lw->count() - 1); + }; + auto addLoadMore = [](QListWidget* lw, int total) { + const int loaded = lw->count(); + if (loaded < total) { + auto* m = new QListWidgetItem( + QStringLiteral("加载更多(%1/%2)").arg(loaded).arg(total), lw); + m->setData(geopro::app::kDsLoadMoreRole, true); + m->setTextAlignment(Qt::AlignCenter); + m->setForeground(QColor("#2D6CB5")); + } + return loaded; + }; QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav, &geopro::controller::WorkbenchNavController::switchWorkspace); QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav, @@ -643,19 +664,31 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re datasetTabs->setTabText(1, QStringLiteral("文件")); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList, - [datasetList, datasetTitle, datasetTabs]( - const QString&, const std::vector& list) { - geopro::app::populateDatasetList(datasetList, list); + [removeLoadMore, addLoadMore, datasetList, datasetTitle, datasetTabs]( + const QString&, const std::vector& rows, int total, + bool append) { + removeLoadMore(datasetList); + geopro::app::populateDatasetList(datasetList, rows, append); + const int loaded = addLoadMore(datasetList, total); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏")); datasetTabs->setTabText( - 0, QStringLiteral("数据 (%1)").arg(static_cast(list.size()))); + 0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total) + : QStringLiteral("数据")); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::filesLoaded, fileList, - [fileList, datasetTabs](const QString&, - const std::vector& list) { - geopro::app::populateFileList(fileList, list); + [removeLoadMore, addLoadMore, fileList, datasetTabs]( + const QString&, const std::vector& rows, int total, + bool append) { + removeLoadMore(fileList); + geopro::app::populateFileList(fileList, rows, append); + const int loaded = addLoadMore(fileList, total); datasetTabs->setTabText( - 1, QStringLiteral("文件 (%1)").arg(static_cast(list.size()))); + 1, total > 0 ? QStringLiteral("文件 (%1/%2)").arg(loaded).arg(total) + : QStringLiteral("文件")); + }); + QObject::connect(fileList, &QListWidget::itemClicked, fileList, + [&nav](QListWidgetItem* item) { + if (item->data(geopro::app::kDsLoadMoreRole).toBool()) nav.loadMoreFiles(); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::loadFailed, objectTree, [objectTree, &window](const QString& stage, const QString& msg) { diff --git a/src/app/panels/DatasetListPanel.cpp b/src/app/panels/DatasetListPanel.cpp index f1c6f4c..2ca911f 100644 --- a/src/app/panels/DatasetListPanel.cpp +++ b/src/app/panels/DatasetListPanel.cpp @@ -16,22 +16,25 @@ QString humanSize(long long b) { } } // namespace -void populateDatasetList(QListWidget* list, const std::vector& rows) { +void populateDatasetList(QListWidget* list, const std::vector& rows, bool append) { if (!list) return; - list->clear(); + if (!append) list->clear(); for (const auto& d : rows) { QString text = QString::fromStdString(d.dsName); - if (!d.typeName.empty()) text += QStringLiteral("\n%1").arg(QString::fromStdString(d.typeName)); + QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 + if (!d.typeName.empty()) + sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型 + if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub); auto* item = new QListWidgetItem(text, list); item->setData(kDsIdRole, QString::fromStdString(d.id)); item->setData(kDsDdTypeRole, QString::fromStdString(d.ddCode)); } } -void populateFileList(QListWidget* list, const std::vector& rows) { +void populateFileList(QListWidget* list, const std::vector& rows, bool append) { if (!list) return; - list->clear(); - if (rows.empty()) { + if (!append) list->clear(); + if (!append && rows.empty()) { auto* hint = new QListWidgetItem(QStringLiteral("(暂无文件)"), list); hint->setFlags(Qt::NoItemFlags); hint->setForeground(QColor("#9AA6B6")); @@ -41,7 +44,9 @@ void populateFileList(QListWidget* list, const std::vector& for (const auto& d : rows) { const QString fname = d.fileName.empty() ? QString::fromStdString(d.dsName) : QString::fromStdString(d.fileName); - const QString text = fname + QStringLiteral("\n%1").arg(humanSize(d.fileSize)); + QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 + sub += QStringLiteral(" · %1").arg(humanSize(d.fileSize)); // 再跟大小 + const QString text = fname + QStringLiteral("\n%1").arg(sub); auto* item = new QListWidgetItem(text, list); item->setData(kDsIdRole, QString::fromStdString(d.id)); item->setData(kDsFileUrlRole, QString::fromStdString(d.fileUrl)); diff --git a/src/app/panels/DatasetListPanel.hpp b/src/app/panels/DatasetListPanel.hpp index da04fb5..0356760 100644 --- a/src/app/panels/DatasetListPanel.hpp +++ b/src/app/panels/DatasetListPanel.hpp @@ -11,10 +11,11 @@ namespace geopro::app { constexpr int kDsIdRole = 0x0100; // Qt::UserRole constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1 constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2(文件下载 url,备用) +constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行 // 数据页签:每条 = dsName +(类型名);UserRole 存 dsId、+1 存 ddCode。 -void populateDatasetList(QListWidget* list, const std::vector& rows); +void populateDatasetList(QListWidget* list, const std::vector& rows, bool append); // 文件页签:每条 = 文件名 +(可读大小);UserRole 存 dsId、+2 存文件 url。空时显示占位。 -void populateFileList(QListWidget* list, const std::vector& rows); +void populateFileList(QListWidget* list, const std::vector& rows, bool append); } // namespace geopro::app diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 03b060c..093268a 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -106,20 +106,48 @@ void WorkbenchNavController::switchProject(const QString& projectId) { void WorkbenchNavController::selectTm(const QString& tmObjectId) { if (tmObjectId.isEmpty() || busy_) return; BusyGuard guard(this, &busy_); + currentTmId_ = tmObjectId.toStdString(); const std::string pid = currentProjectId_; - const std::string tm = tmObjectId.toStdString(); - const auto data = repo_.loadTmRows(pid, tm, 3); // 数据 - if (!data.ok) { - emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(data.error)); + dataPageNo_ = 1; + filePageNo_ = 1; + const auto d = repo_.loadTmRows(pid, currentTmId_, 3, dataPageNo_); + if (!d.ok) { + emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); return; } - emit datasetsLoaded(tmObjectId, data.value); - const auto files = repo_.loadTmRows(pid, tm, 1); // 文件 - if (!files.ok) { - emit loadFailed(QStringLiteral("files"), QString::fromStdString(files.error)); + dataTotal_ = d.value.total; + emit datasetsLoaded(tmObjectId, d.value.rows, d.value.total, false); + const auto f = repo_.loadTmRows(pid, currentTmId_, 1, filePageNo_); + if (!f.ok) { + emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); return; } - emit filesLoaded(tmObjectId, files.value); + fileTotal_ = f.value.total; + emit filesLoaded(tmObjectId, f.value.rows, f.value.total, false); +} + +void WorkbenchNavController::loadMoreData() { + if (currentTmId_.empty() || busy_) return; + BusyGuard guard(this, &busy_); + const auto d = repo_.loadTmRows(currentProjectId_, currentTmId_, 3, ++dataPageNo_); + if (!d.ok) { + emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); + return; + } + dataTotal_ = d.value.total; + emit datasetsLoaded(QString::fromStdString(currentTmId_), d.value.rows, d.value.total, true); +} + +void WorkbenchNavController::loadMoreFiles() { + if (currentTmId_.empty() || busy_) return; + BusyGuard guard(this, &busy_); + const auto f = repo_.loadTmRows(currentProjectId_, currentTmId_, 1, ++filePageNo_); + if (!f.ok) { + emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); + return; + } + fileTotal_ = f.value.total; + emit filesLoaded(QString::fromStdString(currentTmId_), f.value.rows, f.value.total, true); } } // namespace geopro::controller diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index e676685..6d0b7d6 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -22,14 +22,18 @@ public slots: void switchWorkspace(const QString& tenantId); void switchProject(const QString& projectId); void selectTm(const QString& tmObjectId); + void loadMoreData(); + void loadMoreFiles(); signals: void busyChanged(bool busy); void workspacesLoaded(const std::vector& list, const QString& currentId); void projectsLoaded(const std::vector& list, const QString& currentId); void structureLoaded(const QString& projectName, const std::vector& nodes); - void datasetsLoaded(const QString& tmObjectId, const std::vector& list); - void filesLoaded(const QString& tmObjectId, const std::vector& list); + void datasetsLoaded(const QString& tmObjectId, const std::vector& rows, + int total, bool append); + void filesLoaded(const QString& tmObjectId, const std::vector& rows, + int total, bool append); void loadFailed(const QString& stage, const QString& message); private: @@ -39,6 +43,11 @@ private: bool busy_ = false; std::vector lastProjects_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; + std::string currentTmId_; + int dataPageNo_ = 0; + int filePageNo_ = 0; + int dataTotal_ = 0; + int fileTotal_ = 0; }; } // namespace geopro::controller diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index e6c406c..e6765fd 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -63,9 +63,9 @@ RepoResult> ApiProjectRepository::loadStructure(const st return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}}; } -RepoResult> ApiProjectRepository::loadTmRows(const std::string& projectId, - const std::string& tmObjectId, - int classifyType) { +RepoResult ApiProjectRepository::loadTmRows(const std::string& projectId, + const std::string& tmObjectId, int classifyType, + int pageNo) { const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") : QStringLiteral("/business/dsObject/data/page"); const QJsonObject body{ @@ -73,11 +73,11 @@ RepoResult> ApiProjectRepository::loadTmRows(const std::strin {QStringLiteral("structParentId"), QString::fromStdString(tmObjectId)}, {QStringLiteral("structParentConfType"), 2}, {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, - {QStringLiteral("pageNo"), 1}, + {QStringLiteral("pageNo"), pageNo}, {QStringLiteral("pageSize"), 100}}; const net::ApiResponse r = api_.postJson(path, body); if (!ok(r)) return {false, {}, errorOf(r, "loadTmRows failed")}; - return {true, dto::parseDsRows(r.data.value(QStringLiteral("list")).toArray()), {}}; + return {true, dto::parseDsPage(r.data), {}}; } } // namespace geopro::data diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index 354e350..9501532 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -14,9 +14,8 @@ public: RepoResult switchWorkspace(const std::string& tenantId) override; RepoResult> listProjects(const std::string& lastProjectId) override; RepoResult> loadStructure(const std::string& projectId) override; - RepoResult> loadTmRows(const std::string& projectId, - const std::string& tmObjectId, - int classifyType) override; + RepoResult loadTmRows(const std::string& projectId, const std::string& tmObjectId, + int classifyType, int pageNo) override; private: net::ApiClient& api_; diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 48e5bb8..f45e26c 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -82,6 +82,7 @@ std::vector parseDsRows(const QJsonArray& arr) { d.dsName = str(o, "dsName"); d.typeName = str(o, "name"); // 注意:name 字段=ds类型名 d.ddCode = str(o, "ddCode"); + d.createTime = str(o, "createTime"); const QJsonObject f = o.value(QStringLiteral("file")).toObject(); d.fileName = str(f, "name"); d.fileUrl = str(f, "url"); @@ -91,6 +92,13 @@ std::vector parseDsRows(const QJsonArray& arr) { return out; } +DsPage parseDsPage(const QJsonObject& data) { + DsPage p; + p.rows = parseDsRows(data.value(QStringLiteral("list")).toArray()); + p.total = data.value(QStringLiteral("total")).toInt(); + return p; +} + std::vector buildStructTree(const std::vector& flat) { // 过滤 DS(type==3):DS 不进对象树(按 TM 单独拉取到数据列表)。 std::vector nodes; diff --git a/src/data/dto/NavDto.hpp b/src/data/dto/NavDto.hpp index 3d36326..2acbb8a 100644 --- a/src/data/dto/NavDto.hpp +++ b/src/data/dto/NavDto.hpp @@ -22,6 +22,9 @@ std::vector parseStructNodes(const QJsonArray& arr); // data/page / file/page 的 data["list"] 数组 → DsRow(数据行无 file;文件行含 file{name,size,url})。 std::vector parseDsRows(const QJsonArray& arr); +// data/page 或 file/page 的整个 data 对象 → DsPage(rows + total)。 +DsPage parseDsPage(const QJsonObject& data); + // 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理:项目直挂 TM、孤儿 parentId、空表。 struct StructTreeNode { StructNode node; diff --git a/src/data/repo/IProjectRepository.hpp b/src/data/repo/IProjectRepository.hpp index 1f46922..ce9a3ea 100644 --- a/src/data/repo/IProjectRepository.hpp +++ b/src/data/repo/IProjectRepository.hpp @@ -21,10 +21,10 @@ public: virtual RepoResult switchWorkspace(const std::string& tenantId) = 0; virtual RepoResult> listProjects(const std::string& lastProjectId) = 0; virtual RepoResult> loadStructure(const std::string& projectId) = 0; - // 按 TM 拉数据集/文件行:classifyType 3=数据(data/page) 1=文件(file/page)。 - virtual RepoResult> loadTmRows(const std::string& projectId, - const std::string& tmObjectId, - int classifyType) = 0; + // 按 TM 分页拉数据/文件行:classifyType 3=数据 1=文件;pageNo 从 1 起,pageSize 固定 100。 + virtual RepoResult loadTmRows(const std::string& projectId, + const std::string& tmObjectId, int classifyType, + int pageNo) = 0; }; } // namespace geopro::data diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index 4307e93..ab5015c 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -6,10 +6,11 @@ struct DsNode { std::string id, name, ddType; }; // data/page 或 file/page 的一条 ds。数据行只用 dsName/typeName/ddCode;文件行另含 file*。 struct DsRow { - std::string id, dsName, typeName, ddCode; + std::string id, dsName, typeName, ddCode, createTime; std::string fileName, fileUrl; long long fileSize = 0; }; +struct DsPage { std::vector rows; int total = 0; }; struct TmNode { std::string id, name, confCode; std::vector dss; }; struct GsNode { std::string id, name; std::vector tms; }; struct Project { std::string id, name; std::vector gss; }; diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 918b384..7a769c0 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -147,12 +147,13 @@ TEST(NavDto, BuildStructTreeDropsDsAndTmStaysLeaf) { TEST(NavDto, ParseDsRowsDataAndFile) { const auto d = dto::parseDsRows(arrOf(R"([ - {"id":"d1","dsName":"ERT1-WS","name":"电阻率数据","ddCode":"dd_inversion_data"} + {"id":"d1","dsName":"ERT1-WS","name":"电阻率数据","ddCode":"dd_inversion_data","createTime":"2026-03-25 16:48:57"} ])")); ASSERT_EQ(d.size(), 1u); EXPECT_EQ(d[0].id, "d1"); EXPECT_EQ(d[0].dsName, "ERT1-WS"); EXPECT_EQ(d[0].typeName, "电阻率数据"); + EXPECT_EQ(d[0].createTime, "2026-03-25 16:48:57"); EXPECT_TRUE(d[0].fileName.empty()); const auto f = dto::parseDsRows(arrOf(R"([ @@ -163,4 +164,12 @@ TEST(NavDto, ParseDsRowsDataAndFile) { EXPECT_EQ(f[0].fileName, "ERT1-WS.xlsx"); EXPECT_EQ(f[0].fileSize, 62760); EXPECT_EQ(f[0].fileUrl, "/common/file/x.xlsx"); + + const auto page = dto::parseDsPage(objOf(R"({ + "total": 18, + "list": [{"id":"x","dsName":"a","name":"t","ddCode":"dd","createTime":"2026-01-01 00:00:00"}] + })")); + EXPECT_EQ(page.total, 18); + ASSERT_EQ(page.rows.size(), 1u); + EXPECT_EQ(page.rows[0].dsName, "a"); } From b4824a6e4e184f435be8575a325cebfc795465ef Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 18:17:23 +0800 Subject: [PATCH 23/25] =?UTF-8?q?feat(nav):=20=E9=A1=B9=E7=9B=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=BC=B9=E7=AA=97=EF=BC=88=E5=90=8D=E7=A7=B0/?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E8=BF=87=E6=BB=A4+=E5=88=86=E9=A1=B5+8?= =?UTF-8?q?=E5=88=97=EF=BC=8C=E7=82=B9=E9=A1=B9=E7=9B=AE=E5=90=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=EF=BC=89+=20=E4=B8=8B=E6=8B=89=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 3 +- src/app/ProjectListDialog.cpp | 160 ++++++++++++++++++++++ src/app/ProjectListDialog.hpp | 39 ++++++ src/app/TopBar.cpp | 12 +- src/app/TopBar.hpp | 5 +- src/app/main.cpp | 19 ++- src/controller/WorkbenchNavController.cpp | 10 +- src/controller/WorkbenchNavController.hpp | 3 +- src/data/api/ApiProjectRepository.cpp | 21 ++- src/data/api/ApiProjectRepository.hpp | 4 +- src/data/dto/NavDto.cpp | 22 +++ src/data/dto/NavDto.hpp | 5 + src/data/repo/IProjectRepository.hpp | 7 +- src/data/repo/RepoTypes.hpp | 13 +- tests/data/test_nav_dto.cpp | 28 ++++ 15 files changed, 331 insertions(+), 20 deletions(-) create mode 100644 src/app/ProjectListDialog.cpp create mode 100644 src/app/ProjectListDialog.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index f398122..cb04be7 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -23,7 +23,8 @@ add_executable(geopro_desktop WIN32 panels/AnomalyListPanel.cpp panels/DatasetListPanel.cpp panels/ObjectTreePanel.cpp - CentralScene.cpp) + CentralScene.cpp + ProjectListDialog.cpp) target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/app/ProjectListDialog.cpp b/src/app/ProjectListDialog.cpp new file mode 100644 index 0000000..02f0574 --- /dev/null +++ b/src/app/ProjectListDialog.cpp @@ -0,0 +1,160 @@ +#include "ProjectListDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace geopro::app { +namespace { +QString statusText(int s) { + switch (s) { + case 1: return QStringLiteral("未开始"); + case 2: return QStringLiteral("进行中"); + default: return QString::number(s); + } +} +} // namespace + +ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* parent) + : QDialog(parent), repo_(repo) { + setWindowTitle(QStringLiteral("全部项目")); + resize(980, 560); + + auto* root = new QVBoxLayout(this); + + auto* filter = new QHBoxLayout(); + filter->addWidget(new QLabel(QStringLiteral("项目名称"), this)); + nameEdit_ = new QLineEdit(this); + nameEdit_->setPlaceholderText(QStringLiteral("输入项目名称")); + nameEdit_->setFixedWidth(200); + filter->addWidget(nameEdit_); + filter->addSpacing(8); + filter->addWidget(new QLabel(QStringLiteral("项目类型"), this)); + typeCombo_ = new QComboBox(this); + typeCombo_->setFixedWidth(160); + filter->addWidget(typeCombo_); + filter->addSpacing(8); + auto* searchBtn = new QPushButton(QStringLiteral("搜索"), this); + auto* resetBtn = new QPushButton(QStringLiteral("重置"), this); + filter->addWidget(searchBtn); + filter->addWidget(resetBtn); + filter->addStretch(); + root->addLayout(filter); + + table_ = new QTableWidget(this); + table_->setColumnCount(8); + table_->setHorizontalHeaderLabels(QStringList{ + QStringLiteral("序号"), QStringLiteral("项目名称"), QStringLiteral("项目编号"), + QStringLiteral("项目状态"), QStringLiteral("项目类型"), QStringLiteral("业主单位"), + QStringLiteral("负责人"), QStringLiteral("创建时间")}); + table_->setEditTriggers(QAbstractItemView::NoEditTriggers); + table_->setSelectionBehavior(QAbstractItemView::SelectRows); + table_->setSelectionMode(QAbstractItemView::SingleSelection); + table_->verticalHeader()->setVisible(false); + table_->horizontalHeader()->setStretchLastSection(true); + table_->setColumnWidth(0, 50); + table_->setColumnWidth(1, 260); + root->addWidget(table_, 1); + + auto* bottom = new QHBoxLayout(); + pageLabel_ = new QLabel(this); + bottom->addWidget(pageLabel_); + bottom->addStretch(); + prevBtn_ = new QPushButton(QStringLiteral("上一页"), this); + nextBtn_ = new QPushButton(QStringLiteral("下一页"), this); + bottom->addWidget(prevBtn_); + bottom->addWidget(nextBtn_); + root->addLayout(bottom); + + fillTypeFilter(); + + QObject::connect(searchBtn, &QPushButton::clicked, this, [this]() { + pageNo_ = 1; + query(); + }); + QObject::connect(resetBtn, &QPushButton::clicked, this, [this]() { + nameEdit_->clear(); + typeCombo_->setCurrentIndex(0); + pageNo_ = 1; + query(); + }); + QObject::connect(prevBtn_, &QPushButton::clicked, this, [this]() { + if (pageNo_ > 1) { + --pageNo_; + query(); + } + }); + QObject::connect(nextBtn_, &QPushButton::clicked, this, [this]() { + if (pageNo_ * pageSize_ < total_) { + ++pageNo_; + query(); + } + }); + QObject::connect(table_, &QTableWidget::cellClicked, this, [this](int row, int col) { + if (col != 1) return; // 仅"项目名称"列触发切换 + auto* item = table_->item(row, 1); + if (!item) return; + const QString id = item->data(Qt::UserRole).toString(); + if (id.isEmpty()) return; + emit projectChosen(id, item->text()); + accept(); + }); + + query(); +} + +void ProjectListDialog::fillTypeFilter() { + typeCombo_->addItem(QStringLiteral("全部类型"), QString()); + const auto r = repo_.listProjectTypes(); + if (!r.ok) return; + for (const auto& t : r.value) + typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id)); +} + +void ProjectListDialog::query() { + const std::string name = nameEdit_->text().trimmed().toStdString(); + const std::string typeId = typeCombo_->currentData().toString().toStdString(); + const auto r = repo_.pageProjects(name, typeId, pageNo_, pageSize_); + if (!r.ok) { + table_->setRowCount(0); + pageLabel_->setText(QStringLiteral("加载失败:%1").arg(QString::fromStdString(r.error))); + prevBtn_->setEnabled(false); + nextBtn_->setEnabled(false); + return; + } + total_ = r.value.total; + const auto& rows = r.value.rows; + table_->setRowCount(static_cast(rows.size())); + for (int i = 0; i < static_cast(rows.size()); ++i) { + const auto& p = rows[i]; + auto set = [&](int col, const QString& text) { + table_->setItem(i, col, new QTableWidgetItem(text)); + }; + set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1)); + auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name)); + nameItem->setData(Qt::UserRole, QString::fromStdString(p.id)); + nameItem->setForeground(QColor("#2D6CB5")); + table_->setItem(i, 1, nameItem); + set(2, QString::fromStdString(p.code)); + set(3, statusText(p.status)); + set(4, QString::fromStdString(p.typeName)); + set(5, QString::fromStdString(p.ownerCompany)); + set(6, QString::fromStdString(p.responsiblePerson)); + set(7, QString::fromStdString(p.createTime)); + } + const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1; + pageLabel_->setText(QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages)); + prevBtn_->setEnabled(pageNo_ > 1); + nextBtn_->setEnabled(pageNo_ < pages); +} + +} // namespace geopro::app diff --git a/src/app/ProjectListDialog.hpp b/src/app/ProjectListDialog.hpp new file mode 100644 index 0000000..c0675a8 --- /dev/null +++ b/src/app/ProjectListDialog.hpp @@ -0,0 +1,39 @@ +#pragma once +#include + +#include "repo/IProjectRepository.hpp" + +class QLineEdit; +class QComboBox; +class QTableWidget; +class QLabel; +class QPushButton; + +namespace geopro::app { + +// 项目列表弹窗:名称/类型过滤 + 分页表格;点项目名 → 切换项目并关闭。 +class ProjectListDialog : public QDialog { + Q_OBJECT +public: + explicit ProjectListDialog(data::IProjectRepository& repo, QWidget* parent = nullptr); + +signals: + void projectChosen(const QString& projectId, const QString& projectName); + +private: + void query(); + void fillTypeFilter(); + + data::IProjectRepository& repo_; + QLineEdit* nameEdit_ = nullptr; + QComboBox* typeCombo_ = nullptr; + QTableWidget* table_ = nullptr; + QLabel* pageLabel_ = nullptr; + QPushButton* prevBtn_ = nullptr; + QPushButton* nextBtn_ = nullptr; + int pageNo_ = 1; + int pageSize_ = 20; + int total_ = 0; +}; + +} // namespace geopro::app diff --git a/src/app/TopBar.cpp b/src/app/TopBar.cpp index c1df2af..9d20fc7 100644 --- a/src/app/TopBar.cpp +++ b/src/app/TopBar.cpp @@ -236,7 +236,8 @@ void TopBar::setWorkspaces(const std::vector& list, const QStri QStringLiteral(" ▾")); } -void TopBar::setProjects(const std::vector& list, const QString& currentId) { +void TopBar::setProjects(const std::vector& list, const QString& currentId, + bool hasMore) { auto* menu = new QMenu(projBtn_); auto* header = menu->addAction(QStringLiteral("切换项目")); header->setEnabled(false); @@ -261,9 +262,18 @@ void TopBar::setProjects(const std::vector& list, const QS auto* none = menu->addAction(QStringLiteral("(暂无项目)")); none->setEnabled(false); } + if (hasMore) { + menu->addSeparator(); + auto* all = menu->addAction(QStringLiteral("全部项目…")); + QObject::connect(all, &QAction::triggered, this, [this]() { emit allProjectsRequested(); }); + } projBtn_->setMenu(menu); projBtn_->setText((currentName.isEmpty() ? QStringLiteral("选择项目") : currentName) + QStringLiteral(" ▾")); } +void TopBar::setProjectButtonText(const QString& name) { + projBtn_->setText(name + QStringLiteral(" ▾")); +} + } // namespace geopro::app diff --git a/src/app/TopBar.hpp b/src/app/TopBar.hpp index 3937f9f..b6388ae 100644 --- a/src/app/TopBar.hpp +++ b/src/app/TopBar.hpp @@ -17,11 +17,14 @@ public: explicit TopBar(QWidget* parent = nullptr); void setWorkspaces(const std::vector& list, const QString& currentId); - void setProjects(const std::vector& list, const QString& currentId); + void setProjects(const std::vector& list, const QString& currentId, + bool hasMore); + void setProjectButtonText(const QString& name); // 弹窗切换项目后更新按钮文字 signals: void workspaceSwitchRequested(const QString& tenantId); void projectSwitchRequested(const QString& projectId); + void allProjectsRequested(); // 点击"全部项目…" private: QToolButton* wsBtn_ = nullptr; diff --git a/src/app/main.cpp b/src/app/main.cpp index 48cfa4c..8fe93f5 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -61,6 +61,7 @@ #include "Theme.hpp" #include "TopBar.hpp" #include "CentralScene.hpp" +#include "ProjectListDialog.hpp" #include "WorkbenchNavController.hpp" #include "api/ApiProjectRepository.hpp" #include "panels/ObjectTreePanel.hpp" @@ -138,6 +139,7 @@ constexpr const char* kWgs84 = "EPSG:4326"; // 在给定 QMainWindow 上构建 M1 工作台。 // repo 生命周期须覆盖到事件循环结束(由调用方保证)。 void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo, + geopro::data::IProjectRepository& projectRepo, geopro::controller::WorkbenchNavController& nav) { // ── 世界系:启动取一次 grid1 的 lat/lon,用中位数作 GeoLocalFrame 原点 ── @@ -642,6 +644,17 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re &geopro::controller::WorkbenchNavController::switchWorkspace); QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav, &geopro::controller::WorkbenchNavController::switchProject); + QObject::connect(topBar, &geopro::app::TopBar::allProjectsRequested, &window, + [&projectRepo, &nav, topBar, &window]() { + auto* dlg = new geopro::app::ProjectListDialog(projectRepo, &window); + dlg->setAttribute(Qt::WA_DeleteOnClose); + QObject::connect(dlg, &geopro::app::ProjectListDialog::projectChosen, &nav, + [&nav, topBar](const QString& id, const QString& name) { + topBar->setProjectButtonText(name); + nav.switchProject(id); + }); + dlg->exec(); + }); QObject::connect(objectTree, &geopro::app::ObjectTreePanel::tmClicked, &nav, &geopro::controller::WorkbenchNavController::selectTm); @@ -651,7 +664,9 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar, [topBar](const std::vector& list, - const QString& cur) { topBar->setProjects(list, cur); }); + const QString& cur, int total) { + topBar->setProjects(list, cur, total > static_cast(list.size())); + }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree, [objectTree, datasetList, fileList, datasetTitle, datasetTabs]( const QString& projectName, @@ -772,7 +787,7 @@ int main(int argc, char* argv[]) window.resize(1280, 800); window.setMinimumSize(1024, 680); // 防止停靠面板被压到不可用尺寸 - buildWorkbench(window, repo, nav); + buildWorkbench(window, repo, projectRepo, nav); window.show(); nav.start(); // 进入工作台后拉真实 空间/项目/结构 diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 093268a..ecdefff 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -42,15 +42,15 @@ void WorkbenchNavController::start() { } void WorkbenchNavController::loadProjectsAndStructure() { - const auto ps = repo_.listProjects(std::string()); + const auto ps = repo_.pageProjects(std::string(), std::string(), 1, 20); if (!ps.ok) { emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error)); return; } - lastProjects_ = ps.value; + lastProjects_ = ps.value.rows; QString curP; - if (!ps.value.empty()) { - const auto& first = ps.value.front(); + if (!ps.value.rows.empty()) { + const auto& first = ps.value.rows.front(); curP = QString::fromStdString(first.id); currentProjectId_ = first.id; currentProjectName_ = first.name; @@ -60,7 +60,7 @@ void WorkbenchNavController::loadProjectsAndStructure() { currentProjectName_.clear(); currentCrsCode_.clear(); } - emit projectsLoaded(ps.value, curP); + emit projectsLoaded(ps.value.rows, curP, ps.value.total); if (curP.isEmpty()) { emit structureLoaded(QString(), {}); // 暂无项目 → 空树 diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index 6d0b7d6..04f6a66 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -28,7 +28,8 @@ public slots: signals: void busyChanged(bool busy); void workspacesLoaded(const std::vector& list, const QString& currentId); - void projectsLoaded(const std::vector& list, const QString& currentId); + void projectsLoaded(const std::vector& list, + const QString& currentId, int total); void structureLoaded(const QString& projectName, const std::vector& nodes); void datasetsLoaded(const QString& tmObjectId, const std::vector& rows, int total, bool append); diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index e6765fd..4f3b73a 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -47,11 +47,22 @@ RepoResult ApiProjectRepository::switchWorkspace(const std::string& tenant return {true, true, {}}; } -RepoResult> ApiProjectRepository::listProjects(const std::string&) { - // 我的工作台项目列表(当前空间全部项目)。queryByUser 实测为空,故用此接口。 - const net::ApiResponse r = api_.get(QStringLiteral("/business/my/profile/queryProject")); - if (!ok(r)) return {false, {}, errorOf(r, "listProjects failed")}; - return {true, dto::parseProjectList(r.data.value(QStringLiteral("value")).toArray()), {}}; +RepoResult ApiProjectRepository::pageProjects(const std::string& nameFilter, + const std::string& typeId, int pageNo, + int pageSize) { + QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)}, + {QStringLiteral("pageNo"), pageNo}, + {QStringLiteral("pageSize"), pageSize}}; + if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId); + const net::ApiResponse r = api_.postJson(QStringLiteral("/business/my/profile/project/page"), body); + if (!ok(r)) return {false, {}, errorOf(r, "pageProjects failed")}; + return {true, dto::parseProjectPage(r.data), {}}; +} + +RepoResult> ApiProjectRepository::listProjectTypes() { + const net::ApiResponse r = api_.get(QStringLiteral("/business/project/type/list")); + if (!ok(r)) return {false, {}, errorOf(r, "listProjectTypes failed")}; + return {true, dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray()), {}}; } RepoResult> ApiProjectRepository::loadStructure(const std::string& projectId) { diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index 9501532..9423897 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -12,7 +12,9 @@ public: RepoResult> listWorkspaces() override; RepoResult switchWorkspace(const std::string& tenantId) override; - RepoResult> listProjects(const std::string& lastProjectId) override; + RepoResult pageProjects(const std::string& nameFilter, const std::string& typeId, + int pageNo, int pageSize) override; + RepoResult> listProjectTypes() override; RepoResult> loadStructure(const std::string& projectId) override; RepoResult loadTmRows(const std::string& projectId, const std::string& tmObjectId, int classifyType, int pageNo) override; diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index f45e26c..ef2adcd 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -19,6 +19,11 @@ ProjectSummary parseProjectItem(const QJsonObject& o) { p.typeName = str(o, "projectTypeName"); p.crsCode = str(o, "referenceCRSCode"); p.crsName = str(o, "referenceCRSName"); + p.code = str(o, "projectCode"); + p.projectTypeId = str(o, "projectTypeId"); + p.ownerCompany = str(o, "ownerCompanyName"); + p.responsiblePerson = str(o, "responsiblePersonName"); + p.createTime = str(o, "createTime"); p.status = o.value(QStringLiteral("status")).toInt(); return p; } @@ -55,6 +60,23 @@ std::vector parseProjectList(const QJsonArray& arr) { return out; } +ProjectListPage parseProjectPage(const QJsonObject& data) { + ProjectListPage p; + p.rows = parseProjectList(data.value(QStringLiteral("list")).toArray()); + p.total = data.value(QStringLiteral("total")).toInt(); + return p; +} + +std::vector parseProjectTypes(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(arr.size())); + for (const QJsonValue& v : arr) { + const QJsonObject o = v.toObject(); + out.push_back(ProjectType{str(o, "id"), str(o, "name")}); + } + return out; +} + std::vector parseStructNodes(const QJsonArray& arr) { std::vector out; out.reserve(static_cast(arr.size())); diff --git a/src/data/dto/NavDto.hpp b/src/data/dto/NavDto.hpp index 2acbb8a..6a3c2b7 100644 --- a/src/data/dto/NavDto.hpp +++ b/src/data/dto/NavDto.hpp @@ -16,6 +16,11 @@ ProjectPage parseProjects(const QJsonObject& data); // my/profile/queryProject 的 data["value"] 数组 → 模型(与 parseProjects 同字段映射)。 std::vector parseProjectList(const QJsonArray& arr); +// my/profile/project/page 的整个 data 对象 → ProjectListPage(rows + total)。 +ProjectListPage parseProjectPage(const QJsonObject& data); +// project/type/list 的 data["value"] 数组 → 项目类型列表。 +std::vector parseProjectTypes(const QJsonArray& arr); + // 结构扁平节点数组(queryProjectStruct 的 data["projectStructList"])→ 模型。 std::vector parseStructNodes(const QJsonArray& arr); diff --git a/src/data/repo/IProjectRepository.hpp b/src/data/repo/IProjectRepository.hpp index ce9a3ea..eb857db 100644 --- a/src/data/repo/IProjectRepository.hpp +++ b/src/data/repo/IProjectRepository.hpp @@ -19,7 +19,12 @@ public: virtual ~IProjectRepository() = default; virtual RepoResult> listWorkspaces() = 0; virtual RepoResult switchWorkspace(const std::string& tenantId) = 0; - virtual RepoResult> listProjects(const std::string& lastProjectId) = 0; + // 项目分页:nameFilter 名称模糊(可空)、typeId 类型过滤(空=不限)、pageNo 从 1 起。 + virtual RepoResult pageProjects(const std::string& nameFilter, + const std::string& typeId, int pageNo, + int pageSize) = 0; + // 项目类型列表(弹窗类型过滤下拉)。 + virtual RepoResult> listProjectTypes() = 0; virtual RepoResult> loadStructure(const std::string& projectId) = 0; // 按 TM 分页拉数据/文件行:classifyType 3=数据 1=文件;pageNo 从 1 起,pageSize 固定 100。 virtual RepoResult loadTmRows(const std::string& projectId, diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index ab5015c..1bb99a6 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -18,8 +18,17 @@ struct Project { std::string id, name; std::vector gss; }; // 工作空间(=企业租户/空间)。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; }; +// 项目摘要(下拉/弹窗列表用)。crsCode 供下一轮替换硬编码 EPSG:4547。 +struct ProjectSummary { + std::string id, name, typeName, crsCode, crsName; + std::string code, projectTypeId, ownerCompany, responsiblePerson, createTime; + int status = 0; +}; + +// 项目类型(弹窗类型过滤下拉用)。 +struct ProjectType { std::string id, name; }; +// 项目分页结果。 +struct ProjectListPage { std::vector rows; int total = 0; }; // 项目结构扁平节点(仅 GS / TM)。客户端按 parentId 建树,叶子=TM。 struct StructNode { std::string id, name, parentId, typeName, confCode; int type = 0; }; diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 7a769c0..2f0e281 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -173,3 +173,31 @@ TEST(NavDto, ParseDsRowsDataAndFile) { ASSERT_EQ(page.rows.size(), 1u); EXPECT_EQ(page.rows[0].dsName, "a"); } + +TEST(NavDto, ParseProjectItemFullFields) { + const auto v = dto::parseProjectList(arrOf(R"([ + {"id":"p1","projectName":"演示","projectCode":"001","status":2, + "projectTypeId":"t1","projectTypeName":"全量类型", + "ownerCompanyName":"华南所","responsiblePersonName":"张三","createTime":"2026-01-01 00:00:00"} + ])")); + ASSERT_EQ(v.size(), 1u); + EXPECT_EQ(v[0].code, "001"); + EXPECT_EQ(v[0].status, 2); + EXPECT_EQ(v[0].projectTypeId, "t1"); + EXPECT_EQ(v[0].typeName, "全量类型"); + EXPECT_EQ(v[0].ownerCompany, "华南所"); + EXPECT_EQ(v[0].responsiblePerson, "张三"); + EXPECT_EQ(v[0].createTime, "2026-01-01 00:00:00"); +} + +TEST(NavDto, ParseProjectPageAndTypes) { + const auto page = + dto::parseProjectPage(objOf(R"({"total":20,"list":[{"id":"p1","projectName":"a"}]})")); + EXPECT_EQ(page.total, 20); + ASSERT_EQ(page.rows.size(), 1u); + EXPECT_EQ(page.rows[0].name, "a"); + const auto types = dto::parseProjectTypes(arrOf(R"([{"id":"t1","name":"全量类型"}])")); + ASSERT_EQ(types.size(), 1u); + EXPECT_EQ(types[0].id, "t1"); + EXPECT_EQ(types[0].name, "全量类型"); +} From a37596f0d3db11508c7534fccda9770e5d6e44c3 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 18:37:00 +0800 Subject: [PATCH 24/25] =?UTF-8?q?tune(nav):=20=E4=B8=8B=E6=8B=89=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E9=A6=96=E9=A1=B510=E3=80=81ds=E6=95=B0=E6=8D=AE/?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=AF=8F=E9=A1=B55=EF=BC=88=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E5=85=A8=E9=83=A8=E9=A1=B9=E7=9B=AE/=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=9B=B4=E5=A4=9A=E6=9B=B4=E6=98=93=E8=BE=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/WorkbenchNavController.cpp | 2 +- src/data/api/ApiProjectRepository.cpp | 2 +- src/data/repo/IProjectRepository.hpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index ecdefff..a3366fa 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -42,7 +42,7 @@ void WorkbenchNavController::start() { } void WorkbenchNavController::loadProjectsAndStructure() { - const auto ps = repo_.pageProjects(std::string(), std::string(), 1, 20); + const auto ps = repo_.pageProjects(std::string(), std::string(), 1, 10); // 下拉首页 10 if (!ps.ok) { emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error)); return; diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index 4f3b73a..ab7cb43 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -85,7 +85,7 @@ RepoResult ApiProjectRepository::loadTmRows(const std::string& projectId {QStringLiteral("structParentConfType"), 2}, {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, {QStringLiteral("pageNo"), pageNo}, - {QStringLiteral("pageSize"), 100}}; + {QStringLiteral("pageSize"), 5}}; // 数据/文件页签每页 5;不足 total 时"加载更多"追加 const net::ApiResponse r = api_.postJson(path, body); if (!ok(r)) return {false, {}, errorOf(r, "loadTmRows failed")}; return {true, dto::parseDsPage(r.data), {}}; diff --git a/src/data/repo/IProjectRepository.hpp b/src/data/repo/IProjectRepository.hpp index eb857db..37bd82c 100644 --- a/src/data/repo/IProjectRepository.hpp +++ b/src/data/repo/IProjectRepository.hpp @@ -26,7 +26,7 @@ public: // 项目类型列表(弹窗类型过滤下拉)。 virtual RepoResult> listProjectTypes() = 0; virtual RepoResult> loadStructure(const std::string& projectId) = 0; - // 按 TM 分页拉数据/文件行:classifyType 3=数据 1=文件;pageNo 从 1 起,pageSize 固定 100。 + // 按 TM 分页拉数据/文件行:classifyType 3=数据 1=文件;pageNo 从 1 起,pageSize 固定 5。 virtual RepoResult loadTmRows(const std::string& projectId, const std::string& tmObjectId, int classifyType, int pageNo) = 0; From 475af464d9029edca0cef819b35428f8b9bc4353 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 18:45:25 +0800 Subject: [PATCH 25/25] =?UTF-8?q?docs(spec):=20=E8=A1=A5=E9=BD=90=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=88=97=E8=A1=A8=E5=BC=B9=E7=AA=97/ds=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=9B=B4=E5=A4=9A=E5=88=86=E9=A1=B5/=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E6=97=B6=E9=97=B4=E6=98=BE=E7=A4=BA/=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E6=9D=A1=E6=95=B0(10=C2=B75)/=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=98=A0=E5=B0=84(1=E6=9C=AA=E5=BC=80=E5=A7=8B2=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-09-real-api-navigation-design.md | 82 +++++++++++++------ 1 file changed, 57 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md index 0af9ee9..4992013 100644 --- a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md +++ b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md @@ -24,8 +24,11 @@ **做(In Scope)** - 工作空间列表 / 切换(真实接口)。 -- 项目列表 / 切换(真实接口)。 +- 项目列表 / 切换(真实接口):下拉显示首页项目(首页 10);项目数超过首页 → 下拉底部「全部项目…」打开 + **项目列表弹窗**(名称/类型过滤 + 分页 + 8 列表格:序号/名称/编号/状态/类型/业主/负责人/创建时间;点项目名切换并关弹窗)。 - 对象树:**按真实结构显示 GS 层**(项目根 → GS → TM);TM 在左下"数据真实显示栏"列出其 DS。 +- DS **数据/文件两个页签**接真实分页接口(每页 5);每行显示"名称 / 创建时间 · 类型(数据)或大小(文件)"; + 超过首页 → 列表末尾「加载更多」追加下一页。 - 真实接口失败(断网 / token 过期 / 无数据)→ **显示错误 / 空状态**,**不回退本地样本**。 - 项目 `referenceCRSCode` 存入导航状态,供下一轮替换硬编码 `EPSG:4547`(本轮不改渲染)。 @@ -34,7 +37,7 @@ → 点击真实 DS 时中央/详情显示**占位"待接入"**;`render/*` 与 `LocalSampleRepository` 代码**保留不删**。 - 异步仓储(QFuture/回调)—— 本轮同步阻塞 + WaitCursor(与登录一致),异步留 M1.5。 - 用户头像 / 姓名接真实 `auth/getUserInfo`(本轮先留静态)。 -- 项目分页"加载更多"的滚动 UI(接口支持 `hasNextPage`,本轮先取首页;翻页留待后续)。 +- 文件下载(文件页签已展示文件名/大小、下载 `url` 已存入列表项备用,实际下载动作留后续)。 ## 3. 接口确认结论 @@ -45,10 +48,11 @@ 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:, 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}` | +| 项目分页 | 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:, 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` 自建树。 @@ -109,15 +113,19 @@ token 已由登录注入(`geomativeauthorization` 头),下列接口直接 struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; }; struct ProjectSummary { std::string id, name, typeName, crsCode, crsName; - int status = 0; + std::string code, projectTypeId, ownerCompany, responsiblePerson, createTime; // 弹窗 8 列用 + int status = 0; // 1=未开始 2=进行中(其余显示数字) }; +struct ProjectType { std::string id, name; }; // 类型过滤下拉 +struct ProjectListPage { std::vector rows; int total = 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` 仅本地样本仓储继续用。 +新增 `DsRow{id, dsName, typeName, ddCode, createTime, fileName, fileUrl, fileSize}`(数据/文件页签行通用;文件行含 file*) ++ `DsPage{rows, total}`(分页结果)。`DsNode` 仅本地样本仓储继续用。 ### 5.3 数据访问层 `data` @@ -133,11 +141,15 @@ 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 pageProjects(const std::string& nameFilter, + const std::string& typeId, int pageNo, + int pageSize) = 0; + virtual RepoResult> listProjectTypes() = 0; virtual RepoResult> loadStructure(const std::string& projectId) = 0; - virtual RepoResult> loadTmRows(const std::string& projectId, - const std::string& tmObjectId, - int classifyType) = 0; // 3=数据 1=文件 + // 按 TM 分页拉数据/文件行:classifyType 3=数据 1=文件;pageNo 从 1 起,pageSize 固定 5。 + virtual RepoResult loadTmRows(const std::string& projectId, const std::string& tmObjectId, + int classifyType, int pageNo) = 0; }; ``` @@ -148,9 +160,10 @@ public: **`dto/NavDto.{hpp,cpp}`** — 纯函数映射(**无网络、可单测**): - `parseWorkspaces(QJsonArray) -> vector`(`isCurTenant==1 → isCurrent`)。 -- `parseProjects(QJsonObject) -> {vector, bool hasNextPage}`。 +- `parseProjectList(QJsonArray) -> vector` / `parseProjectPage(QJsonObject) -> ProjectListPage{rows,total}`(project/page)。 +- `parseProjectTypes(QJsonArray) -> vector`(type/list)。 - `parseStructNodes(QJsonArray) -> vector`。 -- `parseDsRows(QJsonArray) -> vector`(data/file page 的 `data.list`;`name→typeName`,`file{name,size,url}`)。 +- `parseDsRows(QJsonArray) -> vector` / `parseDsPage(QJsonObject) -> DsPage{rows,total}`(data/file page;`name→typeName`、`createTime`、`file{name,size,url}`)。 - `buildStructTree(vector) -> vector`:扁平→**通用树**(不强塞 `Project/Gs/Tm` 刚性模型, 以适配任意层级 + TM 直挂项目)。`StructTreeNode{StructNode node; bool isTm; vector children}`。 - 以 `parentId` 归并;`parentId` 为空或不在集合内(孤儿)的节点为根层。 @@ -170,14 +183,17 @@ public: public slots: void switchWorkspace(const QString& tenantId); // 切空间→重载项目→重载结构 void switchProject(const QString& projectId); // 切项目→重载结构(清 DS/详情) - void selectTm(const QString& tmObjectId); // 选 TM→拉其 DS + void selectTm(const QString& tmObjectId); // 选 TM→拉其 DS 首页(数据+文件) + void loadMoreData(); // 数据页签"加载更多"→下一页追加 + void loadMoreFiles(); // 文件页签"加载更多"→下一页追加 signals: void workspacesLoaded(const std::vector&, QString currentId); - void projectsLoaded(const std::vector&, QString currentId); + void projectsLoaded(const std::vector&, QString currentId, int total); // total 判断"全部项目"入口 // 发出项目名 + 扁平结构节点;建树(buildStructTree)在 ObjectTreePanel 内完成。 void structureLoaded(const QString& projectName, const std::vector&); - void datasetsLoaded(const QString& tmObjectId, const std::vector&); // 数据页签 - void filesLoaded(const QString& tmObjectId, const std::vector&); // 文件页签 + // total=总数、append=是否追加(加载更多 true / 首页 false)。 + void datasetsLoaded(const QString& tmObjectId, const std::vector&, int total, bool append); + void filesLoaded(const QString& tmObjectId, const std::vector&, int total, bool append); void loadFailed(const QString& stage, const QString& message); // 出错→UI 空/错状态 void busyChanged(bool busy); // 同步阻塞期间置 WaitCursor private: @@ -185,10 +201,13 @@ private: data::IProjectRepository& repo_; std::vector 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/首个)→ `listProjects`(选首个)→ `loadStructure`→发扁平节点。 +编排逻辑:`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 事件循环,快速二次点击可能重入并污染状态)。 @@ -196,17 +215,25 @@ private: ### 5.5 UI 层 `app`(被动视图,数据驱动) **`app/TopBar`** —— 由"自由函数返回静态 QWidget"升级为**数据驱动类**(QWidget 子类): -- `setWorkspaces(list, currentId)` / `setProjects(list, currentId)` 重建下拉项。 -- 信号 `workspaceSwitchRequested(QString id)` / `projectSwitchRequested(QString id)`。 -- 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。 -- `buildMenuBar` 不变(静态菜单本轮不接)。 +- `setWorkspaces(list, currentId)` / `setProjects(list, currentId, hasMore)` 重建下拉项;`hasMore` 时下拉底部加「全部项目…」。 +- `setProjectButtonText(name)` —— 弹窗切换项目后更新项目按钮文字。 +- 信号 `workspaceSwitchRequested(id)` / `projectSwitchRequested(id)` / `allProjectsRequested()`(打开项目弹窗)。 +- 工作空间/项目下拉用互斥 `QActionGroup`(避免"多选"),选中即更新按钮文字。 +- 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。`buildMenuBar` 不变。 **`app/panels/ObjectTreePanel`**(新增)—— 被动:`setStructure(projectName, vector)` 内部调 `dto::buildStructTree` 重建 `QTreeWidget`(项目根→GS→TM,叶子=TM 可勾选、`UserRole` 存 tmObjectId); `showMessage(msg)` 显示空/错占位。信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)` (后者为前瞻钩子,本轮无消费者)。 -**`app/panels/DatasetListPanel`** —— `datasetsLoaded`→`populateDatasetList`(数据:dsName+类型名);`filesLoaded`→`populateFileList`(文件:文件名+可读大小,url 存角色备下载);空时占位。列表去隔行变色,改细分割线。 +**`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 接口"。渲染代码保留。 @@ -228,8 +255,12 @@ private: 切项目: TopBar.projectSwitchRequested(id) → controller.switchProject: loadStructure(id) → emit structureLoaded;清空 DS 列表/详情占位 +全部项目: TopBar.allProjectsRequested → main 打开 ProjectListDialog(pageProjects 分页 + 名称/类型过滤) + → 点项目名 → projectChosen(id,name) → nav.switchProject(id) + TopBar.setProjectButtonText(name) → 关弹窗 + 选 TM: ObjectTreePanel.tmClicked(tmObjectId) - → controller.selectTm: loadTmRows(pid,tm,3)+loadTmRows(pid,tm,1) → emit datasetsLoaded + filesLoaded → 数据/文件页签 + → 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 → 中央/详情显示占位"待接入"(本轮不渲染真实数据) ``` @@ -306,6 +337,7 @@ void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, - `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) **改造**