feat/real-api-navigation #2
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,392 @@
|
||||||
|
# 接入真实导航(工作空间 / 项目 / 对象树)— 设计文档
|
||||||
|
|
||||||
|
- 日期: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 → TM);TM 在左下"数据真实显示栏"列出其 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 打开 ProjectListDialog(pageProjects 分页 + 名称/类型过滤)
|
||||||
|
→ 点项目名 → 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 };
|
||||||
|
|
||||||
|
// 一个待渲染剖面:grid(2D 测线 / 3D 帘面都用)+ colorScale(3D 帘面上色用)。
|
||||||
|
struct SectionInput {
|
||||||
|
geopro::core::Grid grid;
|
||||||
|
geopro::core::ColorScale colorScale;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 中央场景重建(脱离对象树,按显式 sections 渲染):
|
||||||
|
// 2D = 每个 section 的 buildSurveyLine(红线俯视);
|
||||||
|
// 3D = 每个 section 的 buildCurtain(断面墙,受 showCurtain 开关 + 纵向夸张)。
|
||||||
|
void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
|
||||||
|
vtkRenderWindow* renderWindow, ViewMode mode,
|
||||||
|
const std::vector<SectionInput>& sections, bool showCurtain,
|
||||||
|
const geopro::core::GeoLocalFrame& frame, double verticalExaggeration);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **本轮**:`main.cpp` 用**空 `sections`** 调用该 helper → 中央为空背景(占位)。视图 2D/3D 切换、帘面勾选仍走它,只是无内容。
|
||||||
|
- **下一轮**:`main.cpp` 用真实 DS 数据构建 `std::vector<SectionInput>` 再调同一 helper —— 编排零改动。
|
||||||
|
- **`rebuildDetail`(数据详情:#18/#17/异常/电极)**:保留在 `main.cpp`(暂不触发),下一轮改触发条件即复活。
|
||||||
|
- **体素 / 切片 / 地形**:是 demo 专属派生层(来自两条交叉本地剖面散点 / 本地 DEM),**不绑定单个 DS**,
|
||||||
|
不纳入 `rebuildCentralScene`。本轮移除其 `main.cpp` 内联编排(`render/` 函数保留),其"视图详情"勾选项
|
||||||
|
本轮置灰并提示"(下一轮接入)"。它们的复活属独立未来工作(需真实体素/地形数据源),见 §12.1。
|
||||||
|
|
||||||
|
## 9. 测试策略
|
||||||
|
依既有无测试桩 + 依赖 live 服务器的现实,聚焦**纯逻辑单测**(GoogleTest + CTest):
|
||||||
|
- `dto/NavDto` 映射:喂样本 JSON(取自 OpenAPI example / 手造)验证
|
||||||
|
`parseWorkspaces / parseProjects / parseStructNodes / parseDsRows` 字段与 `name→typeName`、`isCurTenant→isCurrent`。
|
||||||
|
- `buildStructTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS)、孤儿 parentId、空列表、防环 等场景。
|
||||||
|
- 不做 live 集成 / E2E(无桩、依赖真实后端)。控制器/UI 信号联动靠手动联调验证。
|
||||||
|
- 目标:纯逻辑文件(dto + tree builder)覆盖率优先达标;UI/网络 IO 不计入。
|
||||||
|
|
||||||
|
## 10. 线程 / 性能
|
||||||
|
- 同步阻塞 UI 线程(`ApiClient` 用 QEventLoop)+ `busyChanged` 置 `Qt::WaitCursor`,与现有登录一致。
|
||||||
|
- 切空间/项目可能稍慢但可接受(MVP)。异步(QFuture/取消)留 M1.5,届时 `IProjectRepository` 契约可平滑改造。
|
||||||
|
|
||||||
|
## 11. 文件清单
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
- `src/data/repo/IProjectRepository.hpp`(含 `RepoResult<T>`、导航模型 `Workspace/ProjectSummary/StructNode`,或拆 `NavTypes.hpp`)
|
||||||
|
- `src/data/api/ApiProjectRepository.{hpp,cpp}`
|
||||||
|
- `src/data/dto/NavDto.{hpp,cpp}`
|
||||||
|
- `src/controller/WorkbenchNavController.{hpp,cpp}`
|
||||||
|
- `src/app/panels/ObjectTreePanel.{hpp,cpp}`(若不抽,则树构建函数留在 main,但 TopBar 必抽)
|
||||||
|
- `src/app/CentralScene.{hpp,cpp}`(中央三维编排的数据驱动 helper,见 §8.1)
|
||||||
|
- `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、接线信号;移除启动自动渲染 demo;DS 点击改占位
|
||||||
|
- 各层 `CMakeLists.txt` — 新增源文件 + `controller` 目标接入构建;controller 需 `Q_OBJECT`(AUTOMOC ON)
|
||||||
|
|
||||||
|
**保留不删**:`LocalSampleRepository`、`render/*`、`VoxelFromScatters`、现有详情/中央渲染代码。
|
||||||
|
|
||||||
|
## 12. 未决 / 下一轮(概览)
|
||||||
|
- dd/ert/gpr 真实剖面/反演/雷达数据渲染(替换占位)—— 详见 §12.1。
|
||||||
|
- 项目 `crsCode` 替换硬编码 `EPSG:4547`,重建 `GeoLocalFrame`。
|
||||||
|
- 异步仓储(QFuture + 取消 + 分页"加载更多")。
|
||||||
|
- 用户头像/姓名接 `auth/getUserInfo`;token 过期自动跳登录。
|
||||||
|
- 顶部菜单(视图/项目管理/业务工具/设备)接真实页面。
|
||||||
|
|
||||||
|
### 12.1 下一轮:对接真实 DS 数据要做什么
|
||||||
|
|
||||||
|
本轮已把导航与渲染解耦、并保留 render 层与数据驱动 helper(§8.1)。下一轮把"占位"换成真实剖面/反演,
|
||||||
|
**render 层函数原样复用**,只需补"取数 → 映射 → 接线 → 坐标系"四件事:
|
||||||
|
|
||||||
|
**A. 取数:新增 DS 内容仓储(接口/数据层)**
|
||||||
|
- 在 `IProjectRepository`(或新建 `IDatasetContentRepository`)补方法,按 DS 拉真实内容。候选接口:
|
||||||
|
- 反演网格(#18 来源):`GET /business/dd/ert/inversion/rows/{dsObjectId}`、`horizontal/rows/{dsObjectId}`、
|
||||||
|
`dynamic/form/{dsObjectId}`(按实际返回选定网格来源)。
|
||||||
|
- 原始散点(#17 来源):`GET /business/dd/ert/inversion/getErtRawDataScatterGraph/{dsObjectId}`
|
||||||
|
或 `dd/indicator/currentmethod/scatter/graph/{dsObjectId}`。
|
||||||
|
- 异常:`GET /business/exception/queryException/{dsId}`。
|
||||||
|
- 色阶:`GET /business/clr/colorGradation/queryCLRColorGradation/{projectId}` /
|
||||||
|
`lvlTemplate/queryLVLTemplate/{projectId}`。
|
||||||
|
- 在 `ApiProjectRepository` 实现;返回 `RepoResult<T>`。
|
||||||
|
|
||||||
|
**B. 映射:DTO → 现有 core 模型(数据层 `dto/`)**
|
||||||
|
- 把上述接口的 JSON 映射成**现有类型**:`core::Grid`、`core::ScatterField`、`core::ColorScale`、`core::Anomaly`。
|
||||||
|
(这是新增解析,每个 dd 接口形状不同;参考 `data/parse/SampleParsers` 对本地样本的映射约定:
|
||||||
|
`v` 为 `[j=y][i=x]`、east/north 名值约定、纵向夸张统一常量等。)
|
||||||
|
- 单测:喂样本 JSON 验证映射(沿用本轮 NavDto 测试套路)。
|
||||||
|
|
||||||
|
**C. 接线:复活中央 + 详情编排(UI 层,零改 render)**
|
||||||
|
- 中央三维:`controller.selectDataset(dsId)` → 取反演网格 + 色阶 → 构建 `std::vector<app::SectionInput>` →
|
||||||
|
调 **本轮已写好的** `app::rebuildCentralScene(...)`(§8.1)。勾选多 TM/DS 即多 section 共存。
|
||||||
|
- 数据详情:用真实 `Grid/ScatterField/ColorScale/Anomaly` 触发**本轮保留的** `rebuildDetail`(改其触发为真实数据)。
|
||||||
|
- 把本轮的"占位文案"替换为真实渲染调用。
|
||||||
|
|
||||||
|
**D. 坐标系:用真实项目 CRS**
|
||||||
|
- 用本轮控制器已存的 `currentCrsCode()` 重建 `GeoLocalFrame` 与 `CrsTransform`,替换 `main.cpp` 硬编码 `EPSG:4547`;
|
||||||
|
切项目时重建世界系,保证多视图配准。
|
||||||
|
|
||||||
|
**E.(可选)体素 / 切片 / 地形**
|
||||||
|
- 这些是不绑定单 DS 的派生层,需各自的真实数据源(多剖面体素插值 / DEM 影像服务)才能复活;
|
||||||
|
与 A–D 的"按 DS 渲染剖面"是独立工作,按需另起一轮。
|
||||||
|
```
|
||||||
|
|
@ -12,4 +12,5 @@ add_subdirectory(core)
|
||||||
add_subdirectory(data)
|
add_subdirectory(data)
|
||||||
add_subdirectory(net)
|
add_subdirectory(net)
|
||||||
add_subdirectory(render)
|
add_subdirectory(render)
|
||||||
|
add_subdirectory(controller)
|
||||||
add_subdirectory(app)
|
add_subdirectory(app)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ add_executable(geopro_desktop WIN32
|
||||||
Credential.cpp
|
Credential.cpp
|
||||||
login/LoginWindow.cpp
|
login/LoginWindow.cpp
|
||||||
panels/AnomalyListPanel.cpp
|
panels/AnomalyListPanel.cpp
|
||||||
panels/DatasetListPanel.cpp)
|
panels/DatasetListPanel.cpp
|
||||||
|
panels/ObjectTreePanel.cpp
|
||||||
|
CentralScene.cpp
|
||||||
|
ProjectListDialog.cpp)
|
||||||
|
|
||||||
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
# QtKeychain 经 FetchContent 接入,头文件不随 target 传播,显式加源/构建目录(含生成的 export 头)。
|
# QtKeychain 经 FetchContent 接入,头文件不随 target 传播,显式加源/构建目录(含生成的 export 头)。
|
||||||
|
|
@ -38,6 +41,7 @@ target_link_libraries(geopro_desktop PRIVATE
|
||||||
geopro_data # Phase 2:本地样本仓储(对象树 / 网格 / 色阶)
|
geopro_data # Phase 2:本地样本仓储(对象树 / 网格 / 色阶)
|
||||||
geopro_net # Phase 3:登录(验证码 + RSA + login2)
|
geopro_net # Phase 3:登录(验证码 + RSA + login2)
|
||||||
geopro_render # Phase 4:render 层(Scene / GridContourActor / 相机预设)
|
geopro_render # Phase 4:render 层(Scene / GridContourActor / 相机预设)
|
||||||
|
geopro_controller # Phase 5:导航编排(WorkbenchNavController)
|
||||||
)
|
)
|
||||||
|
|
||||||
vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES})
|
vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
#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 };
|
||||||
|
|
||||||
|
// 一个待渲染剖面: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<SectionInput>& sections, bool showCurtain,
|
||||||
|
const geopro::core::GeoLocalFrame& frame, double verticalExaggeration);
|
||||||
|
|
||||||
|
} // namespace geopro::app
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
#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
|
||||||
|
|
@ -127,12 +127,10 @@ QWidget* buildMenuBar(QWidget* parent)
|
||||||
return mb;
|
return mb;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWidget* buildTopToolBar(QWidget* parent)
|
TopBar::TopBar(QWidget* parent) : QWidget(parent) {
|
||||||
{
|
setObjectName(QStringLiteral("appToolBar"));
|
||||||
auto* bar = new QWidget(parent);
|
setFixedHeight(56);
|
||||||
bar->setObjectName(QStringLiteral("appToolBar"));
|
setStyleSheet(QStringLiteral(
|
||||||
bar->setFixedHeight(56);
|
|
||||||
bar->setStyleSheet(QStringLiteral(
|
|
||||||
"#appToolBar { background:#FFFFFF; border-bottom:1px solid #E1E6EE; }"
|
"#appToolBar { background:#FFFFFF; border-bottom:1px solid #E1E6EE; }"
|
||||||
"#topDivider { color:#E1E6EE; }"
|
"#topDivider { color:#E1E6EE; }"
|
||||||
"#wsSwitcher { color:#1F2A3D; border:none; border-radius:8px; padding:8px 12px;"
|
"#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; }"
|
"#userName { color:#1F2A3D; font-size:13px; font-weight:600; }"
|
||||||
"#userRole { color:#8A93A3; font-size:11px; }"));
|
"#userRole { color:#8A93A3; font-size:11px; }"));
|
||||||
|
|
||||||
auto* lay = new QHBoxLayout(bar);
|
auto* lay = new QHBoxLayout(this);
|
||||||
lay->setContentsMargins(14, 0, 14, 0);
|
lay->setContentsMargins(14, 0, 14, 0);
|
||||||
lay->setSpacing(0);
|
lay->setSpacing(0);
|
||||||
|
|
||||||
// ── 工作空间切换(最左):显示当前空间,点击下拉切换 ──
|
// 工作空间切换器(数据驱动;初始占位文本,待 setWorkspaces 填充)。
|
||||||
auto* wsBtn = new QToolButton(bar);
|
wsBtn_ = new QToolButton(this);
|
||||||
wsBtn->setObjectName(QStringLiteral("wsSwitcher"));
|
wsBtn_->setObjectName(QStringLiteral("wsSwitcher"));
|
||||||
wsBtn->setIcon(makeGlyph(Glyph::Workspace, QColor("#2D6CB5"), kWorkspaceIcon));
|
wsBtn_->setIcon(makeGlyph(Glyph::Workspace, QColor("#2D6CB5"), kWorkspaceIcon));
|
||||||
wsBtn->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
|
wsBtn_->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
|
||||||
wsBtn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
wsBtn_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
||||||
wsBtn->setPopupMode(QToolButton::InstantPopup);
|
wsBtn_->setPopupMode(QToolButton::InstantPopup);
|
||||||
wsBtn->setCursor(Qt::PointingHandCursor);
|
wsBtn_->setCursor(Qt::PointingHandCursor);
|
||||||
|
wsBtn_->setText(QStringLiteral("(加载中…)"));
|
||||||
auto* wsMenu = new QMenu(bar);
|
wsBtn_->setMenu(new QMenu(wsBtn_));
|
||||||
auto* wsHeader = wsMenu->addAction(QStringLiteral("切换空间"));
|
lay->addWidget(wsBtn_);
|
||||||
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->addSpacing(10);
|
||||||
lay->addWidget(makeDivider(bar));
|
lay->addWidget(makeDivider(this));
|
||||||
lay->addSpacing(10);
|
lay->addSpacing(10);
|
||||||
|
|
||||||
// ── 项目选择器(与工作空间切换同款样式:无边框 + 图标 + 文本 + 下拉)──
|
// 项目切换器(数据驱动)。
|
||||||
auto* projBtn = new QToolButton(bar);
|
projBtn_ = new QToolButton(this);
|
||||||
projBtn->setObjectName(QStringLiteral("wsSwitcher"));
|
projBtn_->setObjectName(QStringLiteral("wsSwitcher"));
|
||||||
projBtn->setIcon(makeGlyph(Glyph::Folder, QColor("#2D6CB5"), kWorkspaceIcon));
|
projBtn_->setIcon(makeGlyph(Glyph::Folder, QColor("#2D6CB5"), kWorkspaceIcon));
|
||||||
projBtn->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
|
projBtn_->setIconSize(QSize(kWorkspaceIcon, kWorkspaceIcon));
|
||||||
projBtn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
projBtn_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
||||||
projBtn->setPopupMode(QToolButton::InstantPopup);
|
projBtn_->setPopupMode(QToolButton::InstantPopup);
|
||||||
projBtn->setCursor(Qt::PointingHandCursor);
|
projBtn_->setCursor(Qt::PointingHandCursor);
|
||||||
auto* projMenu = new QMenu(bar);
|
projBtn_->setText(QStringLiteral("(加载中…)"));
|
||||||
auto* projHeader = projMenu->addAction(QStringLiteral("切换项目"));
|
projBtn_->setMenu(new QMenu(projBtn_));
|
||||||
projHeader->setEnabled(false);
|
lay->addWidget(projBtn_);
|
||||||
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->addStretch();
|
||||||
|
|
||||||
// ── 右侧:帮助 / 通知 / 设置(仅图标,悬停显示文本)──
|
lay->addWidget(makeIconButton(this, Glyph::Help, QStringLiteral("帮助")));
|
||||||
lay->addWidget(makeIconButton(bar, Glyph::Help, QStringLiteral("帮助")));
|
lay->addWidget(makeIconButton(this, Glyph::Bell, QStringLiteral("通知")));
|
||||||
lay->addWidget(makeIconButton(bar, Glyph::Bell, QStringLiteral("通知")));
|
lay->addWidget(makeIconButton(this, Glyph::Gear, QStringLiteral("设置")));
|
||||||
lay->addWidget(makeIconButton(bar, Glyph::Gear, QStringLiteral("设置")));
|
|
||||||
lay->addSpacing(10);
|
lay->addSpacing(10);
|
||||||
lay->addWidget(makeDivider(bar));
|
lay->addWidget(makeDivider(this));
|
||||||
lay->addSpacing(12);
|
lay->addSpacing(12);
|
||||||
|
|
||||||
// ── 用户:圆形头像 + 姓名/职务 ──
|
// 用户区(本轮静态)。
|
||||||
auto* avatar = new QLabel(QStringLiteral("ZL"), bar);
|
auto* avatar = new QLabel(QStringLiteral("ZL"), this);
|
||||||
avatar->setObjectName(QStringLiteral("avatar"));
|
avatar->setObjectName(QStringLiteral("avatar"));
|
||||||
avatar->setFixedSize(34, 34);
|
avatar->setFixedSize(34, 34);
|
||||||
avatar->setAlignment(Qt::AlignCenter);
|
avatar->setAlignment(Qt::AlignCenter);
|
||||||
lay->addWidget(avatar);
|
lay->addWidget(avatar);
|
||||||
lay->addSpacing(8);
|
lay->addSpacing(8);
|
||||||
|
|
||||||
auto* userBox = new QWidget(bar);
|
auto* userBox = new QWidget(this);
|
||||||
auto* userLay = new QVBoxLayout(userBox);
|
auto* userLay = new QVBoxLayout(userBox);
|
||||||
userLay->setContentsMargins(0, 0, 0, 0);
|
userLay->setContentsMargins(0, 0, 0, 0);
|
||||||
userLay->setSpacing(0);
|
userLay->setSpacing(0);
|
||||||
|
|
@ -231,8 +204,76 @@ QWidget* buildTopToolBar(QWidget* parent)
|
||||||
userLay->addWidget(userName);
|
userLay->addWidget(userName);
|
||||||
userLay->addWidget(userRole);
|
userLay->addWidget(userRole);
|
||||||
lay->addWidget(userBox);
|
lay->addWidget(userBox);
|
||||||
|
}
|
||||||
|
|
||||||
return bar;
|
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(" ▾"));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace geopro::app
|
} // namespace geopro::app
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
#include <vector>
|
||||||
|
#include "repo/RepoTypes.hpp"
|
||||||
|
|
||||||
// 顶部应用区(对齐原型,静态视觉壳):
|
class QToolButton;
|
||||||
// - buildMenuBar:最上方的菜单栏(视图 / 项目管理 / 业务工具 / 设备,含多级子菜单)。
|
|
||||||
// - buildTopToolBar:菜单栏下方的工具条(工作空间切换 + 项目选择 + 帮助/通知/设置 + 用户)。
|
|
||||||
// 调用方将两者纵向堆叠后经 QMainWindow::setMenuWidget 挂到主窗口顶部。
|
|
||||||
// 菜单/按钮当前为静态占位,后续接真实页面与数据。
|
|
||||||
|
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
namespace geopro::app {
|
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<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;
|
||||||
|
};
|
||||||
|
|
||||||
} // namespace geopro::app
|
} // namespace geopro::app
|
||||||
|
|
|
||||||
373
src/app/main.cpp
373
src/app/main.cpp
|
|
@ -62,6 +62,11 @@
|
||||||
#include "PanelHeader.hpp"
|
#include "PanelHeader.hpp"
|
||||||
#include "Theme.hpp"
|
#include "Theme.hpp"
|
||||||
#include "TopBar.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 "login/LoginWindow.hpp"
|
||||||
#include "panels/AnomalyListPanel.hpp"
|
#include "panels/AnomalyListPanel.hpp"
|
||||||
#include "panels/DatasetListPanel.hpp"
|
#include "panels/DatasetListPanel.hpp"
|
||||||
|
|
@ -97,41 +102,6 @@
|
||||||
|
|
||||||
namespace {
|
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<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 全文(登录时密码加密用)。读不到返回空串,登录将报错。
|
// 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。
|
||||||
std::string readPem(const std::string& path)
|
std::string readPem(const std::string& path)
|
||||||
{
|
{
|
||||||
|
|
@ -152,7 +122,7 @@ double median(std::vector<double> v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前中央视图(默认二维地图)。二维地图=测线红线俯视;三维视图=断面墙。
|
// 当前中央视图(默认二维地图)。二维地图=测线红线俯视;三维视图=断面墙。
|
||||||
enum class ViewMode { Map2D, View3D };
|
using geopro::app::ViewMode;
|
||||||
|
|
||||||
// 数据详情显示内容(默认网格数据)。网格数据=#18 banded;原数据=#17 散点(对齐原型命名)。
|
// 数据详情显示内容(默认网格数据)。网格数据=#18 banded;原数据=#17 散点(对齐原型命名)。
|
||||||
enum class DetailMode { Section18, Scatter17 };
|
enum class DetailMode { Section18, Scatter17 };
|
||||||
|
|
@ -171,7 +141,9 @@ constexpr const char* kWgs84 = "EPSG:4326";
|
||||||
|
|
||||||
// 在给定 QMainWindow 上构建 M1 工作台。
|
// 在给定 QMainWindow 上构建 M1 工作台。
|
||||||
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。
|
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。
|
||||||
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo)
|
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
|
||||||
|
geopro::data::IProjectRepository& projectRepo,
|
||||||
|
geopro::controller::WorkbenchNavController& nav)
|
||||||
{
|
{
|
||||||
// ── 世界系:启动取一次 grid1 的 lat/lon,用中位数作 GeoLocalFrame 原点 ──
|
// ── 世界系:启动取一次 grid1 的 lat/lon,用中位数作 GeoLocalFrame 原点 ──
|
||||||
// 全项目共享(shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。
|
// 全项目共享(shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。
|
||||||
|
|
@ -294,6 +266,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
chkTerrain->setEnabled(false); chkTerrain->setToolTip(tip);
|
chkTerrain->setEnabled(false); chkTerrain->setToolTip(tip);
|
||||||
chkSlice->setEnabled(false); chkSlice->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(layerTitle);
|
||||||
layerLayout->addWidget(chkCurtain);
|
layerLayout->addWidget(chkCurtain);
|
||||||
layerLayout->addWidget(chkVoxel);
|
layerLayout->addWidget(chkVoxel);
|
||||||
|
|
@ -354,44 +331,26 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
// 放在中央视图下方。
|
// 放在中央视图下方。
|
||||||
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
|
dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea);
|
||||||
|
|
||||||
// 项目结构(GS→TM→DS):取一次共享,供树/中央/数据列表查 TM 的数据集。
|
// 左上 dock:对象树(真实结构:项目根 → GS → TM)。被动视图,数据由控制器推送。
|
||||||
auto structure = std::make_shared<std::vector<geopro::data::GsNode>>(repo.loadStructure());
|
auto* objectTree = new geopro::app::ObjectTreePanel();
|
||||||
|
|
||||||
// 左上 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("对象显示栏"));
|
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("新建对象")}}));
|
{{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}}));
|
||||||
auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
|
auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
|
||||||
|
|
||||||
// 左下 dock:数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
|
// 左下 dock:数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
|
||||||
auto* datasetTabs = new QTabWidget();
|
auto* datasetTabs = new QTabWidget();
|
||||||
auto* datasetList = new QListWidget();
|
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("数据"));
|
datasetTabs->addTab(datasetList, QStringLiteral("数据"));
|
||||||
auto* fileList = new QListWidget(); // M1 文件 tab 占位
|
auto* fileList = new QListWidget();
|
||||||
{ // 空状态引导:M1 暂无文件来源,给出说明而非空白面板(识别优先于回忆)。
|
fileList->setStyleSheet(datasetList->styleSheet()); // 与数据页签同款简洁分割
|
||||||
auto* hint = new QListWidgetItem(QStringLiteral("(M1 暂无关联文件)"), fileList);
|
|
||||||
hint->setFlags(Qt::NoItemFlags);
|
|
||||||
hint->setForeground(QColor("#9AA6B6"));
|
|
||||||
hint->setTextAlignment(Qt::AlignCenter);
|
|
||||||
}
|
|
||||||
datasetTabs->addTab(fileList, QStringLiteral("文件"));
|
datasetTabs->addTab(fileList, QStringLiteral("文件"));
|
||||||
auto* datasetDock = new ads::CDockWidget(QStringLiteral("数据真实显示栏"));
|
auto* datasetDock = new ads::CDockWidget(QStringLiteral("数据真实显示栏"));
|
||||||
auto* datasetBox = wrapWithHeader(
|
auto* datasetBox = wrapWithHeader(
|
||||||
|
|
@ -441,128 +400,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
if (auto* bar = area->titleBar()) bar->setVisible(false);
|
if (auto* bar = area->titleBar()) bar->setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 中央视图重建(核心)─────────────────────────────────────────────
|
// 中央编排已解耦到 CentralScene::rebuildCentralScene(数据驱动)。本轮空 sections → 空背景占位。
|
||||||
// 按勾选的测线(TM)整体重建:scene.clear() → 对每个勾选 TM 的 dd_section 加对应 actor。
|
// 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活。
|
||||||
// 二维地图 = buildSurveyLine(红线俯视,浅底背景)+ applyTop2D。
|
auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() {
|
||||||
// 三维视图 = buildCurtain(断面墙)SetScale(1,1,kVerticalExaggeration) + applyFree3D(白底)。
|
geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode,
|
||||||
// frame/structure 全局共享;切视图/勾选变化都调用此函数重建当前视图。
|
std::vector<geopro::app::SectionInput>{}, *showCurtain,
|
||||||
auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, &repo, frame, tree,
|
*frame, kVerticalExaggeration);
|
||||||
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(空=未选)与详情显示模式(反演剖面/原数据);切模式或换选中都重建。
|
// 当前选中数据集 id(空=未选)与详情显示模式(反演剖面/原数据);切模式或换选中都重建。
|
||||||
auto currentDsId = std::make_shared<QString>();
|
auto currentDsId = std::make_shared<QString>();
|
||||||
|
|
@ -658,16 +503,21 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
.arg(name).arg(g.nx()).arg(g.ny()).arg(g.vmin).arg(g.vmax)
|
.arg(name).arg(g.nx()).arg(g.ny()).arg(g.vmin).arg(g.vmax)
|
||||||
.arg(anomalies.size()));
|
.arg(anomalies.size()));
|
||||||
};
|
};
|
||||||
|
(void)loadDataset; // 暂未触发:保留待下一轮真实 DS 详情渲染复用
|
||||||
|
|
||||||
// ── 单击左下数据列表的采集批次(DS) → 加载到数据详情/异常/属性 ──
|
// ── 单击左下数据列表的采集批次(DS) → 占位(真实剖面/反演渲染下一阶段接 dd 接口)──
|
||||||
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
|
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
|
||||||
[loadDataset](QListWidgetItem* item) {
|
[propLabel, detailRendererPtr, detailRenderWindowPtr, &nav](QListWidgetItem* item) {
|
||||||
const QString dsId = item->data(geopro::app::kDsIdRole).toString();
|
if (item->data(geopro::app::kDsLoadMoreRole).toBool()) {
|
||||||
const QString ddType = item->data(geopro::app::kDsDdTypeRole).toString();
|
nav.loadMoreData();
|
||||||
if (ddType != "dd_section") return; // 仅剖面网格有详情图
|
return;
|
||||||
|
}
|
||||||
const QString name =
|
const QString name =
|
||||||
item->data(Qt::DisplayRole).toString().section('\n', 0, 0);
|
item->data(Qt::DisplayRole).toString().section('\n', 0, 0);
|
||||||
loadDataset(dsId, name);
|
detailRendererPtr->RemoveAllViewProps();
|
||||||
|
detailRenderWindowPtr->Render();
|
||||||
|
propLabel->setText(QStringLiteral(
|
||||||
|
"数据集: %1\n(该数据集的剖面/反演渲染将在下一阶段接入 dd 接口)").arg(name));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 异常列表勾选(显隐) → 更新隐藏集 → 重建数据详情 ──
|
// ── 异常列表勾选(显隐) → 更新隐藏集 → 重建数据详情 ──
|
||||||
|
|
@ -758,45 +608,122 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
rebuildCentral();
|
rebuildCentral();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 启动默认:测线已勾选,但 itemChanged 在 connect 之前触发故未渲染;这里重建一次中央内容。
|
// ── 启动:建立一次空背景中央视图(真实 sections 数据由下一轮接入)。
|
||||||
rebuildCentral();
|
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* topChrome = new QWidget(&window);
|
||||||
auto* topLayout = new QVBoxLayout(topChrome);
|
auto* topLayout = new QVBoxLayout(topChrome);
|
||||||
topLayout->setContentsMargins(0, 0, 0, 0);
|
topLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
topLayout->setSpacing(0);
|
topLayout->setSpacing(0);
|
||||||
topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
|
topLayout->addWidget(geopro::app::buildMenuBar(topChrome));
|
||||||
topLayout->addWidget(geopro::app::buildTopToolBar(topChrome));
|
topBar = new geopro::app::TopBar(topChrome);
|
||||||
|
topLayout->addWidget(topBar);
|
||||||
window.setMenuWidget(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:用户随时知道当前空间基准)。
|
// 底部状态栏:常驻显示坐标系与世界系原点(wayfinding:用户随时知道当前空间基准)。
|
||||||
window.statusBar()->showMessage(
|
window.statusBar()->showMessage(
|
||||||
QStringLiteral("就绪 | 坐标系 %1 | 世界系原点 %2, %3")
|
QStringLiteral("就绪 | 坐标系 %1 | 世界系原点 %2, %3")
|
||||||
|
|
@ -882,13 +809,19 @@ int main(int argc, char* argv[])
|
||||||
geopro::data::LocalSampleRepository repo(
|
geopro::data::LocalSampleRepository repo(
|
||||||
"D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/");
|
"D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/");
|
||||||
|
|
||||||
|
// 导航仓储 + 控制器(接口/逻辑层):用同一共享会话 ApiClient。
|
||||||
|
geopro::data::ApiProjectRepository projectRepo(api);
|
||||||
|
geopro::controller::WorkbenchNavController nav(projectRepo);
|
||||||
|
|
||||||
QMainWindow window;
|
QMainWindow window;
|
||||||
window.setWindowTitle(QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)"));
|
window.setWindowTitle(QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)"));
|
||||||
window.resize(1280, 800);
|
window.resize(1280, 800);
|
||||||
window.setMinimumSize(1024, 680); // 防止停靠面板被压到不可用尺寸
|
window.setMinimumSize(1024, 680); // 防止停靠面板被压到不可用尺寸
|
||||||
|
|
||||||
buildWorkbench(window, repo);
|
buildWorkbench(window, repo, projectRepo, nav);
|
||||||
window.show();
|
window.show();
|
||||||
|
|
||||||
|
nav.start(); // 进入工作台后拉真实 空间/项目/结构
|
||||||
|
|
||||||
return app.exec();
|
return app.exec();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "panels/DatasetListPanel.hpp"
|
#include "panels/DatasetListPanel.hpp"
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
#include <QListWidget>
|
#include <QListWidget>
|
||||||
#include <QListWidgetItem>
|
#include <QListWidgetItem>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
@ -7,30 +8,48 @@
|
||||||
namespace geopro::app {
|
namespace geopro::app {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
QString humanSize(long long b) {
|
||||||
// dd 类型 → 中文标注。
|
if (b < 1024) return QStringLiteral("%1 B").arg(b);
|
||||||
QString ddTypeLabel(const std::string& ddType)
|
const double kb = b / 1024.0;
|
||||||
{
|
if (kb < 1024.0) return QStringLiteral("%1 KB").arg(kb, 0, 'f', 1);
|
||||||
if (ddType == "dd_section") return QStringLiteral("剖面网格");
|
return QStringLiteral("%1 MB").arg(kb / 1024.0, 0, 'f', 1);
|
||||||
if (ddType == "dd_voxel") return QStringLiteral("体素");
|
|
||||||
return QString::fromStdString(ddType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsNode>& dss)
|
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) {
|
||||||
{
|
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
list->clear();
|
if (!append) list->clear();
|
||||||
for (const auto& ds : dss) {
|
for (const auto& d : rows) {
|
||||||
const QString name = QString::fromStdString(ds.name);
|
QString text = QString::fromStdString(d.dsName);
|
||||||
const QString label = ddTypeLabel(ds.ddType);
|
QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间
|
||||||
QString text = name;
|
if (!d.typeName.empty())
|
||||||
if (!label.isEmpty()) text += QStringLiteral("\n%1").arg(label);
|
sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型
|
||||||
|
if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub);
|
||||||
auto* item = new QListWidgetItem(text, list);
|
auto* item = new QListWidgetItem(text, list);
|
||||||
item->setData(kDsIdRole, QString::fromStdString(ds.id));
|
item->setData(kDsIdRole, QString::fromStdString(d.id));
|
||||||
item->setData(kDsDdTypeRole, QString::fromStdString(ds.ddType));
|
item->setData(kDsDdTypeRole, QString::fromStdString(d.ddCode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ namespace geopro::app {
|
||||||
// 数据列表条目角色(与 main.cpp 树一致:Qt::UserRole=dsId、+1=ddType)。
|
// 数据列表条目角色(与 main.cpp 树一致:Qt::UserRole=dsId、+1=ddType)。
|
||||||
constexpr int kDsIdRole = 0x0100; // Qt::UserRole
|
constexpr int kDsIdRole = 0x0100; // Qt::UserRole
|
||||||
constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1
|
constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1
|
||||||
|
constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2(文件下载 url,备用)
|
||||||
|
constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行
|
||||||
|
|
||||||
// 用某测线(TM)的数据集(采集批次)填充 QListWidget(对齐原型左下「数据真实显示栏」)。
|
// 数据页签:每条 = dsName +(类型名);UserRole 存 dsId、+1 存 ddCode。
|
||||||
// 每条目 = 名称 +(ddType 标注);UserRole 存 dsId、+1 存 ddType(供单击驱动数据详情)。
|
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append);
|
||||||
// 清空旧条目后重填。
|
// 文件页签:每条 = 文件名 +(可读大小);UserRole 存 dsId、+2 存文件 url。空时显示占位。
|
||||||
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsNode>& dss);
|
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append);
|
||||||
|
|
||||||
} // namespace geopro::app
|
} // namespace geopro::app
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
#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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
#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
|
||||||
|
|
@ -2,8 +2,10 @@ find_package(nlohmann_json CONFIG REQUIRED)
|
||||||
find_package(Qt6 COMPONENTS Core REQUIRED)
|
find_package(Qt6 COMPONENTS Core REQUIRED)
|
||||||
add_library(geopro_data STATIC
|
add_library(geopro_data STATIC
|
||||||
parse/SampleParsers.cpp
|
parse/SampleParsers.cpp
|
||||||
repo/LocalSampleRepository.cpp)
|
repo/LocalSampleRepository.cpp
|
||||||
|
dto/NavDto.cpp
|
||||||
|
api/ApiProjectRepository.cpp)
|
||||||
target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
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)
|
target_compile_features(geopro_data PUBLIC cxx_std_17)
|
||||||
set_target_properties(geopro_data PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)
|
set_target_properties(geopro_data PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
#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 对象 → ProjectListPage(rows + 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 对象 → DsPage(rows + 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
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
#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
|
||||||
|
|
@ -3,7 +3,33 @@
|
||||||
#include <vector>
|
#include <vector>
|
||||||
namespace geopro::data {
|
namespace geopro::data {
|
||||||
struct DsNode { std::string id, name, ddType; };
|
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 TmNode { std::string id, name, confCode; std::vector<DsNode> dss; };
|
||||||
struct GsNode { std::string id, name; std::vector<TmNode> tms; };
|
struct GsNode { std::string id, name; std::vector<TmNode> tms; };
|
||||||
struct Project { std::string id, name; std::vector<GsNode> gss; };
|
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
|
} // namespace geopro::data
|
||||||
|
|
|
||||||
|
|
@ -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_parsers.cpp)
|
||||||
target_sources(geopro_tests PRIVATE data/test_local_repo.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)
|
target_link_libraries(geopro_tests PRIVATE geopro_data)
|
||||||
|
|
||||||
# net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package
|
# net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
#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、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 不进树
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "全量类型");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue