Compare commits

..

No commits in common. "475af464d9029edca0cef819b35428f8b9bc4353" and "405fb2ae4ff42f471e1bb2b59ef85f2ac2a8e859" have entirely different histories.

20 changed files with 182 additions and 776 deletions

View File

@ -24,11 +24,8 @@
**做In Scope** **做In Scope**
- 工作空间列表 / 切换(真实接口)。 - 工作空间列表 / 切换(真实接口)。
- 项目列表 / 切换(真实接口):下拉显示首页项目(首页 10项目数超过首页 → 下拉底部「全部项目…」打开 - 项目列表 / 切换(真实接口)。
**项目列表弹窗**(名称/类型过滤 + 分页 + 8 列表格:序号/名称/编号/状态/类型/业主/负责人/创建时间;点项目名切换并关弹窗)。
- 对象树:**按真实结构显示 GS 层**(项目根 → GS → TMTM 在左下"数据真实显示栏"列出其 DS。 - 对象树:**按真实结构显示 GS 层**(项目根 → GS → TMTM 在左下"数据真实显示栏"列出其 DS。
- DS **数据/文件两个页签**接真实分页接口(每页 5每行显示"名称 / 创建时间 · 类型(数据)或大小(文件)"
超过首页 → 列表末尾「加载更多」追加下一页。
- 真实接口失败(断网 / token 过期 / 无数据)→ **显示错误 / 空状态****不回退本地样本**。 - 真实接口失败(断网 / token 过期 / 无数据)→ **显示错误 / 空状态****不回退本地样本**。
- 项目 `referenceCRSCode` 存入导航状态,供下一轮替换硬编码 `EPSG:4547`(本轮不改渲染)。 - 项目 `referenceCRSCode` 存入导航状态,供下一轮替换硬编码 `EPSG:4547`(本轮不改渲染)。
@ -37,7 +34,7 @@
→ 点击真实 DS 时中央/详情显示**占位"待接入"**`render/*` 与 `LocalSampleRepository` 代码**保留不删**。 → 点击真实 DS 时中央/详情显示**占位"待接入"**`render/*` 与 `LocalSampleRepository` 代码**保留不删**。
- 异步仓储QFuture/回调)—— 本轮同步阻塞 + WaitCursor与登录一致异步留 M1.5。 - 异步仓储QFuture/回调)—— 本轮同步阻塞 + WaitCursor与登录一致异步留 M1.5。
- 用户头像 / 姓名接真实 `auth/getUserInfo`(本轮先留静态)。 - 用户头像 / 姓名接真实 `auth/getUserInfo`(本轮先留静态)。
- 文件下载(文件页签已展示文件名/大小、下载 `url` 已存入列表项备用,实际下载动作留后续)。 - 项目分页"加载更多"的滚动 UI接口支持 `hasNextPage`,本轮先取首页;翻页留待后续)。
## 3. 接口确认结论 ## 3. 接口确认结论
@ -48,15 +45,13 @@ token 已由登录注入(`geomativeauthorization` 头),下列接口直接
|---|---|---|---| |---|---|---|---|
| 工作空间列表 | GET | `/business/system/tenant/enterprise/joined/list` | `[{id, name, ownerType(1个人/2企业), isCurTenant(0/1), logoPath}]` | | 工作空间列表 | 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/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/queryByUser?lastProjectId=` | `{hasNextPage, projectList:[{id, projectName, projectTypeName, referenceCRSCode, referenceCRSName, status, ...}]}`(游标分页,首页传空 lastProjectId |
| 项目类型列表 | GET | `/business/project/type/list` | `data.value:[{id, name}]`(弹窗"项目类型"过滤下拉) | | 项目结构 | POST | `/business/projectWorkbench/queryProjectStruct` | body `{projectId}`data `{projectStructList:[{id, name, parentId, type, typeId, typeName, confCode}]}` |
| 项目结构 | GET | `/business/projectStruct/queryProjectStruct/{projectId}` | `data.value:[{id, name, parentId, type(1项目/2TM), typeId, typeName, confCode}]`(仅项目根+TM不含 DS | | TM 下 DS | GET | `/business/projectWorkbench/queryDsByTmObjectId/{tmObjectId}` | `[{id, name, ddCode, typeName}]` |
| 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`**。 **层级确认(修正需求方假设)**:真实结构**不是** `项目→tm→ds`,而是 **`项目 → GS(工区) → TM(测线) → DS`**。
- `queryProjectStruct` 返回一个**扁平 parent-child 列表**(仅含 GS + TM 两类节点,**不含 DS**),客户端按 `parentId` 自建树。 - `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 不在结构列表里,按 TM 单独拉取(`queryDsByTmObjectId`)。
- **项目不能直接挂 DS**DS 永远挂在 TM 下。但由于是 `parentId` 扁平结构,**TM 可直接挂在项目下(无中间 GS**——这是"项目直接挂"印象的来源,但叶子仍是 TM→DS。 - **项目不能直接挂 DS**DS 永远挂在 TM 下。但由于是 `parentId` 扁平结构,**TM 可直接挂在项目下(无中间 GS**——这是"项目直接挂"印象的来源,但叶子仍是 TM→DS。
**节点判定**:结构列表只含 GS+TM**TM = 该节点在结构列表中无子节点(叶子)**;非叶子 = GS。 **节点判定**:结构列表只含 GS+TM**TM = 该节点在结构列表中无子节点(叶子)**;非叶子 = GS。
@ -113,19 +108,15 @@ token 已由登录注入(`geomativeauthorization` 头),下列接口直接
struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; }; struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; };
struct ProjectSummary { struct ProjectSummary {
std::string id, name, typeName, crsCode, crsName; std::string id, name, typeName, crsCode, crsName;
std::string code, projectTypeId, ownerCompany, responsiblePerson, createTime; // 弹窗 8 列用 int status = 0;
int status = 0; // 1=未开始 2=进行中(其余显示数字)
}; };
struct ProjectType { std::string id, name; }; // 类型过滤下拉
struct ProjectListPage { std::vector<ProjectSummary> rows; int total = 0; }; // 项目分页结果
// 项目结构扁平节点GS / TM客户端按 parentId 建树。 // 项目结构扁平节点GS / TM客户端按 parentId 建树。
struct StructNode { struct StructNode {
std::string id, name, parentId, typeName, confCode; std::string id, name, parentId, typeName, confCode;
int type = 0; int type = 0;
}; };
``` ```
新增 `DsRow{id, dsName, typeName, ddCode, createTime, fileName, fileUrl, fileSize}`(数据/文件页签行通用;文件行含 file* `DsNode{id,name,ddType}` 复用;映射时 `ddCode → ddType`
+ `DsPage{rows, total}`(分页结果)。`DsNode` 仅本地样本仓储继续用。
### 5.3 数据访问层 `data` ### 5.3 数据访问层 `data`
@ -141,35 +132,25 @@ public:
virtual ~IProjectRepository() = default; virtual ~IProjectRepository() = default;
virtual RepoResult<std::vector<Workspace>> listWorkspaces() = 0; virtual RepoResult<std::vector<Workspace>> listWorkspaces() = 0;
virtual RepoResult<bool> switchWorkspace(const std::string& tenantId) = 0; virtual RepoResult<bool> switchWorkspace(const std::string& tenantId) = 0;
// 项目分页(名称/类型过滤)+ 项目类型列表(弹窗用)。 virtual RepoResult<std::vector<ProjectSummary>> listProjects(const std::string& lastProjectId) = 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; virtual RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) = 0;
// 按 TM 分页拉数据/文件行classifyType 3=数据 1=文件pageNo 从 1 起pageSize 固定 5。 virtual RepoResult<std::vector<DsNode>> loadDatasetsOfTm(const std::string& tmObjectId) = 0;
virtual RepoResult<DsPage> loadTmRows(const std::string& projectId, const std::string& tmObjectId,
int classifyType, int pageNo) = 0;
}; };
``` ```
**`api/ApiProjectRepository.{hpp,cpp}`** — 实现:持有 `net::ApiClient&` **`api/ApiProjectRepository.{hpp,cpp}`** — 实现:持有 `net::ApiClient&`
按 §3 路径发请求,把 `ApiResponse` 交给 `dto/` 映射;网络/业务码错误 → `RepoResult{ok=false, error=msg}` 按 §3 路径发请求,把 `ApiResponse` 交给 `dto/` 映射;网络/业务码错误 → `RepoResult{ok=false, error=msg}`
判定成功:`code==200`(沿用登录 `AuthService` 的约定业务码即成功标志。id 进 URL 路径/查询前 判定成功:`httpStatus==200 && code==<成功码>`(成功码沿用登录约定,实现时核对)。
`QUrl::toPercentEncoding` 百分号编码(不可信后端数据:防 `? # & /` 空格 破坏 URL
**`dto/NavDto.{hpp,cpp}`** — 纯函数映射(**无网络、可单测** **`dto/NavDto.{hpp,cpp}`** — 纯函数映射(**无网络、可单测**
- `parseWorkspaces(QJsonArray) -> vector<Workspace>``isCurTenant==1 → isCurrent`)。 - `parseWorkspaces(QJsonArray) -> vector<Workspace>``isCurTenant==1 → isCurrent`)。
- `parseProjectList(QJsonArray) -> vector<ProjectSummary>` / `parseProjectPage(QJsonObject) -> ProjectListPage{rows,total}`project/page - `parseProjects(QJsonObject) -> {vector<ProjectSummary>, bool hasNextPage}`
- `parseProjectTypes(QJsonArray) -> vector<ProjectType>`type/list
- `parseStructNodes(QJsonArray) -> vector<StructNode>` - `parseStructNodes(QJsonArray) -> vector<StructNode>`
- `parseDsRows(QJsonArray) -> vector<DsRow>` / `parseDsPage(QJsonObject) -> DsPage{rows,total}`data/file page`name→typeName`、`createTime`、`file{name,size,url}`)。 - `parseDatasets(QJsonArray) -> vector<DsNode>``ddCode→ddType`)。
- `buildStructTree(vector<StructNode>) -> vector<StructTreeNode>`:扁平→**通用树**(不强塞 `Project/Gs/Tm` 刚性模型, - `buildProjectTree(vector<StructNode>, projectName) -> Project`:扁平→树。
以适配任意层级 + TM 直挂项目)。`StructTreeNode{StructNode node; bool isTm; vector<StructTreeNode> children}`。 - 以 `parentId` 归并;`parentId` 为空或不在集合内的节点挂到合成"项目根"。
- 以 `parentId` 归并;`parentId` 为空或不在集合内(孤儿)的节点为根层。 - **叶子节点判定为 TM**(进 `TmNode`,携带 `confCode`/真实 id 作 tmObjectId非叶子为 GS。
- **叶子节点判定为 TM**`isTm=true``node.id` 即 tmObjectId非叶子为 GS。 - TM 的 `dss` 本轮留空DS 懒加载)。
- `visited` 集防环:不可信后端数据(多节点环 / 重复 id也不会无限递归规约永不信任外部数据
- 纯函数、可单测树→QTreeWidget 的填充由 `ObjectTreePanel` 调用本函数完成(见 §5.5)。
### 5.4 逻辑层 `controller/WorkbenchNavController`QObject ### 5.4 逻辑层 `controller/WorkbenchNavController`QObject
唯一持有导航状态;不碰 widget经信号把模型推给 UI、经槽接收用户意图。 唯一持有导航状态;不碰 widget经信号把模型推给 UI、经槽接收用户意图。
@ -183,57 +164,35 @@ public:
public slots: public slots:
void switchWorkspace(const QString& tenantId); // 切空间→重载项目→重载结构 void switchWorkspace(const QString& tenantId); // 切空间→重载项目→重载结构
void switchProject(const QString& projectId); // 切项目→重载结构(清 DS/详情) void switchProject(const QString& projectId); // 切项目→重载结构(清 DS/详情)
void selectTm(const QString& tmObjectId); // 选 TM→拉其 DS 首页(数据+文件) void selectTm(const QString& tmObjectId); // 选 TM→拉其 DS
void loadMoreData(); // 数据页签"加载更多"→下一页追加
void loadMoreFiles(); // 文件页签"加载更多"→下一页追加
signals: signals:
void workspacesLoaded(const std::vector<data::Workspace>&, QString currentId); void workspacesLoaded(const std::vector<data::Workspace>&, QString currentId);
void projectsLoaded(const std::vector<data::ProjectSummary>&, QString currentId, int total); // total 判断"全部项目"入口 void projectsLoaded(const std::vector<data::ProjectSummary>&, QString currentId);
// 发出项目名 + 扁平结构节点建树buildStructTree在 ObjectTreePanel 内完成。 void structureLoaded(const data::Project&); // 已建好的树
void structureLoaded(const QString& projectName, const std::vector<data::StructNode>&); void datasetsLoaded(const QString& tmObjectId, const std::vector<data::DsNode>&);
// 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 loadFailed(const QString& stage, const QString& message); // 出错→UI 空/错状态
void busyChanged(bool busy); // 同步阻塞期间置 WaitCursor void busyChanged(bool busy); // 同步阻塞期间置 WaitCursor
private: private:
void loadProjectsAndStructure(); // start + switchWorkspace 共用
data::IProjectRepository& repo_; data::IProjectRepository& repo_;
std::vector<data::ProjectSummary> lastProjects_; // 供 switchProject 查 name/crsCode QString currentWorkspaceId_, currentProjectId_, currentCrsCode_;
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`→发扁平节点。 编排逻辑:`start()` → `listWorkspaces`(选 isCurrent/首个)→ `listProjects`(选首个)→ `loadStructure`→建树。
`selectTm` 拉数据/文件首页(每页 5`loadMoreData/Files` 递增页码追加。`switchWorkspace` 成功后用返回的新 accessToken 重注入 ApiClient否则后续请求仍落旧空间
切空间/项目按 §6 时序。每个阶段失败 emit `loadFailed(stage,msg)` 并停在该阶段。 切空间/项目按 §6 时序。每个阶段失败 emit `loadFailed(stage,msg)` 并停在该阶段。
**重入保护**:每个公共操作入口 `if (busy_) return;`,并用 RAII guard 在置忙/复位时配平 `busyChanged`
(同步 HTTP 会泵 Qt 事件循环,快速二次点击可能重入并污染状态)。
### 5.5 UI 层 `app`(被动视图,数据驱动) ### 5.5 UI 层 `app`(被动视图,数据驱动)
**`app/TopBar`** —— 由"自由函数返回静态 QWidget"升级为**数据驱动类**QWidget 子类): **`app/TopBar`** —— 由"自由函数返回静态 QWidget"升级为**数据驱动类**QWidget 子类):
- `setWorkspaces(list, currentId)` / `setProjects(list, currentId, hasMore)` 重建下拉项;`hasMore` 时下拉底部加「全部项目…」。 - `setWorkspaces(list, currentId)` / `setProjects(list, currentId)` 重建下拉项。
- `setProjectButtonText(name)` —— 弹窗切换项目后更新项目按钮文字。 - 信号 `workspaceSwitchRequested(QString id)` / `projectSwitchRequested(QString id)`
- 信号 `workspaceSwitchRequested(id)` / `projectSwitchRequested(id)` / `allProjectsRequested()`(打开项目弹窗)。 - 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。
- 工作空间/项目下拉用互斥 `QActionGroup`(避免"多选"),选中即更新按钮文字。 - `buildMenuBar` 不变(静态菜单本轮不接)。
- 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。`buildMenuBar` 不变。
**`app/panels/ObjectTreePanel`**(新增)—— 被动:`setStructure(projectName, vector<StructNode>)` 内部调 **`app/panels/ObjectTreePanel`**(新增;或先以构建函数落在 main二选一见 §11—— 被动:
`dto::buildStructTree` 重建 `QTreeWidget`项目根→GS→TM叶子=TM 可勾选、`UserRole` 存 tmObjectId `setProject(const data::Project&)` 重建 `QTreeWidget`项目根→GS→TMTM 可勾选、存 tmObjectId
`showMessage(msg)` 显示空/错占位。信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)` 信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)`。空/错状态:树区显示占位 label。
(后者为前瞻钩子,本轮无消费者)。
**`app/panels/DatasetListPanel`** —— `datasetsLoaded`→`populateDatasetList`(数据:`dsName / 创建时间 · 类型名` **`app/panels/DatasetListPanel`**(已有)—— `datasetsLoaded``populateDatasetList`;空时显示"暂无数据集"。
`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 点击 → 详情面板与中央视图显示占位文案 **中央/详情**:移除"启动自动渲染本地 demo"DS 点击 → 详情面板与中央视图显示占位文案
"该数据集渲染将在下一阶段接入 dd 接口"。渲染代码保留。 "该数据集渲染将在下一阶段接入 dd 接口"。渲染代码保留。
@ -246,7 +205,7 @@ private:
controller.start(): controller.start():
listWorkspaces → emit workspacesLoaded → TopBar.setWorkspaces listWorkspaces → emit workspacesLoaded → TopBar.setWorkspaces
listProjects(empty) → emit projectsLoaded → TopBar.setProjects listProjects(empty) → emit projectsLoaded → TopBar.setProjects
loadStructure(currentProject) → emit structureLoaded(name,nodes) → ObjectTreePanel.setStructure(→buildStructTree) loadStructure(currentProject) → buildProjectTree → emit structureLoaded → ObjectTreePanel.setProject
切空间: TopBar.workspaceSwitchRequested(id) 切空间: TopBar.workspaceSwitchRequested(id)
→ controller.switchWorkspace: switchWorkspace(id) → listProjects → 选首个 → loadStructure → controller.switchWorkspace: switchWorkspace(id) → listProjects → 选首个 → loadStructure
@ -255,12 +214,8 @@ private:
切项目: TopBar.projectSwitchRequested(id) 切项目: TopBar.projectSwitchRequested(id)
→ controller.switchProject: loadStructure(id) → emit structureLoaded清空 DS 列表/详情占位 → controller.switchProject: loadStructure(id) → emit structureLoaded清空 DS 列表/详情占位
全部项目: TopBar.allProjectsRequested → main 打开 ProjectListDialogpageProjects 分页 + 名称/类型过滤)
→ 点项目名 → projectChosen(id,name) → nav.switchProject(id) + TopBar.setProjectButtonText(name) → 关弹窗
选 TM: ObjectTreePanel.tmClicked(tmObjectId) 选 TM: ObjectTreePanel.tmClicked(tmObjectId)
→ controller.selectTm: loadTmRows(pid,tm,3,1)+loadTmRows(pid,tm,1,1) → emit datasetsLoaded/filesLoaded(total,append=false) → 数据/文件页签 → controller.selectTm: loadDatasetsOfTm → emit datasetsLoaded → DatasetListPanel 填充
加载更多: 点列表末尾"加载更多" → controller.loadMoreData/Files → emit …(total,append=true) → 追加
点 DS: DatasetListPanel → 中央/详情显示占位"待接入"(本轮不渲染真实数据) 点 DS: DatasetListPanel → 中央/详情显示占位"待接入"(本轮不渲染真实数据)
``` ```
@ -270,8 +225,7 @@ private:
- controller 任一阶段失败 → `loadFailed(stage, msg)`UI 在对应面板显示空/错状态 label + 状态栏提示,**不回退本地样本**。 - controller 任一阶段失败 → `loadFailed(stage, msg)`UI 在对应面板显示空/错状态 label + 状态栏提示,**不回退本地样本**。
- 空数据(无空间 / 无项目 / 无结构 / 无 DS→ 各面板显示"暂无…"占位(识别优于回忆)。 - 空数据(无空间 / 无项目 / 无结构 / 无 DS→ 各面板显示"暂无…"占位(识别优于回忆)。
- token 过期(业务码 401 类)→ `loadFailed` 文案提示重新登录(本轮先提示,自动跳登录留后续)。 - token 过期(业务码 401 类)→ `loadFailed` 文案提示重新登录(本轮先提示,自动跳登录留后续)。
- 输入边界:`tmObjectId` / `projectId` 为空时短路不发请求URL 中的 id 一律百分号编码(见 §5.3)。 - 输入边界:`tmObjectId` / `projectId` 为空时短路不发请求。
- 重入:同步请求期间 `busy_` 拒绝再次进入(避免快速点击重入污染状态,见 §5.4)。
## 8. 渲染解耦 ## 8. 渲染解耦
@ -319,8 +273,8 @@ void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
## 9. 测试策略 ## 9. 测试策略
依既有无测试桩 + 依赖 live 服务器的现实,聚焦**纯逻辑单测**GoogleTest + CTest 依既有无测试桩 + 依赖 live 服务器的现实,聚焦**纯逻辑单测**GoogleTest + CTest
- `dto/NavDto` 映射:喂样本 JSON取自 OpenAPI example / 手造)验证 - `dto/NavDto` 映射:喂样本 JSON取自 OpenAPI example / 手造)验证
`parseWorkspaces / parseProjects / parseStructNodes / parseDsRows` 字段与 `name→typeName`、`isCurTenant→isCurrent`。 `parseWorkspaces / parseProjects / parseStructNodes / parseDatasets` 字段与 `ddCode→ddType`、`isCurTenant→isCurrent`。
- `buildStructTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS、孤儿 parentId、空列表、防环 等场景。 - `buildProjectTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS、孤儿 parentId、空列表 等场景。
- 不做 live 集成 / E2E无桩、依赖真实后端。控制器/UI 信号联动靠手动联调验证。 - 不做 live 集成 / E2E无桩、依赖真实后端。控制器/UI 信号联动靠手动联调验证。
- 目标纯逻辑文件dto + tree builder覆盖率优先达标UI/网络 IO 不计入。 - 目标纯逻辑文件dto + tree builder覆盖率优先达标UI/网络 IO 不计入。
@ -337,7 +291,6 @@ void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer,
- `src/controller/WorkbenchNavController.{hpp,cpp}` - `src/controller/WorkbenchNavController.{hpp,cpp}`
- `src/app/panels/ObjectTreePanel.{hpp,cpp}`(若不抽,则树构建函数留在 main但 TopBar 必抽) - `src/app/panels/ObjectTreePanel.{hpp,cpp}`(若不抽,则树构建函数留在 main但 TopBar 必抽)
- `src/app/CentralScene.{hpp,cpp}`(中央三维编排的数据驱动 helper见 §8.1 - `src/app/CentralScene.{hpp,cpp}`(中央三维编排的数据驱动 helper见 §8.1
- `src/app/ProjectListDialog.{hpp,cpp}`(项目列表弹窗,见 §5.5
- 测试:`tests/data/test_nav_dto.cpp`NavDto 映射 + buildStructTree - 测试:`tests/data/test_nav_dto.cpp`NavDto 映射 + buildStructTree
**改造** **改造**

View File

@ -23,8 +23,7 @@ add_executable(geopro_desktop WIN32
panels/AnomalyListPanel.cpp panels/AnomalyListPanel.cpp
panels/DatasetListPanel.cpp panels/DatasetListPanel.cpp
panels/ObjectTreePanel.cpp panels/ObjectTreePanel.cpp
CentralScene.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})

View File

@ -1,160 +0,0 @@
#include "ProjectListDialog.hpp"
#include <QAbstractItemView>
#include <QColor>
#include <QComboBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QStringList>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QVBoxLayout>
namespace geopro::app {
namespace {
QString statusText(int s) {
switch (s) {
case 1: return QStringLiteral("未开始");
case 2: return QStringLiteral("进行中");
default: return QString::number(s);
}
}
} // namespace
ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* parent)
: QDialog(parent), repo_(repo) {
setWindowTitle(QStringLiteral("全部项目"));
resize(980, 560);
auto* root = new QVBoxLayout(this);
auto* filter = new QHBoxLayout();
filter->addWidget(new QLabel(QStringLiteral("项目名称"), this));
nameEdit_ = new QLineEdit(this);
nameEdit_->setPlaceholderText(QStringLiteral("输入项目名称"));
nameEdit_->setFixedWidth(200);
filter->addWidget(nameEdit_);
filter->addSpacing(8);
filter->addWidget(new QLabel(QStringLiteral("项目类型"), this));
typeCombo_ = new QComboBox(this);
typeCombo_->setFixedWidth(160);
filter->addWidget(typeCombo_);
filter->addSpacing(8);
auto* searchBtn = new QPushButton(QStringLiteral("搜索"), this);
auto* resetBtn = new QPushButton(QStringLiteral("重置"), this);
filter->addWidget(searchBtn);
filter->addWidget(resetBtn);
filter->addStretch();
root->addLayout(filter);
table_ = new QTableWidget(this);
table_->setColumnCount(8);
table_->setHorizontalHeaderLabels(QStringList{
QStringLiteral("序号"), QStringLiteral("项目名称"), QStringLiteral("项目编号"),
QStringLiteral("项目状态"), QStringLiteral("项目类型"), QStringLiteral("业主单位"),
QStringLiteral("负责人"), QStringLiteral("创建时间")});
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
table_->setSelectionBehavior(QAbstractItemView::SelectRows);
table_->setSelectionMode(QAbstractItemView::SingleSelection);
table_->verticalHeader()->setVisible(false);
table_->horizontalHeader()->setStretchLastSection(true);
table_->setColumnWidth(0, 50);
table_->setColumnWidth(1, 260);
root->addWidget(table_, 1);
auto* bottom = new QHBoxLayout();
pageLabel_ = new QLabel(this);
bottom->addWidget(pageLabel_);
bottom->addStretch();
prevBtn_ = new QPushButton(QStringLiteral("上一页"), this);
nextBtn_ = new QPushButton(QStringLiteral("下一页"), this);
bottom->addWidget(prevBtn_);
bottom->addWidget(nextBtn_);
root->addLayout(bottom);
fillTypeFilter();
QObject::connect(searchBtn, &QPushButton::clicked, this, [this]() {
pageNo_ = 1;
query();
});
QObject::connect(resetBtn, &QPushButton::clicked, this, [this]() {
nameEdit_->clear();
typeCombo_->setCurrentIndex(0);
pageNo_ = 1;
query();
});
QObject::connect(prevBtn_, &QPushButton::clicked, this, [this]() {
if (pageNo_ > 1) {
--pageNo_;
query();
}
});
QObject::connect(nextBtn_, &QPushButton::clicked, this, [this]() {
if (pageNo_ * pageSize_ < total_) {
++pageNo_;
query();
}
});
QObject::connect(table_, &QTableWidget::cellClicked, this, [this](int row, int col) {
if (col != 1) return; // 仅"项目名称"列触发切换
auto* item = table_->item(row, 1);
if (!item) return;
const QString id = item->data(Qt::UserRole).toString();
if (id.isEmpty()) return;
emit projectChosen(id, item->text());
accept();
});
query();
}
void ProjectListDialog::fillTypeFilter() {
typeCombo_->addItem(QStringLiteral("全部类型"), QString());
const auto r = repo_.listProjectTypes();
if (!r.ok) return;
for (const auto& t : r.value)
typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id));
}
void ProjectListDialog::query() {
const std::string name = nameEdit_->text().trimmed().toStdString();
const std::string typeId = typeCombo_->currentData().toString().toStdString();
const auto r = repo_.pageProjects(name, typeId, pageNo_, pageSize_);
if (!r.ok) {
table_->setRowCount(0);
pageLabel_->setText(QStringLiteral("加载失败:%1").arg(QString::fromStdString(r.error)));
prevBtn_->setEnabled(false);
nextBtn_->setEnabled(false);
return;
}
total_ = r.value.total;
const auto& rows = r.value.rows;
table_->setRowCount(static_cast<int>(rows.size()));
for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
const auto& p = rows[i];
auto set = [&](int col, const QString& text) {
table_->setItem(i, col, new QTableWidgetItem(text));
};
set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1));
auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name));
nameItem->setData(Qt::UserRole, QString::fromStdString(p.id));
nameItem->setForeground(QColor("#2D6CB5"));
table_->setItem(i, 1, nameItem);
set(2, QString::fromStdString(p.code));
set(3, statusText(p.status));
set(4, QString::fromStdString(p.typeName));
set(5, QString::fromStdString(p.ownerCompany));
set(6, QString::fromStdString(p.responsiblePerson));
set(7, QString::fromStdString(p.createTime));
}
const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1;
pageLabel_->setText(QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages));
prevBtn_->setEnabled(pageNo_ > 1);
nextBtn_->setEnabled(pageNo_ < pages);
}
} // namespace geopro::app

View File

@ -1,39 +0,0 @@
#pragma once
#include <QDialog>
#include "repo/IProjectRepository.hpp"
class QLineEdit;
class QComboBox;
class QTableWidget;
class QLabel;
class QPushButton;
namespace geopro::app {
// 项目列表弹窗:名称/类型过滤 + 分页表格;点项目名 → 切换项目并关闭。
class ProjectListDialog : public QDialog {
Q_OBJECT
public:
explicit ProjectListDialog(data::IProjectRepository& repo, QWidget* parent = nullptr);
signals:
void projectChosen(const QString& projectId, const QString& projectName);
private:
void query();
void fillTypeFilter();
data::IProjectRepository& repo_;
QLineEdit* nameEdit_ = nullptr;
QComboBox* typeCombo_ = nullptr;
QTableWidget* table_ = nullptr;
QLabel* pageLabel_ = nullptr;
QPushButton* prevBtn_ = nullptr;
QPushButton* nextBtn_ = nullptr;
int pageNo_ = 1;
int pageSize_ = 20;
int total_ = 0;
};
} // namespace geopro::app

View File

@ -211,8 +211,6 @@ void TopBar::setWorkspaces(const std::vector<data::Workspace>& list, const QStri
auto* header = menu->addAction(QStringLiteral("切换空间")); auto* header = menu->addAction(QStringLiteral("切换空间"));
header->setEnabled(false); header->setEnabled(false);
menu->addSeparator(); menu->addSeparator();
auto* group = new QActionGroup(menu);
group->setExclusive(true); // 互斥:只一个勾选,避免“多选”
QString currentName; QString currentName;
for (const auto& w : list) { for (const auto& w : list) {
const QString id = QString::fromStdString(w.id); const QString id = QString::fromStdString(w.id);
@ -220,12 +218,9 @@ void TopBar::setWorkspaces(const std::vector<data::Workspace>& list, const QStri
auto* a = menu->addAction(name); auto* a = menu->addAction(name);
a->setCheckable(true); a->setCheckable(true);
a->setChecked(id == currentId); a->setChecked(id == currentId);
group->addAction(a);
if (id == currentId) currentName = name; if (id == currentId) currentName = name;
QObject::connect(a, &QAction::triggered, this, [this, id, name]() { QObject::connect(a, &QAction::triggered, this,
wsBtn_->setText(name + QStringLiteral("")); // 立即反馈 [this, id]() { emit workspaceSwitchRequested(id); });
emit workspaceSwitchRequested(id);
});
} }
if (list.empty()) { if (list.empty()) {
auto* none = menu->addAction(QStringLiteral("(暂无空间)")); auto* none = menu->addAction(QStringLiteral("(暂无空间)"));
@ -236,14 +231,11 @@ void TopBar::setWorkspaces(const std::vector<data::Workspace>& list, const QStri
QStringLiteral("")); QStringLiteral(""));
} }
void TopBar::setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId, void TopBar::setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId) {
bool hasMore) {
auto* menu = new QMenu(projBtn_); auto* menu = new QMenu(projBtn_);
auto* header = menu->addAction(QStringLiteral("切换项目")); auto* header = menu->addAction(QStringLiteral("切换项目"));
header->setEnabled(false); header->setEnabled(false);
menu->addSeparator(); menu->addSeparator();
auto* group = new QActionGroup(menu);
group->setExclusive(true);
QString currentName; QString currentName;
for (const auto& p : list) { for (const auto& p : list) {
const QString id = QString::fromStdString(p.id); const QString id = QString::fromStdString(p.id);
@ -251,29 +243,17 @@ void TopBar::setProjects(const std::vector<data::ProjectSummary>& list, const QS
auto* a = menu->addAction(name); auto* a = menu->addAction(name);
a->setCheckable(true); a->setCheckable(true);
a->setChecked(id == currentId); a->setChecked(id == currentId);
group->addAction(a);
if (id == currentId) currentName = name; if (id == currentId) currentName = name;
QObject::connect(a, &QAction::triggered, this, [this, id, name]() { QObject::connect(a, &QAction::triggered, this,
projBtn_->setText(name + QStringLiteral("")); [this, id]() { emit projectSwitchRequested(id); });
emit projectSwitchRequested(id);
});
} }
if (list.empty()) { if (list.empty()) {
auto* none = menu->addAction(QStringLiteral("(暂无项目)")); auto* none = menu->addAction(QStringLiteral("(暂无项目)"));
none->setEnabled(false); 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_->setMenu(menu);
projBtn_->setText((currentName.isEmpty() ? QStringLiteral("选择项目") : currentName) + projBtn_->setText((currentName.isEmpty() ? QStringLiteral("选择项目") : currentName) +
QStringLiteral("")); QStringLiteral(""));
} }
void TopBar::setProjectButtonText(const QString& name) {
projBtn_->setText(name + QStringLiteral(""));
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -17,14 +17,11 @@ public:
explicit TopBar(QWidget* parent = nullptr); explicit TopBar(QWidget* parent = nullptr);
void setWorkspaces(const std::vector<data::Workspace>& list, const QString& currentId); void setWorkspaces(const std::vector<data::Workspace>& list, const QString& currentId);
void setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId, void setProjects(const std::vector<data::ProjectSummary>& list, const QString& currentId);
bool hasMore);
void setProjectButtonText(const QString& name); // 弹窗切换项目后更新按钮文字
signals: signals:
void workspaceSwitchRequested(const QString& tenantId); void workspaceSwitchRequested(const QString& tenantId);
void projectSwitchRequested(const QString& projectId); void projectSwitchRequested(const QString& projectId);
void allProjectsRequested(); // 点击"全部项目…"
private: private:
QToolButton* wsBtn_ = nullptr; QToolButton* wsBtn_ = nullptr;

View File

@ -61,7 +61,6 @@
#include "Theme.hpp" #include "Theme.hpp"
#include "TopBar.hpp" #include "TopBar.hpp"
#include "CentralScene.hpp" #include "CentralScene.hpp"
#include "ProjectListDialog.hpp"
#include "WorkbenchNavController.hpp" #include "WorkbenchNavController.hpp"
#include "api/ApiProjectRepository.hpp" #include "api/ApiProjectRepository.hpp"
#include "panels/ObjectTreePanel.hpp" #include "panels/ObjectTreePanel.hpp"
@ -139,7 +138,6 @@ 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) geopro::controller::WorkbenchNavController& nav)
{ {
// ── 世界系:启动取一次 grid1 的 lat/lon用中位数作 GeoLocalFrame 原点 ── // ── 世界系:启动取一次 grid1 的 lat/lon用中位数作 GeoLocalFrame 原点 ──
@ -339,15 +337,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。 // 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
auto* datasetTabs = new QTabWidget(); auto* datasetTabs = new QTabWidget();
auto* datasetList = new QListWidget(); auto* datasetList = new QListWidget();
// 简洁分割:去隔行变色,改为 item 间极淡分割线 + 内边距 + hover/选中反馈(专业、不误导)。 datasetList->setAlternatingRowColors(true);
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(); auto* fileList = new QListWidget(); // M1 文件 tab 占位
fileList->setStyleSheet(datasetList->styleSheet()); // 与数据页签同款简洁分割 { // 空状态引导M1 暂无文件来源,给出说明而非空白面板(识别优先于回忆)。
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(
@ -500,15 +498,10 @@ 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) → 占位(真实剖面/反演渲染下一阶段接 dd 接口)── // ── 单击左下数据列表的采集批次(DS) → 占位(真实剖面/反演渲染下一阶段接 dd 接口)──
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, QObject::connect(datasetList, &QListWidget::itemClicked, datasetList,
[propLabel, detailRendererPtr, detailRenderWindowPtr, &nav](QListWidgetItem* item) { [propLabel, detailRendererPtr, detailRenderWindowPtr](QListWidgetItem* item) {
if (item->data(geopro::app::kDsLoadMoreRole).toBool()) {
nav.loadMoreData();
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);
detailRendererPtr->RemoveAllViewProps(); detailRendererPtr->RemoveAllViewProps();
@ -623,38 +616,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
} }
// ── 控制器 ↔ UI 信号接线(导航壳)────────────────────────────────────── // ── 控制器 ↔ 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, QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
&geopro::controller::WorkbenchNavController::switchWorkspace); &geopro::controller::WorkbenchNavController::switchWorkspace);
QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav, QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav,
&geopro::controller::WorkbenchNavController::switchProject); &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, QObject::connect(objectTree, &geopro::app::ObjectTreePanel::tmClicked, &nav,
&geopro::controller::WorkbenchNavController::selectTm); &geopro::controller::WorkbenchNavController::selectTm);
@ -664,46 +629,24 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
}); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar, QObject::connect(&nav, &geopro::controller::WorkbenchNavController::projectsLoaded, topBar,
[topBar](const std::vector<geopro::data::ProjectSummary>& list, [topBar](const std::vector<geopro::data::ProjectSummary>& list,
const QString& cur, int total) { const QString& cur) { topBar->setProjects(list, cur); });
topBar->setProjects(list, cur, total > static_cast<int>(list.size()));
});
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree, QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree,
[objectTree, datasetList, fileList, datasetTitle, datasetTabs]( [objectTree, datasetList, datasetTitle, datasetTabs](
const QString& projectName, const QString& projectName,
const std::vector<geopro::data::StructNode>& nodes) { const std::vector<geopro::data::StructNode>& nodes) {
objectTree->setStructure(projectName, nodes); objectTree->setStructure(projectName, nodes);
datasetList->clear(); datasetList->clear(); // 切项目清空 DS 列表
fileList->clear();
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏")); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏"));
datasetTabs->setTabText(0, QStringLiteral("数据")); datasetTabs->setTabText(0, QStringLiteral("数据"));
datasetTabs->setTabText(1, QStringLiteral("文件"));
}); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList, QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList,
[removeLoadMore, addLoadMore, datasetList, datasetTitle, datasetTabs]( [datasetList, datasetTitle, datasetTabs](
const QString&, const std::vector<geopro::data::DsRow>& rows, int total, const QString&, const std::vector<geopro::data::DsNode>& list) {
bool append) { geopro::app::populateDatasetList(datasetList, list);
removeLoadMore(datasetList); if (datasetTitle)
geopro::app::populateDatasetList(datasetList, rows, append); datasetTitle->setText(QStringLiteral("数据集显示栏"));
const int loaded = addLoadMore(datasetList, total);
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集显示栏"));
datasetTabs->setTabText( datasetTabs->setTabText(
0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total) 0, QStringLiteral("数据 (%1)").arg(static_cast<int>(list.size())));
: 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, QObject::connect(&nav, &geopro::controller::WorkbenchNavController::loadFailed, objectTree,
[objectTree, &window](const QString& stage, const QString& msg) { [objectTree, &window](const QString& stage, const QString& msg) {
@ -787,7 +730,7 @@ int main(int argc, char* argv[])
window.resize(1280, 800); window.resize(1280, 800);
window.setMinimumSize(1024, 680); // 防止停靠面板被压到不可用尺寸 window.setMinimumSize(1024, 680); // 防止停靠面板被压到不可用尺寸
buildWorkbench(window, repo, projectRepo, nav); buildWorkbench(window, repo, nav);
window.show(); window.show();
nav.start(); // 进入工作台后拉真实 空间/项目/结构 nav.start(); // 进入工作台后拉真实 空间/项目/结构

View File

@ -1,6 +1,5 @@
#include "panels/DatasetListPanel.hpp" #include "panels/DatasetListPanel.hpp"
#include <QColor>
#include <QListWidget> #include <QListWidget>
#include <QListWidgetItem> #include <QListWidgetItem>
#include <QString> #include <QString>
@ -8,48 +7,30 @@
namespace geopro::app { namespace geopro::app {
namespace { namespace {
QString humanSize(long long b) {
if (b < 1024) return QStringLiteral("%1 B").arg(b); // dd 类型 → 中文标注。
const double kb = b / 1024.0; QString ddTypeLabel(const std::string& ddType)
if (kb < 1024.0) return QStringLiteral("%1 KB").arg(kb, 0, 'f', 1); {
return QStringLiteral("%1 MB").arg(kb / 1024.0, 0, 'f', 1); if (ddType == "dd_section") return QStringLiteral("剖面网格");
if (ddType == "dd_voxel") return QStringLiteral("体素");
return QString::fromStdString(ddType);
} }
} // namespace } // namespace
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) { void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsNode>& dss)
{
if (!list) return; if (!list) return;
if (!append) list->clear(); list->clear();
for (const auto& d : rows) { for (const auto& ds : dss) {
QString text = QString::fromStdString(d.dsName); const QString name = QString::fromStdString(ds.name);
QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 const QString label = ddTypeLabel(ds.ddType);
if (!d.typeName.empty()) QString text = name;
sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型 if (!label.isEmpty()) text += QStringLiteral("\n%1").arg(label);
if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub);
auto* item = new QListWidgetItem(text, list);
item->setData(kDsIdRole, QString::fromStdString(d.id));
item->setData(kDsDdTypeRole, QString::fromStdString(d.ddCode));
}
}
void populateFileList(QListWidget* list, const std::vector<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); auto* item = new QListWidgetItem(text, list);
item->setData(kDsIdRole, QString::fromStdString(d.id)); item->setData(kDsIdRole, QString::fromStdString(ds.id));
item->setData(kDsFileUrlRole, QString::fromStdString(d.fileUrl)); item->setData(kDsDdTypeRole, QString::fromStdString(ds.ddType));
} }
} }

View File

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

View File

@ -79,7 +79,9 @@ void ObjectTreePanel::setStructure(const QString& projectName,
} }
hint_->setVisible(false); hint_->setVisible(false);
tree_->setVisible(true); tree_->setVisible(true);
addNodes(tree_->invisibleRootItem(), roots); // 结构已含项目根节点,直接渲染 auto* rootItem = new QTreeWidgetItem(tree_);
rootItem->setText(0, projectName.isEmpty() ? QStringLiteral("项目") : projectName);
addNodes(rootItem, roots);
tree_->expandAll(); tree_->expandAll();
} }

View File

@ -20,7 +20,6 @@ public:
signals: signals:
void tmClicked(const QString& tmObjectId); void tmClicked(const QString& tmObjectId);
// 前瞻钩子:勾选驱动中央渲染留待下一轮接真实 DS本轮暂无消费者
void tmCheckToggled(const QString& tmObjectId, bool checked); void tmCheckToggled(const QString& tmObjectId, bool checked);
private: private:

View File

@ -8,27 +8,11 @@ using data::Workspace;
WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent) WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent)
: QObject(parent), repo_(repo) {} : 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() { void WorkbenchNavController::start() {
if (busy_) return; emit busyChanged(true);
BusyGuard guard(this, &busy_);
const auto ws = repo_.listWorkspaces(); const auto ws = repo_.listWorkspaces();
if (!ws.ok) { if (!ws.ok) {
emit busyChanged(false);
emit loadFailed(QStringLiteral("workspaces"), QString::fromStdString(ws.error)); emit loadFailed(QStringLiteral("workspaces"), QString::fromStdString(ws.error));
return; return;
} }
@ -38,19 +22,21 @@ void WorkbenchNavController::start() {
if (cur.isEmpty() && !ws.value.empty()) cur = QString::fromStdString(ws.value.front().id); if (cur.isEmpty() && !ws.value.empty()) cur = QString::fromStdString(ws.value.front().id);
currentWorkspaceId_ = cur.toStdString(); currentWorkspaceId_ = cur.toStdString();
emit workspacesLoaded(ws.value, cur); emit workspacesLoaded(ws.value, cur);
loadProjectsAndStructure(); loadProjectsAndStructure();
emit busyChanged(false);
} }
void WorkbenchNavController::loadProjectsAndStructure() { void WorkbenchNavController::loadProjectsAndStructure() {
const auto ps = repo_.pageProjects(std::string(), std::string(), 1, 10); // 下拉首页 10 const auto ps = repo_.listProjects(std::string());
if (!ps.ok) { if (!ps.ok) {
emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error)); emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error));
return; return;
} }
lastProjects_ = ps.value.rows; lastProjects_ = ps.value;
QString curP; QString curP;
if (!ps.value.rows.empty()) { if (!ps.value.empty()) {
const auto& first = ps.value.rows.front(); const auto& first = ps.value.front();
curP = QString::fromStdString(first.id); curP = QString::fromStdString(first.id);
currentProjectId_ = first.id; currentProjectId_ = first.id;
currentProjectName_ = first.name; currentProjectName_ = first.name;
@ -60,7 +46,7 @@ void WorkbenchNavController::loadProjectsAndStructure() {
currentProjectName_.clear(); currentProjectName_.clear();
currentCrsCode_.clear(); currentCrsCode_.clear();
} }
emit projectsLoaded(ps.value.rows, curP, ps.value.total); emit projectsLoaded(ps.value, curP);
if (curP.isEmpty()) { if (curP.isEmpty()) {
emit structureLoaded(QString(), {}); // 暂无项目 → 空树 emit structureLoaded(QString(), {}); // 暂无项目 → 空树
@ -75,20 +61,22 @@ void WorkbenchNavController::loadProjectsAndStructure() {
} }
void WorkbenchNavController::switchWorkspace(const QString& tenantId) { void WorkbenchNavController::switchWorkspace(const QString& tenantId) {
if (tenantId.isEmpty() || busy_) return; if (tenantId.isEmpty()) return;
BusyGuard guard(this, &busy_); emit busyChanged(true);
const auto r = repo_.switchWorkspace(tenantId.toStdString()); const auto r = repo_.switchWorkspace(tenantId.toStdString());
if (!r.ok) { if (!r.ok) {
emit busyChanged(false);
emit loadFailed(QStringLiteral("switchWorkspace"), QString::fromStdString(r.error)); emit loadFailed(QStringLiteral("switchWorkspace"), QString::fromStdString(r.error));
return; return;
} }
currentWorkspaceId_ = tenantId.toStdString(); currentWorkspaceId_ = tenantId.toStdString();
loadProjectsAndStructure(); loadProjectsAndStructure();
emit busyChanged(false);
} }
void WorkbenchNavController::switchProject(const QString& projectId) { void WorkbenchNavController::switchProject(const QString& projectId) {
if (projectId.isEmpty() || busy_) return; if (projectId.isEmpty()) return;
BusyGuard guard(this, &busy_); emit busyChanged(true);
currentProjectId_ = projectId.toStdString(); currentProjectId_ = projectId.toStdString();
for (const auto& p : lastProjects_) for (const auto& p : lastProjects_)
if (p.id == currentProjectId_) { if (p.id == currentProjectId_) {
@ -97,57 +85,24 @@ void WorkbenchNavController::switchProject(const QString& projectId) {
} }
const auto st = repo_.loadStructure(currentProjectId_); const auto st = repo_.loadStructure(currentProjectId_);
if (!st.ok) { if (!st.ok) {
emit busyChanged(false);
emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error)); emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error));
return; return;
} }
emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);
emit busyChanged(false);
} }
void WorkbenchNavController::selectTm(const QString& tmObjectId) { void WorkbenchNavController::selectTm(const QString& tmObjectId) {
if (tmObjectId.isEmpty() || busy_) return; if (tmObjectId.isEmpty()) return;
BusyGuard guard(this, &busy_); emit busyChanged(true);
currentTmId_ = tmObjectId.toStdString(); const auto ds = repo_.loadDatasetsOfTm(tmObjectId.toStdString());
const std::string pid = currentProjectId_; emit busyChanged(false);
dataPageNo_ = 1; if (!ds.ok) {
filePageNo_ = 1; emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(ds.error));
const auto d = repo_.loadTmRows(pid, currentTmId_, 3, dataPageNo_);
if (!d.ok) {
emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error));
return; return;
} }
dataTotal_ = d.value.total; emit datasetsLoaded(tmObjectId, ds.value);
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 } // namespace geopro::controller

View File

@ -22,33 +22,21 @@ public slots:
void switchWorkspace(const QString& tenantId); void switchWorkspace(const QString& tenantId);
void switchProject(const QString& projectId); void switchProject(const QString& projectId);
void selectTm(const QString& tmObjectId); void selectTm(const QString& tmObjectId);
void loadMoreData();
void loadMoreFiles();
signals: signals:
void busyChanged(bool busy); void busyChanged(bool busy);
void workspacesLoaded(const std::vector<geopro::data::Workspace>& list, const QString& currentId); void workspacesLoaded(const std::vector<geopro::data::Workspace>& list, const QString& currentId);
void projectsLoaded(const std::vector<geopro::data::ProjectSummary>& list, void projectsLoaded(const std::vector<geopro::data::ProjectSummary>& list, const QString& currentId);
const QString& currentId, int total);
void structureLoaded(const QString& projectName, const std::vector<geopro::data::StructNode>& nodes); void structureLoaded(const QString& projectName, const std::vector<geopro::data::StructNode>& nodes);
void datasetsLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsRow>& rows, void datasetsLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsNode>& list);
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); void loadFailed(const QString& stage, const QString& message);
private: private:
void loadProjectsAndStructure(); // start + switchWorkspace 共用 void loadProjectsAndStructure(); // start + switchWorkspace 共用
data::IProjectRepository& repo_; data::IProjectRepository& repo_;
bool busy_ = false;
std::vector<data::ProjectSummary> lastProjects_; std::vector<data::ProjectSummary> lastProjects_;
std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
std::string currentTmId_;
int dataPageNo_ = 0;
int filePageNo_ = 0;
int dataTotal_ = 0;
int fileTotal_ = 0;
}; };
} // namespace geopro::controller } // namespace geopro::controller

View File

@ -3,7 +3,6 @@
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
#include <QUrl>
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "dto/NavDto.hpp" #include "dto/NavDto.hpp"
@ -20,11 +19,6 @@ std::string errorOf(const net::ApiResponse& r, const char* fallback) {
if (!r.rawError.isEmpty()) return r.rawError.toStdString(); if (!r.rawError.isEmpty()) return r.rawError.toStdString();
return fallback; return fallback;
} }
// 后端 id 进 URL 前做百分号编码(不可信外部数据:防 ? # & / 空格 破坏路径/查询)。
QString enc(const std::string& s) {
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
}
} // namespace } // namespace
ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {} ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {}
@ -38,57 +32,35 @@ RepoResult<std::vector<Workspace>> ApiProjectRepository::listWorkspaces() {
RepoResult<bool> ApiProjectRepository::switchWorkspace(const std::string& tenantId) { RepoResult<bool> ApiProjectRepository::switchWorkspace(const std::string& tenantId) {
const QString path = const QString path =
QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId)); QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(QString::fromStdString(tenantId));
const net::ApiResponse r = api_.postJson(path, QJsonObject{}); const net::ApiResponse r = api_.postJson(path, QJsonObject{});
if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")}; 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, {}}; return {true, true, {}};
} }
RepoResult<ProjectListPage> ApiProjectRepository::pageProjects(const std::string& nameFilter, RepoResult<std::vector<ProjectSummary>> ApiProjectRepository::listProjects(
const std::string& typeId, int pageNo, const std::string& lastProjectId) {
int pageSize) { const QString path = QStringLiteral("/business/project/queryByUser?lastProjectId=%1")
QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)}, .arg(QString::fromStdString(lastProjectId));
{QStringLiteral("pageNo"), pageNo}, const net::ApiResponse r = api_.get(path);
{QStringLiteral("pageSize"), pageSize}}; if (!ok(r)) return {false, {}, errorOf(r, "listProjects failed")};
if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId); return {true, dto::parseProjects(r.data).projects, {}};
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) { RepoResult<std::vector<StructNode>> ApiProjectRepository::loadStructure(const std::string& projectId) {
// 项目结构(项目根 + GS + TM不含 DS。比 projectWorkbench 干净。 const QJsonObject body{{QStringLiteral("projectId"), QString::fromStdString(projectId)}};
const QString path = const net::ApiResponse r =
QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId)); api_.postJson(QStringLiteral("/business/projectWorkbench/queryProjectStruct"), body);
const net::ApiResponse r = api_.get(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")}; if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")};
return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}}; return {true, dto::parseStructNodes(r.data.value(QStringLiteral("projectStructList")).toArray()), {}};
} }
RepoResult<DsPage> ApiProjectRepository::loadTmRows(const std::string& projectId, RepoResult<std::vector<DsNode>> ApiProjectRepository::loadDatasetsOfTm(const std::string& tmObjectId) {
const std::string& tmObjectId, int classifyType, const QString path = QStringLiteral("/business/projectWorkbench/queryDsByTmObjectId/%1")
int pageNo) { .arg(QString::fromStdString(tmObjectId));
const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") const net::ApiResponse r = api_.get(path);
: QStringLiteral("/business/dsObject/data/page"); if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetsOfTm failed")};
const QJsonObject body{ return {true, dto::parseDatasets(r.data.value(QStringLiteral("value")).toArray()), {}};
{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 } // namespace geopro::data

View File

@ -12,12 +12,9 @@ public:
RepoResult<std::vector<Workspace>> listWorkspaces() override; RepoResult<std::vector<Workspace>> listWorkspaces() override;
RepoResult<bool> switchWorkspace(const std::string& tenantId) override; RepoResult<bool> switchWorkspace(const std::string& tenantId) override;
RepoResult<ProjectListPage> pageProjects(const std::string& nameFilter, const std::string& typeId, RepoResult<std::vector<ProjectSummary>> listProjects(const std::string& lastProjectId) override;
int pageNo, int pageSize) override;
RepoResult<std::vector<ProjectType>> listProjectTypes() override;
RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) override; RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) override;
RepoResult<DsPage> loadTmRows(const std::string& projectId, const std::string& tmObjectId, RepoResult<std::vector<DsNode>> loadDatasetsOfTm(const std::string& tmObjectId) override;
int classifyType, int pageNo) override;
private: private:
net::ApiClient& api_; net::ApiClient& api_;

View File

@ -11,22 +11,6 @@ namespace {
std::string str(const QJsonObject& o, const char* key) { std::string str(const QJsonObject& o, const char* key) {
return o.value(QString::fromLatin1(key)).toString().toStdString(); 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 } // namespace
std::vector<Workspace> parseWorkspaces(const QJsonArray& arr) { std::vector<Workspace> parseWorkspaces(const QJsonArray& arr) {
@ -49,32 +33,18 @@ ProjectPage parseProjects(const QJsonObject& data) {
page.hasNextPage = data.value(QStringLiteral("hasNextPage")).toBool(); page.hasNextPage = data.value(QStringLiteral("hasNextPage")).toBool();
const QJsonArray list = data.value(QStringLiteral("projectList")).toArray(); const QJsonArray list = data.value(QStringLiteral("projectList")).toArray();
page.projects.reserve(static_cast<size_t>(list.size())); page.projects.reserve(static_cast<size_t>(list.size()));
for (const QJsonValue& v : list) page.projects.push_back(parseProjectItem(v.toObject())); for (const QJsonValue& v : list) {
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(); const QJsonObject o = v.toObject();
out.push_back(ProjectType{str(o, "id"), str(o, "name")}); ProjectSummary p;
p.id = str(o, "id");
p.name = str(o, "projectName");
p.typeName = str(o, "projectTypeName");
p.crsCode = str(o, "referenceCRSCode");
p.crsName = str(o, "referenceCRSName");
p.status = o.value(QStringLiteral("status")).toInt();
page.projects.push_back(std::move(p));
} }
return out; return page;
} }
std::vector<StructNode> parseStructNodes(const QJsonArray& arr) { std::vector<StructNode> parseStructNodes(const QJsonArray& arr) {
@ -94,58 +64,47 @@ std::vector<StructNode> parseStructNodes(const QJsonArray& arr) {
return out; return out;
} }
std::vector<DsRow> parseDsRows(const QJsonArray& arr) { std::vector<DsNode> parseDatasets(const QJsonArray& arr) {
std::vector<DsRow> out; std::vector<DsNode> out;
out.reserve(static_cast<size_t>(arr.size())); out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) { for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject(); const QJsonObject o = v.toObject();
DsRow d; DsNode d;
d.id = str(o, "id"); d.id = str(o, "id");
d.dsName = str(o, "dsName"); d.name = str(o, "name");
d.typeName = str(o, "name"); // 注意name 字段=ds类型名 d.ddType = str(o, "ddCode");
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)); out.push_back(std::move(d));
} }
return out; 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) { 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; std::set<std::string> ids;
for (const auto& n : nodes) ids.insert(n.id); std::set<std::string> hasChild;
// 根层parentId 为空 / "0" / 不在集合内(孤儿)。 for (const auto& n : flat) {
ids.insert(n.id);
if (!n.parentId.empty()) hasChild.insert(n.parentId);
}
// 叶子(无子节点)= TM。
auto isLeaf = [&](const std::string& id) { return hasChild.find(id) == hasChild.end(); };
// 根层节点parentId 为空或 parentId 不在 id 集合内(孤儿)。
auto isRootLevel = [&](const StructNode& n) { auto isRootLevel = [&](const StructNode& n) {
return n.parentId.empty() || n.parentId == "0" || ids.find(n.parentId) == ids.end(); return n.parentId.empty() || ids.find(n.parentId) == ids.end();
}; };
std::set<std::string> visited; // 防环:每个 id 最多进树一次。 // visited 防环:每个 id 最多进树一次。对正常树(单父)等价于原逻辑;
// 对不可信后端数据的多节点环 / 重复 id 环,避免无限递归(规约:永不信任外部数据)。
std::set<std::string> visited;
std::function<std::vector<StructTreeNode>(const std::string&, bool)> build = std::function<std::vector<StructTreeNode>(const std::string&, bool)> build =
[&](const std::string& parentId, bool root) { [&](const std::string& parentId, bool root) {
std::vector<StructTreeNode> out; std::vector<StructTreeNode> out;
for (const auto& n : nodes) { for (const auto& n : flat) {
const bool belongs = root ? isRootLevel(n) : (n.parentId == parentId); const bool belongs = root ? isRootLevel(n) : (n.parentId == parentId);
if (!belongs) continue; if (!belongs) continue;
if (visited.count(n.id)) continue; if (visited.count(n.id)) continue; // 已进树 → 跳过,防环/防重复
visited.insert(n.id); visited.insert(n.id);
StructTreeNode t; StructTreeNode t;
t.node = n; t.node = n;
t.isTm = (n.type == 2); // type: 1=项目根 2=TM(测线) 3=DS(已过滤) t.isTm = isLeaf(n.id);
t.children = build(n.id, false); t.children = build(n.id, false);
out.push_back(std::move(t)); out.push_back(std::move(t));
} }

View File

@ -13,22 +13,11 @@ std::vector<Workspace> parseWorkspaces(const QJsonArray& arr);
struct ProjectPage { std::vector<ProjectSummary> projects; bool hasNextPage = false; }; struct ProjectPage { std::vector<ProjectSummary> projects; bool hasNextPage = false; };
ProjectPage parseProjects(const QJsonObject& data); ProjectPage parseProjects(const QJsonObject& data);
// my/profile/queryProject 的 data["value"] 数组 → 模型(与 parseProjects 同字段映射)。
std::vector<ProjectSummary> parseProjectList(const QJsonArray& arr);
// my/profile/project/page 的整个 data 对象 → ProjectListPagerows + total
ProjectListPage parseProjectPage(const QJsonObject& data);
// project/type/list 的 data["value"] 数组 → 项目类型列表。
std::vector<ProjectType> parseProjectTypes(const QJsonArray& arr);
// 结构扁平节点数组queryProjectStruct 的 data["projectStructList"])→ 模型。 // 结构扁平节点数组queryProjectStruct 的 data["projectStructList"])→ 模型。
std::vector<StructNode> parseStructNodes(const QJsonArray& arr); std::vector<StructNode> parseStructNodes(const QJsonArray& arr);
// data/page / file/page 的 data["list"] 数组 → DsRow数据行无 file文件行含 file{name,size,url})。 // DS 聚合数组queryDsByTmObjectId 的 data["value"])→ DsNode。ddCode → ddType。
std::vector<DsRow> parseDsRows(const QJsonArray& arr); std::vector<DsNode> parseDatasets(const QJsonArray& arr);
// data/page 或 file/page 的整个 data 对象 → DsPagerows + total
DsPage parseDsPage(const QJsonObject& data);
// 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理项目直挂 TM、孤儿 parentId、空表。 // 扁平 StructNode 按 parentId 建树。叶子(无子节点)=TM。处理项目直挂 TM、孤儿 parentId、空表。
struct StructTreeNode { struct StructTreeNode {

View File

@ -19,17 +19,9 @@ public:
virtual ~IProjectRepository() = default; virtual ~IProjectRepository() = default;
virtual RepoResult<std::vector<Workspace>> listWorkspaces() = 0; virtual RepoResult<std::vector<Workspace>> listWorkspaces() = 0;
virtual RepoResult<bool> switchWorkspace(const std::string& tenantId) = 0; virtual RepoResult<bool> switchWorkspace(const std::string& tenantId) = 0;
// 项目分页nameFilter 名称模糊可空、typeId 类型过滤(空=不限、pageNo 从 1 起。 virtual RepoResult<std::vector<ProjectSummary>> listProjects(const std::string& lastProjectId) = 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; virtual RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) = 0;
// 按 TM 分页拉数据/文件行classifyType 3=数据 1=文件pageNo 从 1 起pageSize 固定 5。 virtual RepoResult<std::vector<DsNode>> loadDatasetsOfTm(const std::string& tmObjectId) = 0;
virtual RepoResult<DsPage> loadTmRows(const std::string& projectId,
const std::string& tmObjectId, int classifyType,
int pageNo) = 0;
}; };
} // namespace geopro::data } // namespace geopro::data

View File

@ -3,14 +3,6 @@
#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; };
@ -18,17 +10,8 @@ struct Project { std::string id, name; std::vector<GsNode> gss; };
// 工作空间(=企业租户/空间。ownerType: 1 个人空间 2 企业空间。 // 工作空间(=企业租户/空间。ownerType: 1 个人空间 2 企业空间。
struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; }; struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; };
// 项目摘要(下拉/弹窗列表用。crsCode 供下一轮替换硬编码 EPSG:4547。 // 项目摘要列表用。crsCode/crsName 为项目参考坐标系,下一轮替换硬编码 EPSG:4547。
struct ProjectSummary { struct ProjectSummary { std::string id, name, typeName, crsCode, crsName; int status = 0; };
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。 // 项目结构扁平节点(仅 GS / TM。客户端按 parentId 建树,叶子=TM。
struct StructNode { std::string id, name, parentId, typeName, confCode; int type = 0; }; struct StructNode { std::string id, name, parentId, typeName, confCode; int type = 0; };

View File

@ -64,6 +64,17 @@ TEST(NavDto, ParseStructNodesMapsParentAndType) {
EXPECT_EQ(ns[1].type, 2); EXPECT_EQ(ns[1].type, 2);
} }
TEST(NavDto, ParseDatasetsMapsDdCodeToDdType) {
const auto arr = arrOf(R"([
{"id":"ds1","name":"批次1","ddCode":"dd_section","typeName":"剖面"}
])");
const auto ds = dto::parseDatasets(arr);
ASSERT_EQ(ds.size(), 1u);
EXPECT_EQ(ds[0].id, "ds1");
EXPECT_EQ(ds[0].name, "批次1");
EXPECT_EQ(ds[0].ddType, "dd_section");
}
TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) { TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) {
const std::vector<StructNode> flat = { const std::vector<StructNode> flat = {
{"gs1", "工区1", "", "GS", "", 1}, {"gs1", "工区1", "", "GS", "", 1},
@ -108,96 +119,3 @@ TEST(NavDto, BuildStructTreeHandlesCycleWithoutInfiniteRecursion) {
ASSERT_EQ(roots.size(), 1u); ASSERT_EQ(roots.size(), 1u);
EXPECT_EQ(roots[0].node.id, "R"); EXPECT_EQ(roots[0].node.id, "R");
} }
TEST(NavDto, ParseProjectsEmptyAndMissingListGraceful) {
EXPECT_TRUE(dto::parseProjects(objOf(R"({})")).projects.empty());
EXPECT_FALSE(dto::parseProjects(objOf(R"({"hasNextPage":false})")).hasNextPage);
const auto p = dto::parseProjects(objOf(R"({"projectList":[]})"));
EXPECT_TRUE(p.projects.empty());
}
TEST(NavDto, ParseProjectListArrayMapsItem) {
const auto arr = arrOf(R"([
{"id":"p1","projectName":"演示","projectTypeName":"ERT","referenceCRSCode":"EPSG:4547","status":1}
])");
const auto v = dto::parseProjectList(arr);
ASSERT_EQ(v.size(), 1u);
EXPECT_EQ(v[0].id, "p1");
EXPECT_EQ(v[0].name, "演示");
EXPECT_EQ(v[0].crsCode, "EPSG:4547");
}
TEST(NavDto, BuildStructTreeDropsDsAndTmStaysLeaf) {
// 真实形态:项目(1) → TM(2) → DS(3)。DS 不进树;带 DS 子节点的 TM 仍是 TM 叶子。
const std::vector<StructNode> flat = {
{"P", "项目", "0", "PRJ", "", 1},
{"T1", "ERT1", "P", "ERT", "ERT", 2},
{"D1", "批次1","T1", "", "", 3}, // DS应被过滤
{"T2", "ERT2", "P", "ERT", "ERT", 2},
};
const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 1u); // 仅项目根parentId "0"
EXPECT_EQ(roots[0].node.id, "P");
EXPECT_FALSE(roots[0].isTm); // 项目根 type1
ASSERT_EQ(roots[0].children.size(), 2u); // T1、T2D1 被过滤)
EXPECT_EQ(roots[0].children[0].node.id, "T1");
EXPECT_TRUE(roots[0].children[0].isTm); // TM type2
EXPECT_TRUE(roots[0].children[0].children.empty()); // DS 不进树
}
TEST(NavDto, ParseDsRowsDataAndFile) {
const auto d = dto::parseDsRows(arrOf(R"([
{"id":"d1","dsName":"ERT1-WS","name":"电阻率数据","ddCode":"dd_inversion_data","createTime":"2026-03-25 16:48:57"}
])"));
ASSERT_EQ(d.size(), 1u);
EXPECT_EQ(d[0].id, "d1");
EXPECT_EQ(d[0].dsName, "ERT1-WS");
EXPECT_EQ(d[0].typeName, "电阻率数据");
EXPECT_EQ(d[0].createTime, "2026-03-25 16:48:57");
EXPECT_TRUE(d[0].fileName.empty());
const auto f = dto::parseDsRows(arrOf(R"([
{"id":"f1","dsName":"ERT1-WS.xlsx","name":"","ddCode":"dd_file",
"file":{"name":"ERT1-WS.xlsx","size":62760,"url":"/common/file/x.xlsx"}}
])"));
ASSERT_EQ(f.size(), 1u);
EXPECT_EQ(f[0].fileName, "ERT1-WS.xlsx");
EXPECT_EQ(f[0].fileSize, 62760);
EXPECT_EQ(f[0].fileUrl, "/common/file/x.xlsx");
const auto page = dto::parseDsPage(objOf(R"({
"total": 18,
"list": [{"id":"x","dsName":"a","name":"t","ddCode":"dd","createTime":"2026-01-01 00:00:00"}]
})"));
EXPECT_EQ(page.total, 18);
ASSERT_EQ(page.rows.size(), 1u);
EXPECT_EQ(page.rows[0].dsName, "a");
}
TEST(NavDto, ParseProjectItemFullFields) {
const auto v = dto::parseProjectList(arrOf(R"([
{"id":"p1","projectName":"演示","projectCode":"001","status":2,
"projectTypeId":"t1","projectTypeName":"全量类型",
"ownerCompanyName":"华南所","responsiblePersonName":"张三","createTime":"2026-01-01 00:00:00"}
])"));
ASSERT_EQ(v.size(), 1u);
EXPECT_EQ(v[0].code, "001");
EXPECT_EQ(v[0].status, 2);
EXPECT_EQ(v[0].projectTypeId, "t1");
EXPECT_EQ(v[0].typeName, "全量类型");
EXPECT_EQ(v[0].ownerCompany, "华南所");
EXPECT_EQ(v[0].responsiblePerson, "张三");
EXPECT_EQ(v[0].createTime, "2026-01-01 00:00:00");
}
TEST(NavDto, ParseProjectPageAndTypes) {
const auto page =
dto::parseProjectPage(objOf(R"({"total":20,"list":[{"id":"p1","projectName":"a"}]})"));
EXPECT_EQ(page.total, 20);
ASSERT_EQ(page.rows.size(), 1u);
EXPECT_EQ(page.rows[0].name, "a");
const auto types = dto::parseProjectTypes(arrOf(R"([{"id":"t1","name":""}])"));
ASSERT_EQ(types.size(), 1u);
EXPECT_EQ(types[0].id, "t1");
EXPECT_EQ(types[0].name, "全量类型");
}