Compare commits

..

No commits in common. "045bb3cc1f523203d4f79ee1470c83129ecd7abe" and "890a3b95d9d9f08a4f6cfa9047d4a8ea0f0a9581" have entirely different histories.

27 changed files with 321 additions and 3569 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -12,5 +12,4 @@ add_subdirectory(core)
add_subdirectory(data)
add_subdirectory(net)
add_subdirectory(render)
add_subdirectory(controller)
add_subdirectory(app)

View File

@ -22,10 +22,7 @@ add_executable(geopro_desktop WIN32
Credential.cpp
login/LoginWindow.cpp
panels/AnomalyListPanel.cpp
panels/DatasetListPanel.cpp
panels/ObjectTreePanel.cpp
CentralScene.cpp
ProjectListDialog.cpp)
panels/DatasetListPanel.cpp)
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
# QtKeychain FetchContent target / export
@ -41,7 +38,6 @@ target_link_libraries(geopro_desktop PRIVATE
geopro_data # Phase 2 / /
geopro_net # Phase 3 + RSA + login2
geopro_render # Phase 4render Scene / GridContourActor /
geopro_controller # Phase 5WorkbenchNavController
)
vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES})

View File

@ -1,44 +0,0 @@
#include "CentralScene.hpp"
#include <vtkActor.h>
#include <vtkRenderWindow.h>
#include <vtkRenderer.h>
#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<SectionInput>& 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

View File

@ -1,31 +0,0 @@
#pragma once
#include <vector>
#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 };
// 一个待渲染剖面grid2D 测线 / 3D 帘面都用)+ colorScale3D 帘面上色)。
struct SectionInput {
geopro::core::Grid grid;
geopro::core::ColorScale colorScale;
};
// 中央场景重建(脱离对象树,按显式 sections 渲染):
// 2D = 每个 section 的 buildSurveyLine3D = 每个 section 的 buildCurtain受 showCurtain
// 下一轮接真实 DS构建 sections 后调用本函数即可render 层零改动。
void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
vtkRenderWindow* renderWindow, ViewMode mode,
const std::vector<SectionInput>& sections, bool showCurtain,
const geopro::core::GeoLocalFrame& frame, double verticalExaggeration);
} // namespace geopro::app

View File

@ -1,160 +0,0 @@
#include "ProjectListDialog.hpp"
#include <QAbstractItemView>
#include <QColor>
#include <QComboBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QStringList>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QVBoxLayout>
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<int>(rows.size()));
for (int i = 0; i < static_cast<int>(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

View File

@ -1,39 +0,0 @@
#pragma once
#include <QDialog>
#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

View File

@ -127,10 +127,12 @@ QWidget* buildMenuBar(QWidget* parent)
return mb;
}
TopBar::TopBar(QWidget* parent) : QWidget(parent) {
setObjectName(QStringLiteral("appToolBar"));
setFixedHeight(56);
setStyleSheet(QStringLiteral(
QWidget* buildTopToolBar(QWidget* parent)
{
auto* bar = new QWidget(parent);
bar->setObjectName(QStringLiteral("appToolBar"));
bar->setFixedHeight(56);
bar->setStyleSheet(QStringLiteral(
"#appToolBar { background:#FFFFFF; border-bottom:1px solid #E1E6EE; }"
"#topDivider { color:#E1E6EE; }"
"#wsSwitcher { color:#1F2A3D; border:none; border-radius:8px; padding:8px 12px;"
@ -144,56 +146,81 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) {
"#userName { color:#1F2A3D; font-size:13px; font-weight:600; }"
"#userRole { color:#8A93A3; font-size:11px; }"));
auto* lay = new QHBoxLayout(this);
auto* lay = new QHBoxLayout(bar);
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_);
// ── 工作空间切换(最左):显示当前空间,点击下拉切换 ──
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);
lay->addSpacing(10);
lay->addWidget(makeDivider(this));
lay->addWidget(makeDivider(bar));
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_);
// ── 项目选择器(与工作空间切换同款样式:无边框 + 图标 + 文本 + 下拉)──
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);
lay->addStretch();
lay->addWidget(makeIconButton(this, Glyph::Help, QStringLiteral("帮助")));
lay->addWidget(makeIconButton(this, Glyph::Bell, QStringLiteral("通知")));
lay->addWidget(makeIconButton(this, Glyph::Gear, QStringLiteral("设置")));
// ── 右侧:帮助 / 通知 / 设置(仅图标,悬停显示文本)──
lay->addWidget(makeIconButton(bar, Glyph::Help, QStringLiteral("帮助")));
lay->addWidget(makeIconButton(bar, Glyph::Bell, QStringLiteral("通知")));
lay->addWidget(makeIconButton(bar, Glyph::Gear, QStringLiteral("设置")));
lay->addSpacing(10);
lay->addWidget(makeDivider(this));
lay->addWidget(makeDivider(bar));
lay->addSpacing(12);
// 用户区(本轮静态)。
auto* avatar = new QLabel(QStringLiteral("ZL"), this);
// ── 用户:圆形头像 + 姓名/职务 ──
auto* avatar = new QLabel(QStringLiteral("ZL"), bar);
avatar->setObjectName(QStringLiteral("avatar"));
avatar->setFixedSize(34, 34);
avatar->setAlignment(Qt::AlignCenter);
lay->addWidget(avatar);
lay->addSpacing(8);
auto* userBox = new QWidget(this);
auto* userBox = new QWidget(bar);
auto* userLay = new QVBoxLayout(userBox);
userLay->setContentsMargins(0, 0, 0, 0);
userLay->setSpacing(0);
@ -204,76 +231,8 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) {
userLay->addWidget(userName);
userLay->addWidget(userRole);
lay->addWidget(userBox);
}
void TopBar::setWorkspaces(const std::vector<data::Workspace>& list, const QString& currentId) {
auto* menu = new QMenu(wsBtn_);
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);
const QString name = QString::fromStdString(w.name);
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, name]() {
wsBtn_->setText(name + QStringLiteral("")); // 立即反馈
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<data::ProjectSummary>& list, const QString& currentId,
bool hasMore) {
auto* menu = new QMenu(projBtn_);
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);
const QString name = QString::fromStdString(p.name);
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, name]() {
projBtn_->setText(name + QStringLiteral(""));
emit projectSwitchRequested(id);
});
}
if (list.empty()) {
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(""));
return bar;
}
} // namespace geopro::app

View File

@ -1,34 +1,19 @@
#pragma once
#include <QWidget>
#include <vector>
#include "repo/RepoTypes.hpp"
class QToolButton;
// 顶部应用区(对齐原型,静态视觉壳):
// - buildMenuBar最上方的菜单栏视图 / 项目管理 / 业务工具 / 设备,含多级子菜单)。
// - buildTopToolBar菜单栏下方的工具条工作空间切换 + 项目选择 + 帮助/通知/设置 + 用户)。
// 调用方将两者纵向堆叠后经 QMainWindow::setMenuWidget 挂到主窗口顶部。
// 菜单/按钮当前为静态占位,后续接真实页面与数据。
class QWidget;
namespace geopro::app {
// 顶部菜单栏(静态,本轮不接真实页面)。
QWidget* buildMenuBar(QWidget* parent);
// 顶部菜单栏(返回 QWidget*,内部是 QMenuBar调用方放在最上一行)。
QWidget* buildMenuBar(QWidget* parent = nullptr);
// 顶部工具条:数据驱动的工作空间/项目切换器 + 右侧图标 + 用户区。
class TopBar : public QWidget {
Q_OBJECT
public:
explicit TopBar(QWidget* parent = nullptr);
void setWorkspaces(const std::vector<data::Workspace>& list, const QString& currentId);
void setProjects(const std::vector<data::ProjectSummary>& 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;
QToolButton* projBtn_ = nullptr;
};
// 菜单栏下方的工具条(工作空间/项目/帮助/通知/设置/用户)。
QWidget* buildTopToolBar(QWidget* parent = nullptr);
} // namespace geopro::app

View File

@ -62,11 +62,6 @@
#include "PanelHeader.hpp"
#include "Theme.hpp"
#include "TopBar.hpp"
#include "CentralScene.hpp"
#include "ProjectListDialog.hpp"
#include "WorkbenchNavController.hpp"
#include "api/ApiProjectRepository.hpp"
#include "panels/ObjectTreePanel.hpp"
#include "login/LoginWindow.hpp"
#include "panels/AnomalyListPanel.hpp"
#include "panels/DatasetListPanel.hpp"
@ -102,6 +97,41 @@
namespace {
// 角色:树 TM 项存 tmId(UserRole+2);数据列表 DS 项的 dsId/ddType 由 panels/DatasetListPanel 定义。
constexpr int kRoleTmId = Qt::UserRole + 2;
// 从对象结构树构建 QTreeWidgetGS → TM 两层对齐原型DS=采集批次在左下「数据列表」,不进树)。
// TM(测线) 项可勾选(复选框):勾选驱动该测线的 dd_section 在中央场景显示UserRole+2 存 tmId。
// 含 dd_section 的测线默认勾选,启动即显示。
void populateTree(QTreeWidget* tree, const std::vector<geopro::data::GsNode>& 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<geopro::data::GsNode>& 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)
{
@ -122,7 +152,7 @@ double median(std::vector<double> v)
}
// 当前中央视图(默认二维地图)。二维地图=测线红线俯视;三维视图=断面墙。
using geopro::app::ViewMode;
enum class ViewMode { Map2D, View3D };
// 数据详情显示内容(默认网格数据)。网格数据=#18 banded原数据=#17 散点(对齐原型命名)。
enum class DetailMode { Section18, Scatter17 };
@ -141,9 +171,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)
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo)
{
// ── 世界系:启动取一次 grid1 的 lat/lon用中位数作 GeoLocalFrame 原点 ──
// 全项目共享shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。
@ -266,11 +294,6 @@ 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);
@ -331,26 +354,44 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 放在中央视图下方。
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
// 左上 dock对象树真实结构项目根 → GS → TM。被动视图数据由控制器推送。
auto* objectTree = new geopro::app::ObjectTreePanel();
// 项目结构(GS→TM→DS):取一次共享,供树/中央/数据列表查 TM 的数据集。
auto structure = std::make_shared<std::vector<geopro::data::GsNode>>(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));
}
auto* leftDock = new ads::CDockWidget(QStringLiteral("对象显示栏"));
leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象显示栏"),
objectTree,
leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象显示栏"), tree,
{{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}}));
auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
// 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
auto* datasetTabs = new QTabWidget();
auto* datasetList = new QListWidget();
// 简洁分割:去隔行变色,改为 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; }"));
datasetList->setAlternatingRowColors(true);
datasetTabs->addTab(datasetList, QStringLiteral("数据"));
auto* fileList = new QListWidget();
fileList->setStyleSheet(datasetList->styleSheet()); // 与数据页签同款简洁分割
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);
}
datasetTabs->addTab(fileList, QStringLiteral("文件"));
auto* datasetDock = new ads::CDockWidget(QStringLiteral("数据真实显示栏"));
auto* datasetBox = wrapWithHeader(
@ -400,14 +441,128 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
if (auto* bar = area->titleBar()) bar->setVisible(false);
}
// 中央编排已解耦到 CentralScene::rebuildCentralScene数据驱动。本轮空 sections → 空背景占位。
// 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活。
auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() {
geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode,
std::vector<geopro::app::SectionInput>{}, *showCurtain,
*frame, kVerticalExaggeration);
// ── 中央视图重建(核心)─────────────────────────────────────────────
// 按勾选的测线(TM)整体重建scene.clear() → 对每个勾选 TM 的 dd_section 加对应 actor。
// 二维地图 = buildSurveyLine红线俯视浅底背景+ applyTop2D。
// 三维视图 = buildCurtain断面墙SetScale(1,1,kVerticalExaggeration) + 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, kVerticalExaggeration); // 纵向夸张成墙
scene->addActor(curtain);
}
}
};
// 遍历对象树收集所有勾选的测线(TM),渲染其 dd_section 数据集(可多条共存)。
QList<QTreeWidgetItem*> 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=kVerticalExaggeration),使体绘制/切片/帘面纵向一致。
auto vr = geopro::render::buildVoxelFromScatters(profs, vcs, *crs, *frame, 1.0, 0.5, 2.0,
4.0, kVerticalExaggeration);
if (vr.valid()) {
if (*showVoxel) {
rendererPtr->AddVolume(vr.volume); // 夸张已烤进 image无需 actor SetScale
}
vtkRenderWindowInteractor* interactor = renderWindowPtr->GetInteractor();
if (*showSlice && interactor) {
const std::vector<double> 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<vtkImagePlaneWidget>::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();
};
// 勾选/取消某测线(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<int>(tm->dss.size())));
});
// ── 数据详情共享状态 + 重建 ──────────────────────────────────────────
// 当前选中数据集 id空=未选)与详情显示模式(反演剖面/原数据);切模式或换选中都重建。
auto currentDsId = std::make_shared<QString>();
@ -503,21 +658,16 @@ 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 接口)──
// ── 单击左下数据列表的采集批次(DS) → 加载到数据详情/异常/属性 ──
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
[propLabel, detailRendererPtr, detailRenderWindowPtr, &nav](QListWidgetItem* item) {
if (item->data(geopro::app::kDsLoadMoreRole).toBool()) {
nav.loadMoreData();
return;
}
[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; // 仅剖面网格有详情图
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(dsId, name);
});
// ── 异常列表勾选(显隐) → 更新隐藏集 → 重建数据详情 ──
@ -608,122 +758,45 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
rebuildCentral();
});
// ── 启动:建立一次空背景中央视图(真实 sections 数据由下一轮接入)
// ── 启动默认:测线已勾选,但 itemChanged 在 connect 之前触发故未渲染;这里重建一次中央内容
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<int>(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));
topBar = new geopro::app::TopBar(topChrome);
topLayout->addWidget(topBar);
topLayout->addWidget(geopro::app::buildTopToolBar(topChrome));
window.setMenuWidget(topChrome);
}
// ── 控制器 ↔ 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,
&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);
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::workspacesLoaded, topBar,
[topBar](const std::vector<geopro::data::Workspace>& list, const QString& cur) {
topBar->setWorkspaces(list, cur);
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar,
[topBar](const std::vector<geopro::data::ProjectSummary>& list,
const QString& cur, int total) {
topBar->setProjects(list, cur, total > static_cast<int>(list.size()));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree,
[objectTree, datasetList, fileList, datasetTitle, datasetTabs](
const QString& projectName,
const std::vector<geopro::data::StructNode>& nodes) {
objectTree->setStructure(projectName, nodes);
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,
[removeLoadMore, addLoadMore, datasetList, datasetTitle, datasetTabs](
const QString&, const std::vector<geopro::data::DsRow>& 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, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total)
: QStringLiteral("数据"));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::filesLoaded, fileList,
[removeLoadMore, addLoadMore, fileList, datasetTabs](
const QString&, const std::vector<geopro::data::DsRow>& rows, int total,
bool append) {
removeLoadMore(fileList);
geopro::app::populateFileList(fileList, rows, append);
const int loaded = addLoadMore(fileList, total);
datasetTabs->setTabText(
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) {
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")
@ -809,19 +882,13 @@ 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, projectRepo, nav);
buildWorkbench(window, repo);
window.show();
nav.start(); // 进入工作台后拉真实 空间/项目/结构
return app.exec();
}

View File

@ -1,6 +1,5 @@
#include "panels/DatasetListPanel.hpp"
#include <QColor>
#include <QListWidget>
#include <QListWidgetItem>
#include <QString>
@ -8,48 +7,30 @@
namespace geopro::app {
namespace {
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);
// dd 类型 → 中文标注。
QString ddTypeLabel(const std::string& ddType)
{
if (ddType == "dd_section") return QStringLiteral("剖面网格");
if (ddType == "dd_voxel") return QStringLiteral("体素");
return QString::fromStdString(ddType);
}
} // namespace
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) {
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsNode>& dss)
{
if (!list) return;
if (!append) list->clear();
for (const auto& d : rows) {
QString text = QString::fromStdString(d.dsName);
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));
}
}
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);
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) {
if (!list) return;
if (!append) list->clear();
if (!append && 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);
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));
item->setData(kDsIdRole, QString::fromStdString(ds.id));
item->setData(kDsDdTypeRole, QString::fromStdString(ds.ddType));
}
}

View File

@ -8,14 +8,12 @@ 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 kDsFileUrlRole = 0x0102; // Qt::UserRole + 2文件下载 url备用
constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行
constexpr int kDsIdRole = 0x0100; // Qt::UserRole
constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1
// 数据页签:每条 = dsName +类型名UserRole 存 dsId、+1 存 ddCode
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append);
// 文件页签:每条 = 文件名 +可读大小UserRole 存 dsId、+2 存文件 url。空时显示占位
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append);
// 用某测线(TM)的数据集(采集批次)填充 QListWidget对齐原型左下「数据真实显示栏」
// 每条目 = 名称 +ddType 标注UserRole 存 dsId、+1 存 ddType供单击驱动数据详情
// 清空旧条目后重填
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsNode>& dss);
} // namespace geopro::app

View File

@ -1,93 +0,0 @@
#include "panels/ObjectTreePanel.hpp"
#include <QColor>
#include <QLabel>
#include <QSignalBlocker>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QVBoxLayout>
#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<data::dto::StructTreeNode>& 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<data::StructNode>& 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);
addNodes(tree_->invisibleRootItem(), roots); // 结构已含项目根节点,直接渲染
tree_->expandAll();
}
void ObjectTreePanel::showMessage(const QString& message) {
tree_->clear();
tree_->setVisible(false);
hint_->setText(message);
hint_->setVisible(true);
}
} // namespace geopro::app

View File

@ -1,31 +0,0 @@
#pragma once
#include <QWidget>
#include <vector>
#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<data::StructNode>& nodes);
void showMessage(const QString& message); // 错误/空状态占位
signals:
void tmClicked(const QString& tmObjectId);
// 前瞻钩子:勾选驱动中央渲染留待下一轮接真实 DS本轮暂无消费者
void tmCheckToggled(const QString& tmObjectId, bool checked);
private:
QTreeWidget* tree_ = nullptr;
QLabel* hint_ = nullptr;
};
} // namespace geopro::app

View File

@ -1,7 +0,0 @@
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)

View File

@ -1,153 +0,0 @@
#include "WorkbenchNavController.hpp"
namespace geopro::controller {
using data::ProjectSummary;
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() {
if (busy_) return;
BusyGuard guard(this, &busy_);
const auto ws = repo_.listWorkspaces();
if (!ws.ok) {
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();
}
void WorkbenchNavController::loadProjectsAndStructure() {
const auto ps = repo_.pageProjects(std::string(), std::string(), 1, 10); // 下拉首页 10
if (!ps.ok) {
emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error));
return;
}
lastProjects_ = ps.value.rows;
QString curP;
if (!ps.value.rows.empty()) {
const auto& first = ps.value.rows.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.rows, curP, ps.value.total);
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() || busy_) return;
BusyGuard guard(this, &busy_);
const auto r = repo_.switchWorkspace(tenantId.toStdString());
if (!r.ok) {
emit loadFailed(QStringLiteral("switchWorkspace"), QString::fromStdString(r.error));
return;
}
currentWorkspaceId_ = tenantId.toStdString();
loadProjectsAndStructure();
}
void WorkbenchNavController::switchProject(const QString& projectId) {
if (projectId.isEmpty() || busy_) return;
BusyGuard guard(this, &busy_);
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 loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error));
return;
}
emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);
}
void WorkbenchNavController::selectTm(const QString& tmObjectId) {
if (tmObjectId.isEmpty() || busy_) return;
BusyGuard guard(this, &busy_);
currentTmId_ = tmObjectId.toStdString();
const std::string pid = currentProjectId_;
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;
}
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;
}
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

View File

@ -1,54 +0,0 @@
#pragma once
#include <QObject>
#include <QString>
#include <string>
#include <vector>
#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);
void loadMoreData();
void loadMoreFiles();
signals:
void busyChanged(bool busy);
void workspacesLoaded(const std::vector<geopro::data::Workspace>& list, const QString& currentId);
void projectsLoaded(const std::vector<geopro::data::ProjectSummary>& list,
const QString& currentId, int total);
void structureLoaded(const QString& projectName, const std::vector<geopro::data::StructNode>& nodes);
void datasetsLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsRow>& rows,
int total, bool append);
void filesLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsRow>& rows,
int total, bool append);
void loadFailed(const QString& stage, const QString& message);
private:
void loadProjectsAndStructure(); // start + switchWorkspace 共用
data::IProjectRepository& repo_;
bool busy_ = false;
std::vector<data::ProjectSummary> 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

View File

@ -2,10 +2,8 @@ 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)
repo/LocalSampleRepository.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_link_libraries(geopro_data PUBLIC geopro_core 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)

View File

@ -1,94 +0,0 @@
#include "api/ApiProjectRepository.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <QString>
#include <QUrl>
#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;
}
// 后端 id 进 URL 前做百分号编码(不可信外部数据:防 ? # & / 空格 破坏路径/查询)。
QString enc(const std::string& s) {
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
}
} // namespace
ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {}
RepoResult<std::vector<Workspace>> 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<bool> ApiProjectRepository::switchWorkspace(const std::string& tenantId) {
const QString path =
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<ProjectListPage> 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<std::vector<ProjectType>> 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<std::vector<StructNode>> ApiProjectRepository::loadStructure(const std::string& projectId) {
// 项目结构(项目根 + 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("value")).toArray()), {}};
}
RepoResult<DsPage> 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{
{QStringLiteral("projectId"), QString::fromStdString(projectId)},
{QStringLiteral("structParentId"), QString::fromStdString(tmObjectId)},
{QStringLiteral("structParentConfType"), 2},
{QStringLiteral("classifyTypeList"), QJsonArray{classifyType}},
{QStringLiteral("pageNo"), pageNo},
{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), {}};
}
} // namespace geopro::data

View File

@ -1,26 +0,0 @@
#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<std::vector<Workspace>> listWorkspaces() override;
RepoResult<bool> switchWorkspace(const std::string& tenantId) override;
RepoResult<ProjectListPage> pageProjects(const std::string& nameFilter, const std::string& typeId,
int pageNo, int pageSize) override;
RepoResult<std::vector<ProjectType>> listProjectTypes() override;
RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) override;
RepoResult<DsPage> loadTmRows(const std::string& projectId, const std::string& tmObjectId,
int classifyType, int pageNo) override;
private:
net::ApiClient& api_;
};
} // namespace geopro::data

View File

@ -1,157 +0,0 @@
#include "dto/NavDto.hpp"
#include <QJsonValue>
#include <functional>
#include <set>
namespace geopro::data::dto {
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.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;
}
} // namespace
std::vector<Workspace> parseWorkspaces(const QJsonArray& arr) {
std::vector<Workspace> out;
out.reserve(static_cast<size_t>(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& data) {
ProjectPage page;
page.hasNextPage = data.value(QStringLiteral("hasNextPage")).toBool();
const QJsonArray list = data.value(QStringLiteral("projectList")).toArray();
page.projects.reserve(static_cast<size_t>(list.size()));
for (const QJsonValue& v : list) page.projects.push_back(parseProjectItem(v.toObject()));
return page;
}
std::vector<ProjectSummary> parseProjectList(const QJsonArray& arr) {
std::vector<ProjectSummary> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) out.push_back(parseProjectItem(v.toObject()));
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<ProjectType> parseProjectTypes(const QJsonArray& arr) {
std::vector<ProjectType> out;
out.reserve(static_cast<size_t>(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<StructNode> parseStructNodes(const QJsonArray& arr) {
std::vector<StructNode> out;
out.reserve(static_cast<size_t>(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<DsRow> parseDsRows(const QJsonArray& arr) {
std::vector<DsRow> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
DsRow d;
d.id = str(o, "id");
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");
d.fileSize = static_cast<long long>(f.value(QStringLiteral("size")).toDouble());
out.push_back(std::move(d));
}
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<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat) {
// 过滤 DS(type==3)DS 不进对象树(按 TM 单独拉取到数据列表)。
std::vector<StructNode> nodes;
nodes.reserve(flat.size());
for (const auto& n : flat)
if (n.type != 3) nodes.push_back(n);
std::set<std::string> ids;
for (const auto& n : nodes) ids.insert(n.id);
// 根层parentId 为空 / "0" / 不在集合内(孤儿)。
auto isRootLevel = [&](const StructNode& n) {
return n.parentId.empty() || n.parentId == "0" || ids.find(n.parentId) == ids.end();
};
std::set<std::string> visited; // 防环:每个 id 最多进树一次。
std::function<std::vector<StructTreeNode>(const std::string&, bool)> build =
[&](const std::string& parentId, bool root) {
std::vector<StructTreeNode> out;
for (const auto& n : nodes) {
const bool belongs = root ? isRootLevel(n) : (n.parentId == parentId);
if (!belongs) continue;
if (visited.count(n.id)) continue;
visited.insert(n.id);
StructTreeNode t;
t.node = n;
t.isTm = (n.type == 2); // type: 1=项目根 2=TM(测线) 3=DS(已过滤)
t.children = build(n.id, false);
out.push_back(std::move(t));
}
return out;
};
return build(std::string(), true);
}
} // namespace geopro::data::dto

View File

@ -1,41 +0,0 @@
#pragma once
#include <vector>
#include <QJsonArray>
#include <QJsonObject>
#include "repo/RepoTypes.hpp"
namespace geopro::data::dto {
// 工作空间数组joined/list 的 data["value"])→ 模型。isCurTenant==1 → isCurrent。
std::vector<Workspace> parseWorkspaces(const QJsonArray& arr);
// 项目分页queryByUser 的 data 对象 {hasNextPage, projectList})→ 模型。
struct ProjectPage { std::vector<ProjectSummary> projects; bool hasNextPage = false; };
ProjectPage parseProjects(const QJsonObject& data);
// my/profile/queryProject 的 data["value"] 数组 → 模型(与 parseProjects 同字段映射)。
std::vector<ProjectSummary> parseProjectList(const QJsonArray& arr);
// my/profile/project/page 的整个 data 对象 → ProjectListPagerows + total
ProjectListPage parseProjectPage(const QJsonObject& data);
// project/type/list 的 data["value"] 数组 → 项目类型列表。
std::vector<ProjectType> parseProjectTypes(const QJsonArray& arr);
// 结构扁平节点数组queryProjectStruct 的 data["projectStructList"])→ 模型。
std::vector<StructNode> parseStructNodes(const QJsonArray& arr);
// data/page / file/page 的 data["list"] 数组 → DsRow数据行无 file文件行含 file{name,size,url})。
std::vector<DsRow> parseDsRows(const QJsonArray& arr);
// data/page 或 file/page 的整个 data 对象 → DsPagerows + total
DsPage parseDsPage(const QJsonObject& data);
// 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理项目直挂 TM、孤儿 parentId、空表。
struct StructTreeNode {
StructNode node;
bool isTm = false;
std::vector<StructTreeNode> children;
};
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat);
} // namespace geopro::data::dto

View File

@ -1,35 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include "repo/RepoTypes.hpp"
namespace geopro::data {
// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。
template <class T>
struct RepoResult {
bool ok = false;
T value{};
std::string error;
};
// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。
class IProjectRepository {
public:
virtual ~IProjectRepository() = default;
virtual RepoResult<std::vector<Workspace>> listWorkspaces() = 0;
virtual RepoResult<bool> switchWorkspace(const std::string& tenantId) = 0;
// 项目分页nameFilter 名称模糊可空、typeId 类型过滤(空=不限、pageNo 从 1 起。
virtual RepoResult<ProjectListPage> pageProjects(const std::string& nameFilter,
const std::string& typeId, int pageNo,
int pageSize) = 0;
// 项目类型列表(弹窗类型过滤下拉)。
virtual RepoResult<std::vector<ProjectType>> listProjectTypes() = 0;
virtual RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) = 0;
// 按 TM 分页拉数据/文件行classifyType 3=数据 1=文件pageNo 从 1 起pageSize 固定 5。
virtual RepoResult<DsPage> loadTmRows(const std::string& projectId,
const std::string& tmObjectId, int classifyType,
int pageNo) = 0;
};
} // namespace geopro::data

View File

@ -3,33 +3,7 @@
#include <vector>
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, createTime;
std::string fileName, fileUrl;
long long fileSize = 0;
};
struct DsPage { std::vector<DsRow> rows; int total = 0; };
struct TmNode { std::string id, name, confCode; std::vector<DsNode> dss; };
struct GsNode { std::string id, name; std::vector<TmNode> tms; };
struct Project { std::string id, name; std::vector<GsNode> gss; };
// 工作空间(=企业租户/空间。ownerType: 1 个人空间 2 企业空间。
struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; };
// 项目摘要(下拉/弹窗列表用。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<ProjectSummary> rows; int total = 0; };
// 项目结构扁平节点(仅 GS / TM。客户端按 parentId 建树,叶子=TM。
struct StructNode { std::string id, name, parentId, typeName, confCode; int type = 0; };
} // namespace geopro::data

View File

@ -36,7 +36,6 @@ 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

View File

@ -1,203 +0,0 @@
#include <gtest/gtest.h>
#include <QByteArray>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#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);
}
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, BuildStructTreeNestsGsTmAndDirectTm) {
const std::vector<StructNode> 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<StructNode> 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());
}
TEST(NavDto, BuildStructTreeHandlesCycleWithoutInfiniteRecursion) {
// 不可信数据:重复 id 形成可达环R→X→Y→重复X…。必须终止、不崩。
const std::vector<StructNode> 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");
}
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());
}
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<StructNode> 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、T2D1 被过滤)
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 不进树
}
TEST(NavDto, ParseDsRowsDataAndFile) {
const auto d = dto::parseDsRows(arrOf(R"([
{"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"([
{"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");
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");
}
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, "全量类型");
}