feat/object-dataset-interactions #6

Merged
gaozheng merged 2 commits from feat/object-dataset-interactions into main 2026-06-15 12:18:43 +08:00
35 changed files with 3311 additions and 43 deletions

View File

@ -0,0 +1,68 @@
# 对象视图 / 数据集视图 交互操作 — 实现计划
依据:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「客户端」页签(菜单/交互规格,权威)。
后端提交体:见 `../specs/2026-06-13-batch2-object-dataset-dialogs.md`§B 提交体 / §E 源码补全,**均已由线上压缩源码确证**)。
约定:二维/三维相关交互先占位;插件机制**暂缓**(待与用户深入讨论)。
---
## 一、已完成(已编译 + 单测通过)
### Batch 1 — 交互骨架 + 读联动 + 删除 + 筛选
- ApiClient `putJsonAsync`/`deleteAsync`删除真实GS/TM/DS+ 控制器 `mutationSucceeded/Failed` + 刷新。
- 对象树右键菜单9 项):属性/异常详情/删除 真实;显示隐藏/定位=占位。
- 数据集右键菜单:详情/属性/删除 真实。
- 快速筛选器(对象树、数据集);数据集单击 tooltip手势修复 #1(复选框命中判定)。
### Batch 2 — 动态表单引擎 + 编辑/新建(⚠️ 提交体字段需修正,见下)
- `DynamicFormEditor`(按 getDynamicForm 渲染)、`ObjectFormDialog`(拉 schema→渲染→校验→提交
- 编辑保存 PUT、新建 TM POST、插件列出 `model/list`(点击占位)。
- 数据层 DTO/parse/repo/controller 齐备。
> ⚠️ Batch 2 的**提交体字段是早期错误版本**`typeId/type/structParentId/structParentConfType`
> 与源码确证的真实字段不符,须按下方 P0 修正后才能真正保存成功。
---
## 二、关键认知2026-06-15源码已确证
- 提交体以 **bundle 源码为准**线上前端真实请求构造OpenAPI 为有误的生成文档)。详见 spec §B / §E。
- 菜单规格以 **xlsx 为准**(现有菜单结构已对齐,勿按原型删减)。
- 已确证GS/TM 提交体字段、`properties.<fieldCode>` 嵌套、displayComponentType 全集映射、
requiredType1=必填 / 2=只读 / 其他=可选)、导出 body。
- **唯一开放点:插件机制**web 无"ds→插件"交互xlsx 为客户端原创设计)→ 暂缓,待讨论。
---
## 三、实现计划(不含插件;每步带验证)
| 优先 | 任务 | 验证 |
|---|---|---|
| **P0** | **修提交体字段**`ObjectFormDialog.onConfirm`GS→`{gsTypeId, parentId(仅新建), name, responsiblePersonName, properties}`TM→`{tmTypeId, parentId, parentType:"1", name, properties}`(+编辑 id)。删除 `typeId/type/structParentId/structParentConfType`。 | 字段比对 spec §B真实保存成功写操作留用户实测 |
| **P0** | **`DynamicFormEditor` 按 §E.1 全集重写控件映射**1/5文本·2只读·3复选·4下拉·6日期·7时间·8日期时间·9多行·10数字·11树选·默认步进数字`requiredType` 2=只读禁用。 | 各 comp 类型渲染正确、只读字段禁用 |
| **P0** | **顶层固定字段**GS 加 `gsTypeId` 下拉(来源 `GET /business/project/gsList/{projectId}``[{name,gsTypeId}]`) + `responsiblePersonName` + `name`TM 新建加 `tmTypeId` 下拉(来源 `queryTmType?projectId=&gsId=`) + `name`。 | 新建表单含这些项;编辑态正确预填/禁用 |
| **P1** | **项目根节点按 GS 处理**:可右键「新增检测对象(GS)/新增方法对象(TM)/属性」;`parentId`=根 id。 | 根节点弹菜单、新建走对应对话框 |
| **P1** | **新建 GS 打通**右键新建GS → getDynamicForm(type=1,无id) → 表单 → `POST /gsObject`。 | 弹表单、提交(写操作留用户实测) |
| **P1** | **新建 TM 修正**:方法类型 `queryTmType`;提交体随 P0父对象=右键节点。 | 弹表单、提交 |
| **P2** | **导入 DS 向导**TM 右键「导入DS(1..n)」→ 选类型 → getDynamicForm(type=6 脚本参数) → 选文件 → `checkImport``import`(multipart `{file,dsTypeId,projectId,structParentConfType,structParentId,scriptCode,scriptParamListJsonStr}`)。 | 流程走通、文件上传 |
| **P2** | **导出对话框**:数据集右键「导出」→ 选模板 → `POST /templateExport/export {dsObjectIdList:[该ds],templateId}`;模板来源 localStorage `template`.fileTemplateList / `queryFileType`。 | 导出触发 |
---
## 三补、其余"可做但未做"的编辑操作2026-06-15 盘点)
| 优先 | 任务 | 来源/接口 | 状态 |
|---|---|---|---|
| **P1** | **#1 数据集描述编辑保存**(原版"数据集属性可编辑"实为**富文本描述/备注**编辑) | 加载 `GET /dsObject/getDetail/{id}`.attachedParameters.deltaContent保存 `PUT /dsObject/updateDsObject/ {dsObjectId, description, attachedParameters:{deltaContent}}`(源码确证) | **本轮做**:客户端无 Quill简化为纯文本/富文本 QTextEditdeltaContent 用最小 delta 承载 |
| **P1** | **#3 面板级「添加+」按钮**对象列表→新建GS/TM数据集列表→导入 | 纯前端,复用现有 newGs/newTm/import 入口 | **本轮做** |
| P2 | #4 异常→异常体 拖拽合并(对象异常面板) | `exception/*`(合并接口待查) | 未做 |
| P2 | #5 数据集任务:新增处理任务/保存处理结果 | 与模型/插件流程耦合 | 未做(随插件) |
| P3 | #2 属性面板内联编辑 + 实时跳转(属性指向另一 ds 时点击跳转新建详情页) | — | 未做 |
| P3 | #6 数据详情处理类编辑(异常标注/色阶/白化/滤波/另存为) | 与渲染耦合 | 未做(随 2D/3D 或专项) |
## 四、暂缓 / 排除
- **插件机制**spec §E.3web 无对应交互,需产品/后端决策(客户端反查 `model/list`+各模型 `dsObjectList`,或后端加"按 ds 列模型"接口)。**待与用户深入讨论后再实现。**
- **2D/3D 相关**:显示/隐藏、定位、双击地图获焦 —— 占位,随 2D/3D 批次。
- GS 勾选三态语义(屏蔽不改状态、可恢复)、项目根勾选 —— 随 2D/3D 批次。
- 动态表单只读规则细节:以 §E.1 的 `displayComponentType=2` / `requiredType=2` 为准。

View File

@ -0,0 +1,212 @@
# Batch 2 实施依据:对象/数据集对话框(新建/编辑/导入/导出/插件)
实地研究原系统 `http://tenant.geomative.cn`(项目「香港威立雅」`projectId=1439735554211840`
projectTypeId `1445121423155200`所得。API 经页面 token replay + 真实 DOM 操作 + fetch 录制捕获。
> 口径:成功码 `code==200`;列表载荷在 `data`(非对象时包成 `data.value`)。
> token 头 `geomativeauthorization`base `http://tenant.geomative.cn/pop-api`
---
## 更新2026-06-15权威来源已厘清前述"⚠️未捕获"壁垒解除
三个来源各司其职,别再混用:
- **xlsx「客户端」页签** = 客户端菜单/交互**规格(权威)**。
- **原 web 压缩 bundle = 提交体的"地面真相"(首要权威)**:线上正在跑、能成功建对象的真实前端代码,它发什么服务端就收什么。
bundle 抓 `http://tenant.geomative.cn/static/js/` 各路由 chunk 后 grep 还原:
`GsForm-*.js`(GS表单) / `tmForm-D7d9h9Nb.js`(TM表单,数据管理页) / `ImportForm-*.js`(导入) / `projectStructure-*.js`
- **`docs/apis` OpenAPI = 交叉校验(次级)**:自动生成、已知有缺漏/错标(如 GS 名称字段标成 `projectName`)。**与 bundle 冲突时以 bundle 为准**,并在 §B 并列标注、留一次真实请求验证。
- **原型 `prototype.geomative.cn`** = 粗略视觉 mockup菜单画不全**不作为菜单删减依据**。
### A. xlsx 规定的菜单(= 现有实现已对齐,勿按原型删减)
| 区域 | 菜单/动作xlsx 原文) | 对话框来源xlsx 标注) |
|---|---|---|
| 对象-添加+ | 新建GS当前为TM时无效、新建TM | GS→`项目配置\项目结构\添加`TM→`数据管理\新增方法对象` |
| 对象-右键 | 显示/隐藏、定位、属性、异常详情、编辑、新建GS、新建TM、导入DS(1..n)、删除 | 编辑→`数据管理\编辑`;导入→`数据管理\导入` |
| 数据集-添加+ | 新建/导入一个数据集 | `数据管理\可新建数据类型\导入` |
| 数据集-右键 | 数据集详情、属性、插件1/2/3…、导出、删除 | 导出→`批量导出\文件导出` |
xlsx 第32行"显示项目(**也是一个GS**"——**项目根本身就是 GS 节点**。最强证据是真实数据:`queryProjectStruct` 返回的结构里确有 TM(type=2) 直接挂在项目根(type=1) 下(如 NERT1、"123")——若根不允许建 TM该数据无从产生。故**项目根(作为 GS允许新建 TM**。
### B. 提交体(**bundle 为准OpenAPI 并列校验**,替换下文旧"⚠️未捕获"
bundle 还原(= 客户端应照此发送):
```
POST /business/gsObject { gsTypeId, parentId, projectId, name, responsiblePersonName, properties }
PUT /business/gsObject { gsTypeId, id, projectId, name, responsiblePersonName, properties } // 编辑无 parentId
· gsTypeId(测试对象类型)、responsiblePersonName(负责人)、name 为顶层固定字段;动态字段在 properties
· parentId 仅 POST 用 = 右键所在节点 id含项目根
POST /business/tmObject { tmTypeId, name, properties, projectId, parentId, parentType:"1" }
PUT /business/tmObject { tmTypeId, id, name, properties, projectId, parentId, parentType:"1" }
· bundle 中 create/edit 用同一 spread故 PUT 也带 parentId/parentType
· parentId = 右键所在节点 idGS 或项目根parentType 恒字符串 "1"
POST /business/dsObject/import (参数走 query stringfile 为 multipart 上传项)
{ file*, dsTypeId*, projectId*, structParentConfType*(int), structParentId*, scriptCode?, scriptParamListJsonStr?, aliasName? }
· 选脚本先 getDynamicForm(typeId=scriptId, type=6) 取脚本参数checkImport 校验坐标
```
getDynamicForm 的 `type`**1=GS对象 / 2=TM对象 / 6=导入脚本参数**(已确认)。
**✅ 线上实测已确认intercept-abort未写库动态字段嵌套在 `properties.<fieldCode>` 下。**
证据:编辑弹窗真实字段 DOM id —— GS `properties_topography/geotechnical/climate/hydrology`、TM `properties_supervisor/notes`顶层固定字段gsTypeId/responsiblePersonName/name、tmTypeId/name与 properties 分开渲染。
**bundle ↔ OpenAPI 冲突(已由压缩源码分析确证,以 bundle 为准OpenAPI 为文档生成误差):**
下表结论来自 GsForm/tmForm 压缩源码还原(= 线上前端真实请求构造)。线上 app 正是发这些 body 且能成功建/改对象故服务端接受度亦由线上运行佐证。OpenAPI 的 `projectName`/整数等是自动生成的文档误差,不采信。
| 字段 | bundle线上真实代码 | OpenAPI生成文档 | 取舍 | 实测状态(含源码分析) |
|---|---|---|---|---|
| GS 名称 | 顶层 `name` | POST 标 `projectName`、PUT 无 | 用 `name`projectName 系 DTO 误标) | ✅ 源码已证 |
| TM 名称 | 顶层 `name` | 无 `name` | 用 `name`OpenAPI 漏列) | ✅ 源码已证 |
| `parentType` | 字符串 `"1"` | integer | 发字符串 `"1"` | ✅ 源码已证 |
| PUT tmObject 的 parentId/parentType | 带create/edit 同一 spread | 无 | 带(匹配线上) | ✅ 源码已证 |
| 动态字段位置 `properties.<code>` | 是 | —— | 随 bundle | ✅ 源码 + 运行时双证 |
> 注:拦截-阻断的运行时抓包在本数据集走不通变更表单必填项多、arco 校验拦截、且不写生产库),但**源码分析已足以确证 body**,无需真实写入。
### C. 客户端待改项(对照现有实现)
1. **`ObjectFormDialog` 提交体字段错误**(最关键):
- 现:`{typeId, type, projectId, id?, properties}` + extraBody `{structParentId, structParentConfType}`
- 应(按 bundleGS→`{gsTypeId, parentId(仅新建), name, responsiblePersonName, properties}`
TM→`{tmTypeId, parentId, parentType:"1", name, properties}`+ 编辑加 id
- `structParentId/structParentConfType` 是**导入**的字段被误用于新建对象OpenAPI 证实这两字段只在 `dsObject/import` 出现)。
2. **项目根节点**:现为"非交互无菜单",应按 GS 处理(提供 新建GS/新建TM/属性,依 xlsx 第32行 + 真实数据"TM 挂根")。
3. **新建TM 方法类型来源**(决策):**用 `queryTmType?projectId=&gsId=`**——带 gsId 能按所选 GS 过滤方法类型,比 web 全局 tmMethodList 更准。
4. **父对象确定逻辑**(决策):客户端**不复刻** web 数据管理页"先选左树"的选择器;**父对象 = 右键所在节点**GS 或项目根),`parentId` 直接取该节点 id。
5. **`DynamicFormEditor` 顶层固定字段**GS 须含 gsTypeId 下拉 + responsiblePersonName + nameTM 新建须含 tmTypeId 下拉 + name。
6. `displayComponentType` 全集映射 —— **已由源码确证**,见 §E.1。
### D. 状态(原"待确认"已全部由源码分析落地)
- ✅ `displayComponentType` 完整映射 → §E.1
- ✅ 导出 body → §E.2
- ✅ 插件机制 → §E.3结论web 无"ds→插件"菜单,客户端此项为原创设计,需产品决策)
- §B 字段已由源码确证,**不再需要真实写入回证**。
- `PUT /dsObject/updateDsObject` body OpenAPI 已定义 `{dsObjectId, description, attachedParameters, ...}`,见下文第六节。)
### E. 源码补全FieldItem / 导出 / 插件,均来自压缩源码)
**E.1 `displayComponentType` → 控件FieldItem `index-1LyDq-Qg.js`,权威全集)**
| 值 | 控件 | 备注 |
|---|---|---|
| 1 | a-input 单行文本 | |
| 2 | a-input **禁用** | 只读文本 |
| 3 | a-checkbox | label=item.name |
| 4 | a-select 下拉 | options=optionsObject |
| 5 | a-input 单行文本 | 同 1 |
| 6 | a-date-picker 日期 | |
| 7 | a-time-picker 时间 | |
| 8 | a-date-picker show-time | 日期时间 `YYYY-MM-DD HH:mm:ss` |
| 9 | a-textarea 多行文本 | |
| 10 | a-input-number 数字 | |
| 11 | a-tree-select 树选择 | data=optionsObject, fieldNames{key:value,title:label,children:childList} |
| 其他 | a-input-number mode=button | 步进数字 |
- 字段路径 `${fieldPrefix=properties}.${fieldCode}` → 确认动态值在 `properties.<fieldCode>`
- **`requiredType` 语义纠正1=必填可编辑2=只读禁用(`disabled: isDisabled||requiredType===2`required 仅 ===1其他=可选可编辑。**
- comp 类型由后端按类型配置(如"测区"把地形地貌配成 6=日期选择器),客户端按本表渲染即可。
**E.2 导出 bodyExportModal `ExportModal-DWdo-HxP.js`,两套)**
```
模板导出 POST /business/templateExport/export { dsObjectIdList:[ds ids], templateId }
文件导出 POST /business/dataFileExport/export { idList:[ds ids], fileType [, startTime, endTime] }
// startTime/endTime 仅 fileType==5(时序数据)
文件下载 POST /business/dataFileExport/download (responseType=blob)
```
- `selectType`(checked/currentPage) 仅决定取哪些 id不进 body。
- 批量导出页流程:选数据类型/时间范围 → 勾选文件(ds ids) → 「导出」开 ExportModal(选模板) → 提交。
- 客户端数据集右键「导出」(单 ds, 按模板) → `templateExport/export { dsObjectIdList:[该ds], templateId }`;模板来自 localStorage `template`.fileTemplateList 或 `dataFileExport/queryFileType`
**E.3 插件机制结论web 无对应交互,客户端为原创设计)**
- web 中**没有**"数据集右键→插件"菜单。模型从模型管理页按类型用专用 Modal 调起resipy=ERT反演、gprpy=GPR处理 等,见 `resipyModel-*.js`/`gprpyModel-*.js`/`modelManage-*.js`)。
- 关联方向是**模型→可用数据集**`GET /business/model/{scriptType}/dsObjectList/{projectId}`(如 `model/resipy/dsObjectList/{projectId}`),即每个模型声明它能跑哪些 ds。
- 全局模型目录:`GET /business/model/list`;任务分页:`POST /business/model/task/page`。
- xlsx 的"插件1/2/3 = 列出与当前 ds 关联的插件"是**客户端原创**ds→模型 的反向。web 无单一直达接口,需:用 model/list + 各模型 dsObjectList 反查匹配,或后端新增"按 ds 列模型"接口。**此项需产品/后端决策,非源码可定。**
---
## 一、项目结构(已用于现有功能)
`GET /business/projectStruct/queryProjectStruct/{projectId}`
→ 扁平节点 `[{id,parentId,name,type(1=GS,2=TM),typeName,typeId,confCode,collectTime}]`,根 parentId="0"。
## 二、编辑 / 新建对象 —— 动态表单(核心,最大工作量)
### 表单 schema 来源(统一端点,编辑弹窗打开时调用)
`POST /business/project/getDynamicForm`
body `{"typeId": <类型id>, "id": <对象id>, "type": <1=GS|2=TM>, "projectId": <projectId>}`
→ 返回结构同 getDetail
```
{
typeId, confCode, name(类型名), description,
formList: [ { groupName, values: [ FieldDef... ] } ], // 可多组(对应弹窗内分页签:基本信息/测线布设/数据质量检查...)
properties: { <fieldCode>: <当前值> } // 编辑预填;新建时为空
}
```
getGsObjectDetail / tmObject/getDetail 返回同样的 formList/properties可互为参考。
TM 的 getDetail 还含 dsList / dsClassifyTypeList / gridFieldList。
### FieldDef 字段定义
```
confFieldId, fieldUseType(1=核心字段,2=普通),
fieldCode(键), fieldName(标签),
displayComponentType(控件类型), requiredType(1/2 必填标志——待核实方向),
displaySort, fieldDataType(4=字符串,5=日期,6=日期时间...),
fieldConfigJsonObject:{fieldChnFormat,fieldRemark,fieldEngFormat},
optionsObject(下拉项;普通字段为 null)
```
### displayComponentType 已观察样例(需补全映射)
TM「常规高密度电阻率法」编辑弹窗实测控件
- 方法名称(只读文本)
- 基本信息组:名称/电极数/电极间距/测线长 = **只读文本**(核心字段 fieldUseType=1编辑时禁用
- 设备 = 下拉(必填) ; 布设日期 = 日期选择(必填) ; 天气 = 下拉(必填)
- 布设人/审核人 = 文本(必填) ; 备注 = 文本
GS「测区」formList 含:创建人/创建日期(comp6)/名称(comp1)/创建时间(comp7)/地形地貌(comp8)/岩土性质...
→ 推测 comp1=单行文本, 6=日期, 7=日期时间, 8=多行文本;**落地前需逐一在原版核实**。
### 新建 TM 的类型选择
`GET /business/tmObject/queryTmType?projectId=..&gsId=..`
`[{label:"瞬变电磁方法", value:<tmTypeId>, code:"TEM01"}]`
新建流程:选 GS → queryTmType 选方法类型 → getDynamicForm(typeId,type=2,无id) 取空表单 → 填 → POST。
### ~~⚠️ 未捕获(真实壁垒,不可猜)~~ → 已解除,见上「更新 §B」
~~下列 body 当时认为不可得;实为 OpenAPI requestBody + bundle 已给出,参见文首「更新 §B」。~~
- ~~`POST /business/gsObject`~~ → 见 §B
- ~~`POST /business/tmObject`~~ → 见 §B
- ~~`PUT /business/gsObject` / `PUT /business/tmObject`~~ → 见 §B
- `PUT /business/dsObject/updateDsObject`更新DS body仍待确认
## 三、删除(已实现 Batch1
`DELETE /business/gsObject/{id}` `DELETE /business/tmObject/{id}` `DELETE /business/dsObject/{id}`
## 四、导入 DS
TM 的 getDetail.dsList = 该 TM 可承载的 ds 类型 `[{dsTypeId,nameChn,nameEng,canImport,canExport,...}]`canImport=true 的可导入)。
`GET /business/dsObject/query/script?dsTypeId=..&tmTypeBaseConfId=..` → 该类型可用导入脚本。
`POST /business/dsObject/checkImport` → 校验脚本所需轨迹/坐标文件是否存在。
`POST /business/dsObject/import`query 参数已知aliasName, dsTypeId*, file*, projectId*, scriptCode, scriptParamListJsonStr, structParentConfType*, structParentId*
⚠️ file 为上传项multipartscriptParamListJsonStr 结构未捕获。
## 五、导出
`POST /business/templateExport/queryExportObject` body `{projectId}` → 可选导出对象树 `[{id,parentId,name,check}]`
`GET /business/templateExport/queryDataType/{tmTypeBaseConfId}` → 按方法查数据类型。
`POST /business/templateExport/export`body 未捕获)。
模板列表见 localStorage `template`templateTypeList[数据管理-数据报告/异常体报告], fileTypeList[WORD/EXCEL], fileTemplateList。
另:数据集详情页「导出」按钮点击未发请求/未弹窗(可能直接下载或需先配模板)——待核实。
## 六、插件(数据集右键)
`GET /business/model/list` → 全局模型目录 `[{id,scriptCode,scriptName,scriptOperationType,formItemList}]`
script_ert_inner_inversion「ert反演默认」、script_radar_resultant_data_processing「雷达数据处理默认」。
⚠️ "与当前 ds 关联"的过滤逻辑未捕获model/list 不含 ds 类型关联;各模型用 dsObjectList/{projectId} 声明可接受的数据源)。
`POST /business/model/task/page` → 模型任务分页(用于"数据集任务"面板)。
## 实施建议顺序
> body 已由「更新 §B」给出下列"先捕获"前置多数已不需要;仅 §D 三项仍待确认。
1. 动态表单引擎 `DynamicFormEditor`Qt按 formList 渲染 comp1/6/7/8 + 下拉(optionsObject) + 必填校验 + 只读核心字段;编辑用 properties 预填GS/TM 顶层固定字段§C-5gsTypeId/responsiblePersonName/name、tmTypeId/name。**displayComponentType 全集映射仍需核实。**
2. **修编辑/新建提交体字段**§C-1GS→gsTypeId+parentId(仅新建)+name+responsiblePersonNameTM→tmTypeId+parentId+parentType:"1"+name。
3. 项目根菜单按 GS 处理§C-2新建TM 类型用 queryTmType§C-3父对象=右键节点§C-4
4. 导入 DSdsList 选类型 → query/script → 文件 → checkImport → importbody 见 §B
5. 导出queryExportObject 选对象 + 模板 → export。**export body 待确认§D。**
6. 插件子菜单model/list 列出;调用/关联逻辑待定§D
## 捕获提交载荷的方法(已在原版页面注入录制器 window.__rec
在 Playwright 浏览器对原系统执行一次「编辑保存/新建保存/导入/导出」(填完必填项),
即可从 `window.__rec` 读到真实 method+url+body。
注:编辑/新建/导入的 body 已由 bundle 还原§B此法现仅用于**验证 §B 冲突表 4 项**与**捕获导出 export body**(会改数据,谨慎)。

View File

@ -29,6 +29,9 @@ add_executable(geopro_desktop WIN32
panels/DatasetListPanel.cpp panels/DatasetListPanel.cpp
panels/ObjectTreePanel.cpp panels/ObjectTreePanel.cpp
panels/DynamicFormView.cpp panels/DynamicFormView.cpp
panels/DynamicFormEditor.cpp
panels/ObjectAttrPanel.cpp
panels/DatasetAttrPanel.cpp
panels/ObjectExceptionPanel.cpp panels/ObjectExceptionPanel.cpp
panels/DescriptionPanel.cpp panels/DescriptionPanel.cpp
panels/chart/RawDataChartView.cpp panels/chart/RawDataChartView.cpp
@ -53,6 +56,9 @@ add_executable(geopro_desktop WIN32
panels/DatasetDetailPanel.cpp panels/DatasetDetailPanel.cpp
CentralScene.cpp CentralScene.cpp
ProjectListDialog.cpp ProjectListDialog.cpp
ObjectFormDialog.cpp
ImportDatasetDialog.cpp
ExportDatasetDialog.cpp
SettingsDialog.cpp SettingsDialog.cpp
Logging.cpp) Logging.cpp)

View File

@ -0,0 +1,112 @@
#include "ExportDatasetDialog.hpp"
#include <utility>
#include <QComboBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QVBoxLayout>
#include "Theme.hpp"
#include "api/NavLoads.hpp"
#include "api/NavRequest.hpp"
#include "repo/IAsyncProjectRepository.hpp"
namespace geopro::app {
ExportDatasetDialog::ExportDatasetDialog(geopro::data::IAsyncProjectRepository& repo,
QString dsObjectId, QString tmTypeBaseConfId,
QWidget* parent)
: QDialog(parent), repo_(repo), dsObjectId_(std::move(dsObjectId)),
tmTypeBaseConfId_(std::move(tmTypeBaseConfId)) {
setModal(true);
setWindowTitle(QStringLiteral("导出数据集"));
setMinimumWidth(geopro::app::scaledPx(400));
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd,
geopro::app::space::kLg, geopro::app::space::kMd);
lay->setSpacing(geopro::app::space::kMd);
auto* fl = new QFormLayout();
templateCombo_ = new QComboBox(this);
fl->addRow(QStringLiteral("导出模板"), templateCombo_);
lay->addLayout(fl);
status_ = new QLabel(QStringLiteral("加载模板…"), this);
geopro::app::applyTokenizedStyleSheet(status_, QStringLiteral("color:{{text/disabled}};"));
lay->addWidget(status_);
auto* btnRow = new QHBoxLayout();
btnRow->addStretch();
auto* cancel = new QPushButton(QStringLiteral("取消"), this);
okBtn_ = new QPushButton(QStringLiteral("导出"), this);
okBtn_->setDefault(true);
okBtn_->setEnabled(false);
btnRow->addWidget(cancel);
btnRow->addWidget(okBtn_);
lay->addLayout(btnRow);
QObject::connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
QObject::connect(okBtn_, &QPushButton::clicked, this, &ExportDatasetDialog::onConfirm);
loadTemplates();
}
ExportDatasetDialog::~ExportDatasetDialog() {
// 析构时取消在途请求,避免回调命中已销毁的 this。
if (tplReq_) tplReq_->abort();
if (expReq_) expReq_->abort();
}
void ExportDatasetDialog::loadTemplates() {
if (tplReq_) tplReq_->abort();
tplReq_ = repo_.queryExportTemplatesAsync(tmTypeBaseConfId_.toStdString());
QObject::connect(tplReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
templates_ = qvariant_cast<std::vector<geopro::data::ExportTemplate>>(v);
if (templates_.empty()) {
status_->setText(QStringLiteral("无可用导出模板"));
return;
}
status_->setVisible(false);
for (const auto& t : templates_)
templateCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id));
okBtn_->setEnabled(true);
});
QObject::connect(tplReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("加载模板失败:%1").arg(msg));
});
}
void ExportDatasetDialog::onConfirm() {
const QString templateId = templateCombo_->currentData().toString();
if (templateId.isEmpty()) {
QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请选择导出模板"));
return;
}
// 模板导出 bodyspec §E.2{ dsObjectIdList:[该ds], templateId }。
QJsonObject body{{QStringLiteral("dsObjectIdList"), QJsonArray{dsObjectId_}},
{QStringLiteral("templateId"), templateId}};
okBtn_->setEnabled(false);
status_->setText(QStringLiteral("导出中…"));
status_->setVisible(true);
if (expReq_) expReq_->abort();
expReq_ = repo_.exportDatasetAsync(QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString());
QObject::connect(expReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) {
emit exported();
accept();
});
QObject::connect(expReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("导出失败:%1").arg(msg));
okBtn_->setEnabled(true);
});
}
} // namespace geopro::app

View File

@ -0,0 +1,50 @@
#pragma once
#include <QDialog>
#include <QPointer>
#include <QString>
#include <vector>
#include "repo/RepoTypes.hpp"
namespace geopro::data {
class IAsyncProjectRepository;
class NavRequest;
} // namespace geopro::data
class QComboBox;
class QLabel;
class QPushButton;
namespace geopro::app {
// 导出对话框(数据集右键「导出」):选模板 → POST templateExport/export
// { dsObjectIdList:[该ds], templateId }spec §E.2)。
// 模板来源 templateExport/queryDataType/{tmTypeBaseConfId}ds 右键上下文无 tmTypeBaseConfId 时
// 传 dsTypeBaseConfId 占位TODO来源待精确无模板时下拉为空、导出按钮禁用。
class ExportDatasetDialog : public QDialog {
Q_OBJECT
public:
ExportDatasetDialog(geopro::data::IAsyncProjectRepository& repo, QString dsObjectId,
QString tmTypeBaseConfId, QWidget* parent = nullptr);
~ExportDatasetDialog() override;
signals:
void exported();
private:
void loadTemplates();
void onConfirm();
geopro::data::IAsyncProjectRepository& repo_;
QString dsObjectId_;
QString tmTypeBaseConfId_;
std::vector<geopro::data::ExportTemplate> templates_;
QComboBox* templateCombo_ = nullptr;
QLabel* status_ = nullptr;
QPushButton* okBtn_ = nullptr;
QPointer<geopro::data::NavRequest> tplReq_;
QPointer<geopro::data::NavRequest> expReq_;
};
} // namespace geopro::app

View File

@ -0,0 +1,266 @@
#include "ImportDatasetDialog.hpp"
#include <utility>
#include <QComboBox>
#include <QFileDialog>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollArea>
#include <QUrl>
#include <QUrlQuery>
#include <QVBoxLayout>
#include <QVariantMap>
#include "Theme.hpp"
#include "api/NavLoads.hpp"
#include "api/NavRequest.hpp"
#include "panels/DynamicFormEditor.hpp"
#include "repo/IAsyncProjectRepository.hpp"
namespace geopro::app {
namespace {
constexpr int kFormTypeScript = 6; // getDynamicForm type=6导入脚本参数
constexpr int kStructConfTm = 2; // structParentConfTypeTM=2
} // namespace
ImportDatasetDialog::ImportDatasetDialog(geopro::data::IAsyncProjectRepository& repo,
QString projectId, QString tmObjectId, QWidget* parent)
: QDialog(parent), repo_(repo), projectId_(std::move(projectId)),
tmObjectId_(std::move(tmObjectId)) {
setModal(true);
setWindowTitle(QStringLiteral("导入数据集"));
setMinimumSize(geopro::app::scaledPx(480), geopro::app::scaledPx(520));
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
status_ = new QLabel(QStringLiteral("加载数据类型…"), this);
status_->setAlignment(Qt::AlignCenter);
geopro::app::applyTokenizedStyleSheet(status_,
QStringLiteral("color:{{text/disabled}};padding:12px;"));
lay->addWidget(status_);
auto* form = new QWidget(this);
auto* fl = new QFormLayout(form);
fl->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd,
geopro::app::space::kLg, 0);
fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
typeCombo_ = new QComboBox(form);
fl->addRow(QStringLiteral("数据类型"), typeCombo_);
scriptCombo_ = new QComboBox(form);
fl->addRow(QStringLiteral("导入脚本"), scriptCombo_);
auto* fileRow = new QWidget(form);
auto* fileLay = new QHBoxLayout(fileRow);
fileLay->setContentsMargins(0, 0, 0, 0);
fileEdit_ = new QLineEdit(fileRow);
fileEdit_->setReadOnly(true);
fileEdit_->setPlaceholderText(QStringLiteral("选择导入文件…"));
auto* browse = new QPushButton(QStringLiteral("浏览…"), fileRow);
fileLay->addWidget(fileEdit_, 1);
fileLay->addWidget(browse);
fl->addRow(QStringLiteral("文件"), fileRow);
lay->addWidget(form);
auto* paramLabel = new QLabel(QStringLiteral("脚本参数"), this);
geopro::app::applyTokenizedStyleSheet(
paramLabel, QStringLiteral("color:{{text/secondary}};padding:%1px %2px 0;")
.arg(geopro::app::space::kSm)
.arg(geopro::app::space::kLg));
lay->addWidget(paramLabel);
auto* scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
paramEditor_ = new DynamicFormEditor();
scroll->setWidget(paramEditor_);
lay->addWidget(scroll, 1);
auto* btnRow = new QHBoxLayout();
btnRow->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm,
geopro::app::space::kLg, geopro::app::space::kMd);
btnRow->addStretch();
auto* cancel = new QPushButton(QStringLiteral("取消"), this);
okBtn_ = new QPushButton(QStringLiteral("导入"), this);
okBtn_->setDefault(true);
okBtn_->setEnabled(false);
btnRow->addWidget(cancel);
btnRow->addWidget(okBtn_);
lay->addLayout(btnRow);
QObject::connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
QObject::connect(browse, &QPushButton::clicked, this, &ImportDatasetDialog::chooseFile);
QObject::connect(okBtn_, &QPushButton::clicked, this, &ImportDatasetDialog::onConfirm);
QObject::connect(typeCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int) { onTypeChanged(); });
QObject::connect(scriptCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int) { onScriptChanged(); });
loadTypes();
}
ImportDatasetDialog::~ImportDatasetDialog() {
// 析构时取消在途请求,避免回调命中已销毁的 this。
if (typeReq_) typeReq_->abort();
if (scriptReq_) scriptReq_->abort();
if (formReq_) formReq_->abort();
if (checkReq_) checkReq_->abort();
if (importReq_) importReq_->abort();
}
void ImportDatasetDialog::loadTypes() {
if (typeReq_) typeReq_->abort();
typeReq_ = repo_.loadTmImportTypesAsync(tmObjectId_.toStdString());
QObject::connect(typeReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
types_ = qvariant_cast<std::vector<geopro::data::DsImportType>>(v);
if (types_.empty()) {
status_->setText(QStringLiteral("该方法对象下无可导入的数据类型"));
return;
}
status_->setVisible(false);
for (const auto& t : types_)
typeCombo_->addItem(QString::fromStdString(t.name),
QString::fromStdString(t.dsTypeId));
});
QObject::connect(typeReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("加载数据类型失败:%1").arg(msg));
});
}
void ImportDatasetDialog::onTypeChanged() {
scriptCombo_->clear();
scripts_.clear();
const int idx = typeCombo_->currentIndex();
if (idx < 0 || idx >= static_cast<int>(types_.size())) return;
const QString dsTypeId = typeCombo_->currentData().toString();
if (scriptReq_) scriptReq_->abort();
// tmTypeBaseConfId暂用 TM 对象 id 占位query/script 需方法基础配置 id。TODO来源待精确。
scriptReq_ = repo_.queryImportScriptsAsync(dsTypeId.toStdString(), tmObjectId_.toStdString());
QObject::connect(scriptReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
scripts_ = qvariant_cast<std::vector<geopro::data::ScriptOption>>(v);
for (const auto& s : scripts_) {
// itemData 同时携带 scriptId/scriptCode取值不再依赖 scripts_ 同序索引。
QVariantMap d{{QStringLiteral("scriptId"), QString::fromStdString(s.scriptId)},
{QStringLiteral("scriptCode"), QString::fromStdString(s.scriptCode)}};
scriptCombo_->addItem(QString::fromStdString(s.name), d);
}
if (scripts_.empty()) {
paramEditor_->setForm({}); // 无脚本:清空参数区
okBtn_->setEnabled(!filePath_.isEmpty());
}
});
QObject::connect(scriptReq_, &geopro::data::NavRequest::failed, this, [this](const QString&) {
// 脚本可选:失败不阻塞导入(部分类型无脚本)。
okBtn_->setEnabled(!filePath_.isEmpty());
});
}
void ImportDatasetDialog::onScriptChanged() {
const int idx = scriptCombo_->currentIndex();
if (idx < 0) return;
const QString scriptId = scriptCombo_->currentData().toMap()
.value(QStringLiteral("scriptId")).toString();
if (scriptId.isEmpty()) return;
if (formReq_) formReq_->abort();
// 脚本参数表单getDynamicForm(typeId=scriptId, type=6)。
formReq_ = repo_.loadEditableFormAsync(scriptId.toStdString(), std::string(), kFormTypeScript,
projectId_.toStdString());
QObject::connect(formReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
paramEditor_->setForm(qvariant_cast<geopro::data::EditableForm>(v));
});
QObject::connect(formReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
// 参数表单加载失败:清空参数区,避免脏参数随导入提交。
paramEditor_->setForm({});
status_->setText(QStringLiteral("加载脚本参数失败:%1").arg(msg));
});
}
void ImportDatasetDialog::chooseFile() {
const QString p = QFileDialog::getOpenFileName(this, QStringLiteral("选择导入文件"));
if (p.isEmpty()) return;
filePath_ = p;
fileEdit_->setText(p);
okBtn_->setEnabled(true);
}
void ImportDatasetDialog::onConfirm() {
if (filePath_.isEmpty()) {
QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请选择导入文件"));
return;
}
QString missing;
if (!paramEditor_->validateRequired(&missing)) {
QMessageBox::warning(this, QStringLiteral("校验"),
QStringLiteral("请填写脚本参数:%1").arg(missing));
return;
}
const QString dsTypeId = typeCombo_->currentData().toString();
const QString scriptCode = scriptCombo_->currentData().toMap()
.value(QStringLiteral("scriptCode")).toString();
// 脚本参数 → scriptParamListJsonStr{fieldCode: 值} 序列化)。
// TODO原版 scriptParamListJsonStr 确切结构未捕获,此处用 properties 形态,失败时按服务端 msg 校正。
QJsonObject paramObj;
const auto params = paramEditor_->collectValues();
for (auto it = params.constBegin(); it != params.constEnd(); ++it)
paramObj.insert(it.key(), it.value());
const QString scriptParamJson =
QString::fromUtf8(QJsonDocument(paramObj).toJson(QJsonDocument::Compact));
// import 参数走 query stringspec §B
QUrlQuery q;
q.addQueryItem(QStringLiteral("dsTypeId"), dsTypeId);
q.addQueryItem(QStringLiteral("projectId"), projectId_);
q.addQueryItem(QStringLiteral("structParentConfType"), QString::number(kStructConfTm));
q.addQueryItem(QStringLiteral("structParentId"), tmObjectId_);
if (!scriptCode.isEmpty()) q.addQueryItem(QStringLiteral("scriptCode"), scriptCode);
if (!params.isEmpty()) q.addQueryItem(QStringLiteral("scriptParamListJsonStr"), scriptParamJson);
const QString queryString = q.query(QUrl::FullyEncoded);
// 先 checkImport 校验(坐标/轨迹文件存在性等),通过后 import。
QJsonObject checkBody{{QStringLiteral("dsTypeId"), dsTypeId},
{QStringLiteral("projectId"), projectId_},
{QStringLiteral("structParentConfType"), kStructConfTm},
{QStringLiteral("structParentId"), tmObjectId_}};
okBtn_->setEnabled(false);
status_->setText(QStringLiteral("校验中…"));
status_->setVisible(true);
if (checkReq_) checkReq_->abort();
checkReq_ = repo_.checkImportAsync(
QJsonDocument(checkBody).toJson(QJsonDocument::Compact).toStdString());
QObject::connect(checkReq_, &geopro::data::NavRequest::done, this,
[this, queryString](const QVariant&) {
status_->setText(QStringLiteral("导入中…"));
if (importReq_) importReq_->abort();
importReq_ = repo_.importDatasetAsync(queryString.toStdString(),
filePath_.toStdString());
QObject::connect(importReq_, &geopro::data::NavRequest::done, this,
[this](const QVariant&) {
emit imported();
accept();
});
QObject::connect(importReq_, &geopro::data::NavRequest::failed, this,
[this](const QString& msg) {
status_->setText(QStringLiteral("导入失败:%1").arg(msg));
okBtn_->setEnabled(true);
});
});
QObject::connect(checkReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("校验失败:%1").arg(msg));
okBtn_->setEnabled(true);
});
}
} // namespace geopro::app

View File

@ -0,0 +1,68 @@
#pragma once
#include <QDialog>
#include <QPointer>
#include <QString>
#include <vector>
#include "repo/RepoTypes.hpp"
namespace geopro::data {
class IAsyncProjectRepository;
class NavRequest;
} // namespace geopro::data
class QComboBox;
class QLabel;
class QLineEdit;
class QPushButton;
namespace geopro::app {
class DynamicFormEditor;
// 导入数据集向导TM 右键「导入DS」。流程spec §B / 第四节):
// 1) 选数据类型(来源 TM getDetail.dsListcanImport=true
// 2) 选脚本dsObject/query/script→ getDynamicForm(typeId=scriptId, type=6) 取脚本参数表单
// 3) 选文件
// 4) POST dsObject/checkImport 校验 → POST dsObject/importmultipart
// 注部分接口细节scriptParamListJsonStr 结构、checkImport body以源码/服务端为准;
// 不全处做合理骨架并标注 TODO提交体失败时回显服务端 msg。
class ImportDatasetDialog : public QDialog {
Q_OBJECT
public:
ImportDatasetDialog(geopro::data::IAsyncProjectRepository& repo, QString projectId,
QString tmObjectId, QWidget* parent = nullptr);
~ImportDatasetDialog() override;
signals:
void imported(); // 导入成功(调用方刷新数据列表)
private:
void loadTypes();
void onTypeChanged();
void onScriptChanged();
void chooseFile();
void onConfirm();
geopro::data::IAsyncProjectRepository& repo_;
QString projectId_;
QString tmObjectId_;
QString filePath_;
std::vector<geopro::data::DsImportType> types_;
std::vector<geopro::data::ScriptOption> scripts_;
QComboBox* typeCombo_ = nullptr;
QComboBox* scriptCombo_ = nullptr;
QLineEdit* fileEdit_ = nullptr;
DynamicFormEditor* paramEditor_ = nullptr;
QLabel* status_ = nullptr;
QPushButton* okBtn_ = nullptr;
QPointer<geopro::data::NavRequest> typeReq_;
QPointer<geopro::data::NavRequest> scriptReq_;
QPointer<geopro::data::NavRequest> formReq_;
QPointer<geopro::data::NavRequest> checkReq_;
QPointer<geopro::data::NavRequest> importReq_;
};
} // namespace geopro::app

View File

@ -0,0 +1,303 @@
#include "ObjectFormDialog.hpp"
#include <utility>
#include <QComboBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QJsonDocument>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
#include "Theme.hpp"
#include "api/NavLoads.hpp" // Q_DECLARE_METATYPE(EditableForm) / GsTypeOption
#include "api/NavRequest.hpp"
#include "panels/DynamicFormEditor.hpp"
#include "repo/IAsyncProjectRepository.hpp"
namespace geopro::app {
namespace {
constexpr int kConfGs = 1;
constexpr int kConfTm = 2;
} // namespace
ObjectFormDialog::ObjectFormDialog(geopro::data::IAsyncProjectRepository& repo, QString projectId,
QWidget* parent)
: QDialog(parent), repo_(repo), projectId_(std::move(projectId)) {
setModal(true);
setMinimumSize(geopro::app::scaledPx(480), geopro::app::scaledPx(560));
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
status_ = new QLabel(QStringLiteral("加载中…"), this);
status_->setAlignment(Qt::AlignCenter);
geopro::app::applyTokenizedStyleSheet(status_,
QStringLiteral("color:{{text/disabled}};padding:16px;"));
lay->addWidget(status_);
auto* scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto* content = new QWidget();
auto* contentLay = new QVBoxLayout(content);
contentLay->setContentsMargins(0, 0, 0, 0);
contentLay->setSpacing(0);
topBox_ = new QWidget(content); // 顶层固定字段容器buildTopFields 重填)
contentLay->addWidget(topBox_);
editor_ = new DynamicFormEditor();
contentLay->addWidget(editor_, 1);
scroll->setWidget(content);
lay->addWidget(scroll, 1);
auto* btnRow = new QHBoxLayout();
btnRow->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm,
geopro::app::space::kLg, geopro::app::space::kMd);
btnRow->addStretch();
auto* cancel = new QPushButton(QStringLiteral("取消"), this);
okBtn_ = new QPushButton(QStringLiteral("确定"), this);
okBtn_->setDefault(true);
okBtn_->setEnabled(false);
btnRow->addWidget(cancel);
btnRow->addWidget(okBtn_);
lay->addLayout(btnRow);
QObject::connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
QObject::connect(okBtn_, &QPushButton::clicked, this, &ObjectFormDialog::onConfirm);
}
void ObjectFormDialog::editObject(const QString& typeId, const QString& objectId, int confType,
const QString& displayName, const QString& parentId) {
confType_ = confType;
objectId_ = objectId;
typeId_ = typeId;
parentId_ = parentId; // TM 编辑 PUT 须带真实父 GS/根 idGS 编辑忽略)
setWindowTitle(QStringLiteral("编辑 — %1").arg(displayName));
buildTopFields();
nameEdit_->setText(displayName); // 编辑态:用对象名预填并禁用
loadForm(typeId, objectId);
}
void ObjectFormDialog::newGs(const QString& parentId) {
confType_ = kConfGs;
objectId_.clear();
parentId_ = parentId;
typeId_.clear();
setWindowTitle(QStringLiteral("新建检测对象"));
buildTopFields();
loadGsTypes(); // 拉类型 → 选第一项 → 加载动态表单
}
void ObjectFormDialog::newTm(const QString& parentId) {
confType_ = kConfTm;
objectId_.clear();
parentId_ = parentId;
typeId_.clear();
setWindowTitle(QStringLiteral("新建方法对象"));
buildTopFields();
loadTmTypes(); // 拉全局方法类型 → 选第一项 → 加载动态表单(type=2)
}
// 顶层固定字段:按 confType 与 新建/编辑 决定。整体重建 topBox_。
void ObjectFormDialog::buildTopFields() {
typeCombo_ = nullptr;
typeNameLabel_ = nullptr;
nameEdit_ = nullptr;
responsibleEdit_ = nullptr;
// 清空旧布局/控件。
if (auto* old = topBox_->layout()) {
QLayoutItem* it = nullptr;
while ((it = old->takeAt(0)) != nullptr) {
if (it->widget()) it->widget()->deleteLater();
delete it;
}
delete old;
}
auto* fl = new QFormLayout(topBox_);
fl->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd,
geopro::app::space::kLg, 0);
fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
fl->setHorizontalSpacing(geopro::app::space::kMd);
fl->setVerticalSpacing(geopro::app::space::kSm);
const bool isCreate = objectId_.isEmpty();
if (isCreate) {
// 新建 GS/TM类型下拉数据源 gsList / tmList选择后重载动态表单
const QString label =
confType_ == kConfTm ? QStringLiteral("方法类型") : QStringLiteral("对象类型");
typeCombo_ = new QComboBox(topBox_);
fl->addRow(label, typeCombo_);
QObject::connect(typeCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int) {
const QString tid = typeCombo_->currentData().toString();
if (!tid.isEmpty()) {
typeId_ = tid;
loadForm(tid, QString());
}
});
} else {
// 编辑:类型名只读展示。
typeNameLabel_ = new QLabel(topBox_);
geopro::app::applyTokenizedStyleSheet(typeNameLabel_,
QStringLiteral("color:{{text/secondary}};"));
fl->addRow(QStringLiteral("类型"), typeNameLabel_);
}
nameEdit_ = new QLineEdit(topBox_);
nameEdit_->setPlaceholderText(QStringLiteral("名称"));
if (!isCreate) nameEdit_->setEnabled(false); // 编辑态名称禁用
fl->addRow(QStringLiteral("名称"), nameEdit_);
if (confType_ == kConfGs) {
responsibleEdit_ = new QLineEdit(topBox_);
responsibleEdit_->setPlaceholderText(QStringLiteral("负责人"));
fl->addRow(QStringLiteral("负责人"), responsibleEdit_);
}
}
void ObjectFormDialog::loadGsTypes() {
status_->setText(QStringLiteral("加载类型…"));
status_->setVisible(true);
okBtn_->setEnabled(false);
if (typeReq_) typeReq_->abort();
typeReq_ = repo_.queryGsTypesAsync(projectId_.toStdString());
QObject::connect(typeReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
const auto types = qvariant_cast<std::vector<geopro::data::GsTypeOption>>(v);
if (types.empty()) {
status_->setText(QStringLiteral("无可用对象类型"));
return;
}
if (typeCombo_) {
for (const auto& t : types)
typeCombo_->addItem(QString::fromStdString(t.name),
QString::fromStdString(t.gsTypeId));
// 触发 currentIndexChanged → 加载首个类型的动态表单。
if (typeCombo_->count() > 0) {
typeId_ = typeCombo_->itemData(0).toString();
loadForm(typeId_, QString());
}
}
});
QObject::connect(typeReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("加载类型失败:%1").arg(msg));
status_->setVisible(true);
});
}
void ObjectFormDialog::loadTmTypes() {
status_->setText(QStringLiteral("加载类型…"));
status_->setVisible(true);
okBtn_->setEnabled(false);
if (typeReq_) typeReq_->abort();
typeReq_ = repo_.queryTmMethodTypesAsync(projectId_.toStdString());
QObject::connect(typeReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
const auto types = qvariant_cast<std::vector<geopro::data::TmTypeOption>>(v);
if (types.empty()) {
status_->setText(QStringLiteral("无可用方法类型"));
return;
}
if (typeCombo_) {
for (const auto& t : types)
typeCombo_->addItem(QString::fromStdString(t.label),
QString::fromStdString(t.value));
// 触发 currentIndexChanged → 加载首个类型的动态表单(type=2)。
if (typeCombo_->count() > 0) {
typeId_ = typeCombo_->itemData(0).toString();
loadForm(typeId_, QString());
}
}
});
QObject::connect(typeReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("加载类型失败:%1").arg(msg));
status_->setVisible(true);
});
}
void ObjectFormDialog::loadForm(const QString& typeId, const QString& objectId) {
status_->setText(QStringLiteral("加载中…"));
status_->setVisible(true);
okBtn_->setEnabled(false);
if (req_) req_->abort();
req_ = repo_.loadEditableFormAsync(typeId.toStdString(), objectId.toStdString(), confType_,
projectId_.toStdString());
QObject::connect(req_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
const auto form = qvariant_cast<geopro::data::EditableForm>(v);
editor_->setForm(form);
status_->setVisible(false);
okBtn_->setEnabled(true);
});
QObject::connect(req_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("加载失败:%1").arg(msg));
status_->setVisible(true);
});
}
// 按 spec §B 拼提交体。
QJsonObject ObjectFormDialog::buildBody() const {
QJsonObject props;
const auto values = editor_->collectValues();
for (auto it = values.constBegin(); it != values.constEnd(); ++it)
props.insert(it.key(), it.value());
const bool isCreate = objectId_.isEmpty();
QJsonObject body;
body.insert(QStringLiteral("name"), nameEdit_ ? nameEdit_->text() : QString());
body.insert(QStringLiteral("projectId"), projectId_);
body.insert(QStringLiteral("properties"), props);
if (!isCreate) body.insert(QStringLiteral("id"), objectId_);
if (confType_ == kConfGs) {
body.insert(QStringLiteral("gsTypeId"), typeId_);
body.insert(QStringLiteral("responsiblePersonName"),
responsibleEdit_ ? responsibleEdit_->text() : QString());
if (isCreate) body.insert(QStringLiteral("parentId"), parentId_); // GS 编辑无 parentId
} else {
body.insert(QStringLiteral("tmTypeId"), typeId_);
body.insert(QStringLiteral("parentId"), parentId_); // create/edit 同一 spread
body.insert(QStringLiteral("parentType"), QStringLiteral("1")); // 恒字符串 "1"
}
return body;
}
void ObjectFormDialog::onConfirm() {
if (nameEdit_ && nameEdit_->text().trimmed().isEmpty()) {
QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请填写名称"));
return;
}
QString missing;
if (!editor_->validateRequired(&missing)) {
QMessageBox::warning(this, QStringLiteral("校验"),
QStringLiteral("请填写必填项:%1").arg(missing));
return;
}
const QJsonObject body = buildBody();
const bool isCreate = objectId_.isEmpty();
okBtn_->setEnabled(false);
status_->setText(QStringLiteral("提交中…"));
status_->setVisible(true);
if (subReq_) subReq_->abort();
subReq_ = repo_.submitObjectAsync(
confType_, isCreate, QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString());
QObject::connect(subReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) {
emit submitted(confType_);
accept();
});
QObject::connect(subReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("提交失败:%1").arg(msg));
status_->setVisible(true);
okBtn_->setEnabled(true);
});
}
} // namespace geopro::app

View File

@ -0,0 +1,79 @@
#pragma once
#include <QDialog>
#include <QJsonObject>
#include <QMap>
#include <QPointer>
#include <QString>
namespace geopro::data {
class IAsyncProjectRepository;
class NavRequest;
} // namespace geopro::data
class QLabel;
class QPushButton;
class QComboBox;
class QLineEdit;
namespace geopro::app {
class DynamicFormEditor;
// 对象新建/编辑对话框:顶层固定字段(name / GS:gsTypeId+responsiblePersonName / TM:tmTypeId) +
// 动态字段表单(DynamicFormEditor来源 project/getDynamicForm 的 properties)。
// 编辑editObject(typeId, objectId, confType)(带 properties 预填name/type 禁用)。
// 新建 GSnewGs(parentId)dialog 拉 gsList 渲染 gsTypeId 下拉,切换重载动态表单)。
// 新建 TMnewTm(parentId)dialog 拉 tmList 渲染 tmTypeId 下拉,切换重载动态表单,与 GS 对称)。
// 提交体字段严格按 spec §B
// POST gsObject {gsTypeId, parentId, projectId, name, responsiblePersonName, properties}
// PUT gsObject {gsTypeId, id, projectId, name, responsiblePersonName, properties}
// POST tmObject {tmTypeId, name, properties, projectId, parentId, parentType:"1"}
// PUT tmObject {tmTypeId, id, name, properties, projectId, parentId, parentType:"1"}
class ObjectFormDialog : public QDialog {
Q_OBJECT
public:
ObjectFormDialog(geopro::data::IAsyncProjectRepository& repo, QString projectId,
QWidget* parent = nullptr);
// parentId=该对象父 GS/项目根 idTM 编辑 PUT 用GS 编辑忽略)。
void editObject(const QString& typeId, const QString& objectId, int confType,
const QString& displayName, const QString& parentId);
// 新建 GS拉 gsList 让用户选类型parentId=右键所在节点(含项目根) id。
void newGs(const QString& parentId);
// 新建 TM拉 tmList(全局方法类型)让用户选类型parentId=右键所在节点(GS/项目根) id。
void newTm(const QString& parentId);
signals:
void submitted(int confType); // 提交成功(调用方据此刷新结构)
private:
void buildTopFields(); // 按 confType / 新建·编辑 建顶层固定字段
void loadForm(const QString& typeId, const QString& objectId); // 拉动态表单
void loadGsTypes(); // 新建 GS拉 gsList 填充下拉
void loadTmTypes(); // 新建 TM拉 tmList 填充下拉
void onConfirm();
QJsonObject buildBody() const; // 按 spec §B 拼提交体
geopro::data::IAsyncProjectRepository& repo_;
QString projectId_;
QString objectId_; // 编辑非空;新建空
QString typeId_; // gsTypeId / tmTypeIdGS 新建时随下拉变化)
QString parentId_; // 新建父节点 id
int confType_ = 0; // 1=GS 2=TM
// 顶层固定字段控件(按 confType 创建其一)。
QComboBox* typeCombo_ = nullptr; // GS/TM 新建:选类型(数据源 gsList / tmList
QLabel* typeNameLabel_ = nullptr; // 编辑:类型名只读展示
QLineEdit* nameEdit_ = nullptr; // name编辑禁用
QLineEdit* responsibleEdit_ = nullptr; // GSresponsiblePersonName
DynamicFormEditor* editor_ = nullptr;
QWidget* topBox_ = nullptr; // 顶层字段容器(重建时整体替换)
QLabel* status_ = nullptr;
QPushButton* okBtn_ = nullptr;
QPointer<geopro::data::NavRequest> req_;
QPointer<geopro::data::NavRequest> subReq_;
QPointer<geopro::data::NavRequest> typeReq_; // gsList / tmList 拉取(新建 GS/TM 共用)
};
} // namespace geopro::app

View File

@ -66,6 +66,7 @@ QWidget* makeActionButton(QWidget* parent, const HeaderAction& a)
{ {
auto* btn = new QToolButton(parent); auto* btn = new QToolButton(parent);
btn->setObjectName(QStringLiteral("panelAction")); btn->setObjectName(QStringLiteral("panelAction"));
btn->setProperty("glyphId", static_cast<int>(a.first)); // 供调用方按图标定位并连接真实功能
setThemedGlyph(btn, a.first, kActionIcon); setThemedGlyph(btn, a.first, kActionIcon);
btn->setIconSize(QSize(kActionIcon, kActionIcon)); btn->setIconSize(QSize(kActionIcon, kActionIcon));
btn->setCursor(Qt::PointingHandCursor); btn->setCursor(Qt::PointingHandCursor);

View File

@ -37,9 +37,15 @@
#include <QFrame> #include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QGraphicsOpacityEffect> #include <QGraphicsOpacityEffect>
#include <QDate>
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
#include <QListWidgetItem> #include <QListWidgetItem>
#include <QJsonObject>
#include <QMenu>
#include <QMessageBox>
#include <QPoint>
#include <QSet>
#include <QToolButton> #include <QToolButton>
#include <QKeySequence> #include <QKeySequence>
#include <QProcess> #include <QProcess>
@ -84,7 +90,11 @@
#include "TopBar.hpp" #include "TopBar.hpp"
#include "CentralScene.hpp" #include "CentralScene.hpp"
#include "ProjectListDialog.hpp" #include "ProjectListDialog.hpp"
#include "ObjectFormDialog.hpp"
#include "ImportDatasetDialog.hpp"
#include "WorkbenchNavController.hpp" #include "WorkbenchNavController.hpp"
#include "api/NavRequest.hpp"
#include "api/NavLoads.hpp"
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include "panels/chart/ErtInversionStrategy.hpp" #include "panels/chart/ErtInversionStrategy.hpp"
#include "panels/chart/MeasurementStrategy.hpp" #include "panels/chart/MeasurementStrategy.hpp"
@ -98,6 +108,8 @@
#include "panels/DatasetListPanel.hpp" #include "panels/DatasetListPanel.hpp"
#include "panels/DatasetDetailPanel.hpp" #include "panels/DatasetDetailPanel.hpp"
#include "panels/DynamicFormView.hpp" #include "panels/DynamicFormView.hpp"
#include "panels/ObjectAttrPanel.hpp"
#include "panels/DatasetAttrPanel.hpp"
#include "panels/ObjectExceptionPanel.hpp" #include "panels/ObjectExceptionPanel.hpp"
#include "CameraPreset.hpp" #include "CameraPreset.hpp"
@ -417,9 +429,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 左上 dock对象树真实结构项目根 → GS → TM。被动视图数据由控制器推送。 // 左上 dock对象树真实结构项目根 → GS → TM。被动视图数据由控制器推送。
auto* objectTree = new geopro::app::ObjectTreePanel(); auto* objectTree = new geopro::app::ObjectTreePanel();
auto* leftDock = new ads::CDockWidget(QStringLiteral("对象")); auto* leftDock = new ads::CDockWidget(QStringLiteral("对象"));
leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象"), auto* objectBox = wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象"), objectTree,
objectTree, {{geopro::app::Glyph::Filter, QStringLiteral("筛选")},
{{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}})); {geopro::app::Glyph::Plus, QStringLiteral("新建对象")}});
leftDock->setWidget(objectBox);
auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock); auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
// 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。 // 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
@ -449,7 +462,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 右上 dock异常列表 / 对象属性 合并为带 Tab 表头的面板(对齐原型上半)。 // 右上 dock异常列表 / 对象属性 合并为带 Tab 表头的面板(对齐原型上半)。
auto* exceptionPanel = new geopro::app::ObjectExceptionPanel(); auto* exceptionPanel = new geopro::app::ObjectExceptionPanel();
auto* objAttrView = new geopro::app::DynamicFormView(); auto* objAttrView = new geopro::app::ObjectAttrPanel(projectRepo);
auto anomalyPanel = geopro::app::buildTabbedPanel( auto anomalyPanel = geopro::app::buildTabbedPanel(
{{geopro::app::Glyph::Anomaly, QStringLiteral("对象异常"), exceptionPanel, true}, {{geopro::app::Glyph::Anomaly, QStringLiteral("对象异常"), exceptionPanel, true},
@ -470,8 +483,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
rightDock->setWidget(anomalyPanel.container); rightDock->setWidget(anomalyPanel.container);
auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock); auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock);
// 右下 dock属性数据集属性键值;对齐原型下半,独立面板)。 // 右下 dock属性数据集属性上半只读元字段 + 下半可编辑描述)。
auto* propView = new geopro::app::DynamicFormView(); auto* propView = new geopro::app::DatasetAttrPanel(projectRepo);
auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性")); auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性"));
propDock->setWidget( propDock->setWidget(
wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propView)); wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propView));
@ -500,14 +513,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ── // ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ──
QObject::connect(datasetList, &QTreeWidget::itemClicked, datasetList, QObject::connect(datasetList, &QTreeWidget::itemClicked, datasetList,
[&nav, &detailCtrl](QTreeWidgetItem* item, int) { [&nav, &detailCtrl, propView](QTreeWidgetItem* item, int) {
if (item->data(0, geopro::app::kDsLoadMoreRole).toBool()) { if (item->data(0, geopro::app::kDsLoadMoreRole).toBool()) {
nav.loadMoreData(); nav.loadMoreData();
return; return;
} }
const QString dsId = item->data(0, geopro::app::kDsIdRole).toString(); const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
if (dsId.isEmpty()) return; if (dsId.isEmpty()) return;
nav.selectDataset(dsId); // 属性表单(现状) nav.selectDataset(dsId); // 只读元字段表单datasetDetailLoaded
propView->selectDataset(dsId); // 可编辑描述:回填 + 启用保存
detailCtrl.focusDataset(dsId); // 单击=聚焦已开页 detailCtrl.focusDataset(dsId); // 单击=聚焦已开页
}); });
@ -692,12 +706,346 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
&geopro::controller::WorkbenchNavController::selectObject); &geopro::controller::WorkbenchNavController::selectObject);
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &nav, QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &nav,
&geopro::controller::WorkbenchNavController::setCheckedTms); &geopro::controller::WorkbenchNavController::setCheckedTms);
// 单击对象 → 对象属性面板渲染可编辑表单projectId 取当前项目;项目根只读占位)。
// 控制器详情/异常/数据集表单 → 三个被动面板。 QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectSelectedForEdit, objAttrView,
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::objectDetailLoaded, objAttrView, [objAttrView, objectTree, &nav](const QString& id, int confType,
[objAttrView](const QString&, const geopro::data::DynamicForm& form) { const QString& typeId, const QString& name,
objAttrView->setForm(form); bool isRoot) {
objAttrView->loadObject(nav.currentProjectId(), typeId, id, confType, name,
isRoot, objectTree->parentObjectId(id));
}); });
// 当前选中的 TM idconfType==2 时记录,其它选中清空):数据集面板「上传」按钮据此定父对象。
auto currentTmId = std::make_shared<QString>();
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectClicked, &window,
[currentTmId](const QString& objectId, int confType) {
*currentTmId = (confType == 2) ? objectId : QString();
});
// 切项目/重建结构 → 旧选中 TM 失效,清空(避免「上传」按钮误用跨项目的 TM
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, &window,
[currentTmId](const QString&, const std::vector<geopro::data::StructNode>&) {
currentTmId->clear();
});
// ── 交互操作接线(对象/数据集视图:右键菜单 + 快速筛选 + 增删2D/3D 相关占位)────────
auto* anomalyTabGroup = anomalyPanel.tabGroup; // 捕指针anomalyPanel 为局部,勿按引用捕获)
// 状态栏轻提示toast 替代window 生命周期覆盖整个会话,按引用捕获安全)。
auto toast = [&window](const QString& msg) { window.statusBar()->showMessage(msg, 4000); };
// 表头操作按钮定位器:按 glyphId 在已包装面板表头里找具体 QToolButton。
auto findHeaderAction = [](QWidget* box, geopro::app::Glyph g) -> QToolButton* {
const int gid = static_cast<int>(g);
for (auto* b : box->findChildren<QToolButton*>(QStringLiteral("panelAction")))
if (b->property("glyphId").toInt() == gid) return b;
return nullptr;
};
// 对象树右键菜单动作路由。
QObject::connect(
objectTree, &geopro::app::ObjectTreePanel::contextActionRequested, &window,
[&nav, &projectRepo, &window, anomalyTabGroup, toast, objAttrView, objectTree](
const QString& action, const QString& id, int confType, const QString& typeId,
const QString& name) {
if (action == QStringLiteral("properties")) {
nav.selectObject(id, confType);
// 右键「属性」:用可编辑面板渲染(与左键单击同口径)。
objAttrView->loadObject(nav.currentProjectId(), typeId, id, confType, name, false,
objectTree->parentObjectId(id));
if (anomalyTabGroup)
if (auto* b = anomalyTabGroup->button(1)) b->click(); // 切到「对象属性」页签
} else if (action == QStringLiteral("exceptionDetail")) {
nav.showObjectExceptions(id, confType);
if (anomalyTabGroup)
if (auto* b = anomalyTabGroup->button(0)) b->click(); // 切到「对象异常」页签
} else if (action == QStringLiteral("delete")) {
const auto r = QMessageBox::question(
&window, QStringLiteral("删除确认"),
QStringLiteral("确定删除「%1」该操作不可撤销。").arg(name),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (r == QMessageBox::Yes) nav.deleteObject(id, confType);
} else if (action == QStringLiteral("edit")) {
// 动态表单编辑器:拉 project/getDynamicForm 真实 schema 渲染可编辑表单;
// 确定→校验+提交PUTbody 为推断结构,确切性以服务端为准)→成功刷新结构。
auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(),
&window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->editObject(typeId, id, confType, name, objectTree->parentObjectId(id));
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("保存成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
} else if (action == QStringLiteral("newTm")) {
// 新建 TM对话框拉 tmList(全局方法类型)选类型 → getDynamicForm(type=2) → POST /tmObject。
// 父对象:在 GS/项目根上=该节点;在 TM 上=其父 GS/根(即新建同级 TM
const QString tmParent =
(confType == 2) ? objectTree->parentObjectId(id) : id;
auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(),
&window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->newTm(tmParent);
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("新建成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
} else if (action == QStringLiteral("newGs")) {
// 新建 GS对话框拉 gsList 选类型 → getDynamicForm(type=1) → POST /gsObject。
// 父对象 = 右键所在节点(GS/项目根) id。
auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(),
&window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->newGs(id);
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("新建成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
} else if (action == QStringLiteral("importDs")) {
// 导入 DSTM 右键 → 选数据类型/脚本/文件 → checkImport → importmultipart
auto* dlg = new geopro::app::ImportDatasetDialog(projectRepo, nav.currentProjectId(),
id, &window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
QObject::connect(dlg, &geopro::app::ImportDatasetDialog::imported, &window,
[&nav, toast]() {
toast(QStringLiteral("导入成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
} else if (action == QStringLiteral("showHide") || action == QStringLiteral("locate")) {
toast(QStringLiteral("「%1」需要二维/三维视图,开发中").arg(name));
} else {
toast(QStringLiteral("该功能开发中,即将接入"));
}
});
// 对象属性面板保存成功 → toast + 刷新结构(重载当前项目,回填最新属性)。
QObject::connect(objAttrView, &geopro::app::ObjectAttrPanel::saved, &window,
[&nav, toast]() {
toast(QStringLiteral("保存成功"));
nav.switchProject(nav.currentProjectId());
});
// 数据集属性面板描述保存成功 → toast。
QObject::connect(propView, &geopro::app::DatasetAttrPanel::saved, &window,
[toast]() { toast(QStringLiteral("描述已保存")); });
// 增删改结果 → 状态栏反馈(成功后控制器已自行刷新)。
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::mutationSucceeded, &window,
[toast](const QString& msg) { toast(msg); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::mutationFailed, &window,
[&window](const QString& msg) {
auto* sb = window.statusBar();
sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}")
.arg(QString::fromUtf8(geopro::app::semantic::kDanger)));
sb->showMessage(QStringLiteral("操作失败:%1").arg(msg), 6000);
QTimer::singleShot(6000, sb, [sb]() { sb->setStyleSheet(QString()); });
});
// 对象树表头「筛选」按钮 → 快速筛选弹出菜单(按类型批量勾选/反选 TM
if (auto* objFilterBtn = findHeaderAction(objectBox, geopro::app::Glyph::Filter)) {
objFilterBtn->setToolTip(QStringLiteral("快速筛选"));
QObject::connect(objFilterBtn, &QToolButton::clicked, objectTree,
[objectTree, objFilterBtn]() {
QMenu m(objectTree);
m.addAction(QStringLiteral("全选测线"), objectTree,
[objectTree]() { objectTree->setAllTmsChecked(true); });
m.addAction(QStringLiteral("取消全选"), objectTree,
[objectTree]() { objectTree->setAllTmsChecked(false); });
m.addAction(QStringLiteral("反选"), objectTree,
[objectTree]() { objectTree->invertTmChecks(); });
m.exec(objFilterBtn->mapToGlobal(QPoint(0, objFilterBtn->height())));
});
}
// 对象树表头「新建对象」按钮 → 小菜单(新建检测对象/新建方法对象,复用右键 newGs/newTm 流程)。
// 父对象 = 当前选中节点;未选中则取项目根(由 ObjectTreePanel::currentParentForNew 决定)。
if (auto* objAddBtn = findHeaderAction(objectBox, geopro::app::Glyph::Plus)) {
objAddBtn->setToolTip(QStringLiteral("新建对象"));
QObject::connect(
objAddBtn, &QToolButton::clicked, objectTree,
[objAddBtn, objectTree, &projectRepo, &nav, &window, toast]() {
const QString parentId = objectTree->currentParentForNew();
if (parentId.isEmpty()) {
toast(QStringLiteral("请先选择项目"));
return;
}
// 与右键 newGs/newTm 完全一致的对话框流程(文案统一:新建检测对象/新建方法对象)。
auto openForm = [&projectRepo, &nav, &window, toast, parentId](bool gs) {
auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(),
&window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
if (gs) dlg->newGs(parentId); else dlg->newTm(parentId);
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("新建成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
};
// 按选中类型决定菜单项:选 项目根/GS → 新建GS+TM选 TM → 仅新建TM同级
// 父对象由 currentParentForNew() 统一给出TM→父GS、GS/根→自身、未选→根),三种情况均正确。
QMenu m(objectTree);
if (objectTree->currentSelectedConfType() != 2) // 非 TM可新建检测对象(GS)
m.addAction(QStringLiteral("新建检测对象"), objectTree,
[openForm]() { openForm(true); });
m.addAction(QStringLiteral("新建方法对象"), objectTree,
[openForm]() { openForm(false); });
m.exec(objAddBtn->mapToGlobal(QPoint(0, objAddBtn->height())));
});
}
// 模型/插件目录:全局一次性拉取并缓存,供数据集右键「插件」子菜单同步填充。
auto modelsCache = std::make_shared<std::vector<geopro::data::ModelInfo>>();
{
auto* mReq = projectRepo.listModelsAsync();
QObject::connect(mReq, &geopro::data::NavRequest::done, &window,
[modelsCache](const QVariant& v) {
*modelsCache = qvariant_cast<std::vector<geopro::data::ModelInfo>>(v);
});
}
// ── 数据集列表右键菜单(数据集详情 / 属性 / 插件 / 导出 / 删除)──
datasetList->setContextMenuPolicy(Qt::CustomContextMenu);
QObject::connect(
datasetList, &QWidget::customContextMenuRequested, datasetList,
[datasetList, &detailCtrl, &nav, &projectRepo, &window, toast, modelsCache, propView](
const QPoint& pos) {
QTreeWidgetItem* item = datasetList->itemAt(pos);
if (!item || item->data(0, geopro::app::kDsLoadMoreRole).toBool()) return;
const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
if (dsId.isEmpty()) return;
const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
const QString dsName = item->data(0, geopro::app::kDsNameRole).toString();
QMenu menu(datasetList);
menu.addAction(QStringLiteral("数据集详情"), datasetList,
[&detailCtrl, dsId, ddCode, dsName]() {
detailCtrl.openDataset(dsId, ddCode, dsName);
});
menu.addAction(QStringLiteral("属性"), datasetList, [&nav, propView, dsId]() {
nav.selectDataset(dsId); // 只读元字段
propView->selectDataset(dsId); // 可编辑描述
});
menu.addSeparator();
QMenu* plugins = menu.addMenu(QStringLiteral("插件"));
if (modelsCache->empty()) {
plugins->addAction(QStringLiteral("(模型列表加载中…)"))->setEnabled(false);
} else {
for (const auto& m : *modelsCache) {
const QString mn = QString::fromStdString(m.scriptName);
plugins->addAction(mn, datasetList, [toast, mn]() {
toast(QStringLiteral("插件「%1」调用待接入").arg(mn));
});
}
}
menu.addAction(QStringLiteral("导出…"), datasetList, [toast]() {
// ds 右键上下文拿不到 tmTypeBaseConfId空配置打开会直接「加载模板失败」。
// 暂不开对话框提示改用批量导出ExportDatasetDialog 保留供有 confId 场景调用)。
toast(QStringLiteral("从此处导出暂不可用(缺方法配置),请从批量导出使用"));
});
menu.addSeparator();
menu.addAction(QStringLiteral("删除"), datasetList, [&nav, &window, dsId, dsName]() {
const auto r = QMessageBox::question(
&window, QStringLiteral("删除确认"),
QStringLiteral("确定删除数据集「%1」该操作不可撤销。").arg(dsName),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (r == QMessageBox::Yes) nav.deleteDataset(dsId);
});
menu.exec(datasetList->viewport()->mapToGlobal(pos));
});
// 数据集表头「筛选」按钮 → 按类型 + 创建日期快速筛选(客户端隐藏不匹配行;状态跨弹出保留)。
if (auto* dsFilterBtn = findHeaderAction(datasetBox, geopro::app::Glyph::Filter)) {
dsFilterBtn->setToolTip(QStringLiteral("快速筛选"));
auto hiddenTypes = std::make_shared<QSet<QString>>(); // 当前被取消勾选的类型
auto minDate = std::make_shared<QDate>(); // 创建日期下限(无效=不限)
auto reapply = [datasetList, hiddenTypes, minDate]() {
QSet<QString> visible;
for (const QString& x : geopro::app::collectDatasetTypeNames(datasetList))
if (!hiddenTypes->contains(x)) visible.insert(x);
geopro::app::applyDatasetFilter(datasetList, visible, *minDate);
};
QObject::connect(
dsFilterBtn, &QToolButton::clicked, datasetList,
[datasetList, dsFilterBtn, hiddenTypes, minDate, reapply]() {
const QStringList types = geopro::app::collectDatasetTypeNames(datasetList);
QMenu m(datasetList);
if (types.isEmpty()) {
m.addAction(QStringLiteral("(当前无数据集)"))->setEnabled(false);
}
for (const QString& t : types) {
QAction* a = m.addAction(t);
a->setCheckable(true);
a->setChecked(!hiddenTypes->contains(t));
QObject::connect(a, &QAction::toggled, datasetList,
[hiddenTypes, reapply, t](bool on) {
if (on) hiddenTypes->remove(t);
else hiddenTypes->insert(t);
reapply();
});
}
m.addSeparator();
QMenu* dm = m.addMenu(QStringLiteral("创建日期"));
dm->addAction(QStringLiteral("全部"), datasetList,
[minDate, reapply]() { *minDate = QDate(); reapply(); });
dm->addAction(QStringLiteral("近 7 天"), datasetList, [minDate, reapply]() {
*minDate = QDate::currentDate().addDays(-7);
reapply();
});
dm->addAction(QStringLiteral("近 30 天"), datasetList, [minDate, reapply]() {
*minDate = QDate::currentDate().addDays(-30);
reapply();
});
m.addAction(QStringLiteral("清除筛选"), datasetList,
[hiddenTypes, minDate, reapply]() {
hiddenTypes->clear();
*minDate = QDate();
reapply();
});
m.exec(dsFilterBtn->mapToGlobal(QPoint(0, dsFilterBtn->height())));
});
}
// 数据集表头「上传」按钮 → 导入数据集(复用 ImportDatasetDialog父对象=当前选中 TM
// 未选中 TM 时按钮禁用并提示先选方法对象(随选中变化动态启停)。
if (auto* dsUploadBtn = findHeaderAction(datasetBox, geopro::app::Glyph::Upload)) {
dsUploadBtn->setToolTip(QStringLiteral("导入数据集(需先选方法对象)"));
auto syncUploadEnabled = [dsUploadBtn, currentTmId]() {
const bool hasTm = !currentTmId->isEmpty();
dsUploadBtn->setEnabled(hasTm);
dsUploadBtn->setToolTip(hasTm ? QStringLiteral("导入数据集…")
: QStringLiteral("导入数据集(需先选方法对象)"));
};
syncUploadEnabled(); // 初始:未选 TM → 禁用
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectClicked, dsUploadBtn,
[syncUploadEnabled](const QString&, int) { syncUploadEnabled(); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded,
dsUploadBtn,
[syncUploadEnabled](const QString&,
const std::vector<geopro::data::StructNode>&) {
syncUploadEnabled();
});
QObject::connect(
dsUploadBtn, &QToolButton::clicked, &window,
[currentTmId, &projectRepo, &nav, &window, toast]() {
if (currentTmId->isEmpty()) {
toast(QStringLiteral("请先选择方法对象TM再导入数据集"));
return;
}
auto* dlg = new geopro::app::ImportDatasetDialog(
projectRepo, nav.currentProjectId(), *currentTmId, &window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
QObject::connect(dlg, &geopro::app::ImportDatasetDialog::imported, &window,
[&nav, toast]() {
toast(QStringLiteral("导入成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
});
}
// 控制器异常/数据集表单 → 被动面板。
// 对象属性改为可编辑面板:由 ObjectTreePanel::objectSelectedForEdit 直接驱动(见下),
// 不再消费只读的 objectDetailLoaded。
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::exceptionTreeLoaded, QObject::connect(&nav, &geopro::controller::WorkbenchNavController::exceptionTreeLoaded,
exceptionPanel, exceptionPanel,
[exceptionPanel, anomalyBadge]( [exceptionPanel, anomalyBadge](

View File

@ -0,0 +1,128 @@
#include "panels/DatasetAttrPanel.hpp"
#include <QHBoxLayout>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QPushButton>
#include <QTextEdit>
#include <QVBoxLayout>
#include "Theme.hpp"
#include "api/NavRequest.hpp"
#include "panels/DynamicFormView.hpp"
#include "repo/IAsyncProjectRepository.hpp"
namespace geopro::app {
DatasetAttrPanel::DatasetAttrPanel(geopro::data::IAsyncProjectRepository& repo, QWidget* parent)
: QWidget(parent), repo_(repo) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
// 上半:只读元字段(复用 DynamicFormView
metaView_ = new DynamicFormView(this);
metaView_->showMessage(QStringLiteral("(单击数据集查看属性)"));
lay->addWidget(metaView_, 1);
// 下半:可编辑描述区。
auto* descBox = new QWidget(this);
auto* descLay = new QVBoxLayout(descBox);
descLay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm,
geopro::app::space::kLg, geopro::app::space::kMd);
descLay->setSpacing(geopro::app::space::kXs);
auto* hint = new QLabel(QStringLiteral("描述(备注)"), descBox);
geopro::app::applyTokenizedStyleSheet(hint, QStringLiteral("color:{{text/secondary}};"));
descLay->addWidget(hint);
descEdit_ = new QTextEdit(descBox);
descEdit_->setAcceptRichText(false); // 纯文本(与服务端 delta 纯文本往返一致)
descEdit_->setEnabled(false);
descEdit_->setMaximumHeight(geopro::app::scaledPx(120));
descLay->addWidget(descEdit_);
auto* btnRow = new QHBoxLayout();
status_ = new QLabel(QString(), descBox);
geopro::app::applyTokenizedStyleSheet(status_, QStringLiteral("color:{{text/disabled}};"));
btnRow->addWidget(status_, 1);
saveBtn_ = new QPushButton(QStringLiteral("保存"), descBox);
saveBtn_->setEnabled(false);
btnRow->addWidget(saveBtn_);
descLay->addLayout(btnRow);
lay->addWidget(descBox);
QObject::connect(saveBtn_, &QPushButton::clicked, this, &DatasetAttrPanel::onSave);
}
void DatasetAttrPanel::setForm(const geopro::data::DynamicForm& form) {
metaView_->setForm(form);
}
void DatasetAttrPanel::showMessage(const QString& message) {
dsObjectId_.clear();
metaView_->showMessage(message);
descEdit_->clear();
descEdit_->setEnabled(false);
saveBtn_->setEnabled(false);
status_->clear();
}
void DatasetAttrPanel::selectDataset(const QString& dsObjectId) {
// 切换数据集:中止在途保存,避免旧 save 回调串台触发 saved()/启用按钮。
if (saveReq_) saveReq_->abort();
dsObjectId_ = dsObjectId;
descEdit_->setEnabled(false);
saveBtn_->setEnabled(false);
status_->setText(QStringLiteral("加载描述…"));
if (loadReq_) loadReq_->abort();
loadReq_ = repo_.loadDatasetDetailAsync(dsObjectId.toStdString());
QObject::connect(loadReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
descEdit_->setPlainText(v.toString()); // payload=QString现有描述纯文本
descEdit_->setEnabled(true);
saveBtn_->setEnabled(true);
status_->clear();
});
QObject::connect(loadReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
// 加载失败仍允许编辑/保存(视为新建描述),仅提示。
descEdit_->setEnabled(true);
saveBtn_->setEnabled(true);
status_->setText(QStringLiteral("加载描述失败:%1可直接编辑保存").arg(msg));
});
}
void DatasetAttrPanel::onSave() {
if (dsObjectId_.isEmpty()) return;
const QString text = descEdit_->toPlainText();
// 最小 Quill delta[{ insert: <文本 + "\n"> }],承载纯文本与服务端往返。
const QJsonArray deltaOps{QJsonObject{{QStringLiteral("insert"), text + QStringLiteral("\n")}}};
const QJsonObject body{
{QStringLiteral("dsObjectId"), dsObjectId_},
{QStringLiteral("description"), text},
{QStringLiteral("attachedParameters"),
QJsonObject{{QStringLiteral("deltaContent"), deltaOps}}}};
saveBtn_->setEnabled(false);
descEdit_->setEnabled(false);
status_->setText(QStringLiteral("保存中…"));
if (saveReq_) saveReq_->abort();
saveReq_ =
repo_.updateDatasetAsync(QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString());
QObject::connect(saveReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) {
descEdit_->setEnabled(true);
saveBtn_->setEnabled(true);
status_->clear();
emit saved();
});
QObject::connect(saveReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("保存失败:%1").arg(msg));
descEdit_->setEnabled(true);
saveBtn_->setEnabled(true);
});
}
} // namespace geopro::app

View File

@ -0,0 +1,52 @@
#pragma once
#include <QPointer>
#include <QString>
#include <QWidget>
#include "repo/RepoTypes.hpp"
namespace geopro::data {
class IAsyncProjectRepository;
class NavRequest;
} // namespace geopro::data
class QLabel;
class QPushButton;
class QTextEdit;
namespace geopro::app {
class DynamicFormView;
// 数据集属性面板右下「数据集属性」上半只读元字段DynamicForm+ 下半可编辑描述 + 保存。
// 元字段无写接口仅展示datasetDetailLoaded 推送的 DynamicForm
// 描述可写PUT dsObject/updateDsObject/ body
// { dsObjectId, description:<纯文本>, attachedParameters:{ deltaContent:[{ insert:<文本+"\n"> }] } }
// 描述文本经 loadDatasetDetailAsync(GET getDetail) 回填payload=QString
class DatasetAttrPanel : public QWidget {
Q_OBJECT
public:
DatasetAttrPanel(geopro::data::IAsyncProjectRepository& repo, QWidget* parent = nullptr);
void setForm(const geopro::data::DynamicForm& form); // 元字段只读展示
void showMessage(const QString& message); // 空/占位
void selectDataset(const QString& dsObjectId); // 选中 → 回填描述、启用保存
signals:
void saved(); // 描述保存成功
private:
void onSave();
geopro::data::IAsyncProjectRepository& repo_;
QString dsObjectId_;
DynamicFormView* metaView_ = nullptr;
QTextEdit* descEdit_ = nullptr;
QLabel* status_ = nullptr;
QPushButton* saveBtn_ = nullptr;
QPointer<geopro::data::NavRequest> loadReq_;
QPointer<geopro::data::NavRequest> saveReq_;
};
} // namespace geopro::app

View File

@ -123,6 +123,14 @@ QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) {
item->setData(0, kDsDdTypeRole, QString::fromStdString(d.ddCode)); item->setData(0, kDsDdTypeRole, QString::fromStdString(d.ddCode));
item->setData(0, kDsDdCodeRole, QString::fromStdString(d.ddCode)); item->setData(0, kDsDdCodeRole, QString::fromStdString(d.ddCode));
item->setData(0, kDsNameRole, QString::fromStdString(d.dsName)); item->setData(0, kDsNameRole, QString::fromStdString(d.dsName));
item->setData(0, kDsTypeNameRole, QString::fromStdString(d.typeName));
item->setData(0, kDsCreateTimeRole, QString::fromStdString(d.createTime));
// 单击 tip显示数据集主要属性名称 / 类型 / 创建时间对齐菜单文档「tip显示ds的主要属性」。
QString tip = QStringLiteral("名称:%1").arg(QString::fromStdString(d.dsName));
if (!d.typeName.empty()) tip += QStringLiteral("\n类型:%1").arg(QString::fromStdString(d.typeName));
if (!d.createTime.empty())
tip += QStringLiteral("\n创建时间:%1").arg(QString::fromStdString(d.createTime));
item->setToolTip(0, tip);
return item; return item;
} }
} // namespace } // namespace
@ -188,4 +196,60 @@ void applyDatasetCardDelegate(QAbstractItemView* view) {
[view]() { view->viewport()->update(); }); [view]() { view->viewport()->update(); });
} }
QStringList collectDatasetTypeNames(QTreeWidget* tree) {
QStringList types;
if (!tree) return types;
QSet<QString> seen;
for (QTreeWidgetItemIterator it(tree); *it; ++it) {
if ((*it)->data(0, kDsLoadMoreRole).toBool()) continue;
const QString t = (*it)->data(0, kDsTypeNameRole).toString();
if (t.isEmpty() || seen.contains(t)) continue;
seen.insert(t);
types << t;
}
return types;
}
namespace {
// 解析创建时间字符串为日期(容忍 "yyyy-MM-dd HH:mm:ss" / "yyyy-MM-dd" / "yyyy/MM/dd")。
QDate parseRowDate(const QString& s) {
if (s.isEmpty()) return {};
const QString d = s.left(10);
for (const char* fmt : {"yyyy-MM-dd", "yyyy/MM/dd"}) {
QDate v = QDate::fromString(d, QString::fromLatin1(fmt));
if (v.isValid()) return v;
}
return {};
}
// 递归判定项是否匹配(类型在集合内 且 创建日期 >= minDate
bool rowMatches(QTreeWidgetItem* item, const QSet<QString>& visibleTypes, const QDate& minDate) {
const QString t = item->data(0, kDsTypeNameRole).toString();
if (!t.isEmpty() && !visibleTypes.contains(t)) return false;
if (minDate.isValid()) {
const QDate d = parseRowDate(item->data(0, kDsCreateTimeRole).toString());
if (d.isValid() && d < minDate) return false;
}
return true;
}
// 返回该项(或其任一后代)是否可见;据此 setHidden。父项只要有可见后代即保留。
bool applyFilterRec(QTreeWidgetItem* item, const QSet<QString>& visibleTypes, const QDate& minDate) {
if (item->data(0, kDsLoadMoreRole).toBool()) return true; // 「加载更多」行恒显
bool anyChildVisible = false;
for (int i = 0; i < item->childCount(); ++i)
anyChildVisible |= applyFilterRec(item->child(i), visibleTypes, minDate);
const bool selfMatch = rowMatches(item, visibleTypes, minDate);
const bool visible = selfMatch || anyChildVisible;
item->setHidden(!visible);
return visible;
}
} // namespace
void applyDatasetFilter(QTreeWidget* tree, const QSet<QString>& visibleTypes, const QDate& minDate) {
if (!tree) return;
for (int i = 0; i < tree->topLevelItemCount(); ++i)
applyFilterRec(tree->topLevelItem(i), visibleTypes, minDate);
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,6 +1,10 @@
#pragma once #pragma once
#include <vector> #include <vector>
#include <QDate>
#include <QSet>
#include <QStringList>
#include "repo/RepoTypes.hpp" #include "repo/RepoTypes.hpp"
class QListWidget; class QListWidget;
@ -16,6 +20,8 @@ constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2文件下载 url
constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行 constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行
constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4ddCode双击详情选策略用 constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4ddCode双击详情选策略用
constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5dsName详情页签标题用 constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5dsName详情页签标题用
constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6类型名快速筛选用
constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7创建时间按日期筛选用
// 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。 // 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。
// 每项列0文本 = dsName +「创建时间 · 类型名」data(0,角色) 存 dsId/ddCode/dsName。 // 每项列0文本 = dsName +「创建时间 · 类型名」data(0,角色) 存 dsId/ddCode/dsName。
@ -28,4 +34,10 @@ void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>&
// 接受 QListWidget文件或 QTreeWidget数据树——故形参为其共同基类 QAbstractItemView。 // 接受 QListWidget文件或 QTreeWidget数据树——故形参为其共同基类 QAbstractItemView。
void applyDatasetCardDelegate(QAbstractItemView* view); void applyDatasetCardDelegate(QAbstractItemView* view);
// 快速筛选辅助:收集数据集树中出现过的全部类型名(去重,按出现序)。
QStringList collectDatasetTypeNames(QTreeWidget* tree);
// 按类型名集合 + 创建日期下限minDate 为空=不限)过滤显示:不匹配的项隐藏。
// 含子节点时:父项只要自身或任一可见后代匹配即保持可见(树完整性)。
void applyDatasetFilter(QTreeWidget* tree, const QSet<QString>& visibleTypes, const QDate& minDate);
} // namespace geopro::app } // namespace geopro::app

View File

@ -0,0 +1,275 @@
#include "panels/DynamicFormEditor.hpp"
#include <QCheckBox>
#include <QComboBox>
#include <QDate>
#include <QDateEdit>
#include <QDateTime>
#include <QDateTimeEdit>
#include <QDoubleValidator>
#include <QFormLayout>
#include <QIntValidator>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QSpinBox>
#include <QTimeEdit>
#include <QVBoxLayout>
#include "Theme.hpp"
namespace geopro::app {
namespace {
// fieldDataType2=整数 3=浮点 4=字符串 5=日期 6=日期时间 8=枚举 10=设备 11=人员。
constexpr int kDtInt = 2;
constexpr int kDtFloat = 3;
// displayComponentType全集来源 spec §E.1,权威)。
constexpr int kCompText = 1; // 单行文本
constexpr int kCompTextDisabled = 2; // 单行文本(只读禁用)
constexpr int kCompCheckbox = 3; // 复选框
constexpr int kCompSelect = 4; // 下拉
constexpr int kCompText2 = 5; // 单行文本(同 1
constexpr int kCompDate = 6; // 日期
constexpr int kCompTime = 7; // 时间
constexpr int kCompDateTime = 8; // 日期时间 YYYY-MM-DD HH:mm:ss
constexpr int kCompMultiline = 9; // 多行文本
constexpr int kCompNumber = 10; // 数字
constexpr int kCompTreeSelect = 11; // 树选择(无现成控件,用 QComboBox 兜底)
// 其余 = 步进数字QSpinBox/QDoubleSpinBox
// requiredType1=必填可编辑2=只读禁用;其他=可选可编辑spec §E.1)。
constexpr int kRequiredYes = 1;
constexpr int kRequiredReadonly = 2;
bool isReadonly(const data::EditField& f) {
// requiredType==2 只读;或 comp==2 只读文本fromProps 的核心测量值通常也不可改(核心字段)。
return f.required == kRequiredReadonly || f.comp == kCompTextDisabled ||
(f.useType == 1 && f.fromProps);
}
// 必填字段标签:名称 + 红色 *(仅 requiredType==1
QString labelText(const data::EditField& f) {
QString t = QString::fromStdString(f.name);
if (f.required == kRequiredYes)
t += QStringLiteral(" <span style='color:%1'>*</span>")
.arg(QString::fromUtf8(geopro::app::semantic::kDanger));
return t;
}
// 把 optionsObject 顶层平铺为 (label,value) 对。childList 暂未递归comp11 树选择用
// QComboBox 兜底降级(见 buildWidget故仅平铺顶层项。
void flattenOptions(const std::vector<data::EditFieldOption>& opts, QComboBox* cb) {
for (const auto& o : opts)
cb->addItem(QString::fromStdString(o.label), QString::fromStdString(o.value));
}
// 给整数输入框装 QIntValidator并按 limitMin/limitMax可空设上下界。
void applyIntRange(QLineEdit* le, const data::EditField& f) {
auto* v = new QIntValidator(le);
bool ok1 = false, ok2 = false;
const int lo = QString::fromStdString(f.limitMin).toInt(&ok1);
const int hi = QString::fromStdString(f.limitMax).toInt(&ok2);
if (ok1) v->setBottom(lo);
if (ok2) v->setTop(hi);
le->setValidator(v);
}
// 按字段建取值控件并预填。
QWidget* buildWidget(const data::EditField& f) {
const QString val = QString::fromStdString(f.value);
const bool ro = isReadonly(f);
switch (f.comp) {
case kCompCheckbox: {
auto* cbx = new QCheckBox(QString::fromStdString(f.name));
const QString lower = val.trimmed().toLower();
cbx->setChecked(lower == QStringLiteral("1") || lower == QStringLiteral("true"));
if (ro) cbx->setEnabled(false);
return cbx;
}
case kCompSelect:
case kCompTreeSelect: {
auto* cb = new QComboBox();
if (f.options.empty()) {
cb->setEditable(true);
cb->setCurrentText(val);
} else {
flattenOptions(f.options, cb);
const int idx = cb->findData(val);
if (idx >= 0) cb->setCurrentIndex(idx);
}
if (ro) cb->setEnabled(false);
return cb;
}
case kCompDate: {
auto* de = new QDateEdit();
de->setCalendarPopup(true);
de->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
const QDate d = QDate::fromString(val.left(10), QStringLiteral("yyyy-MM-dd"));
de->setDate(d.isValid() ? d : QDate::currentDate());
if (ro) de->setEnabled(false);
return de;
}
case kCompTime: {
auto* te = new QTimeEdit();
te->setDisplayFormat(QStringLiteral("HH:mm:ss"));
const QTime t = QTime::fromString(val, QStringLiteral("HH:mm:ss"));
te->setTime(t.isValid() ? t : QTime::currentTime());
if (ro) te->setEnabled(false);
return te;
}
case kCompDateTime: {
auto* dt = new QDateTimeEdit();
dt->setCalendarPopup(true);
dt->setDisplayFormat(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
const QDateTime v = QDateTime::fromString(val, QStringLiteral("yyyy-MM-dd HH:mm:ss"));
dt->setDateTime(v.isValid() ? v : QDateTime::currentDateTime());
if (ro) dt->setEnabled(false);
return dt;
}
case kCompMultiline: {
auto* te = new QPlainTextEdit();
te->setPlainText(val);
te->setFixedHeight(geopro::app::scaledPx(64));
if (ro) te->setReadOnly(true);
return te;
}
case kCompNumber: {
auto* le = new QLineEdit();
le->setText(val);
if (f.dataType == kDtFloat) {
le->setValidator(new QDoubleValidator(le));
} else {
applyIntRange(le, f);
}
if (ro) le->setReadOnly(true);
return le;
}
case kCompText:
case kCompText2:
case kCompTextDisabled:
default: {
// 其余未知 comp 类型 + 文本类单行文本comp2 / 只读时禁用)。
auto* le = new QLineEdit();
le->setText(val);
if (f.dataType == kDtInt) {
applyIntRange(le, f);
} else if (f.dataType == kDtFloat) {
le->setValidator(new QDoubleValidator(le));
}
if (ro) le->setReadOnly(true);
return le;
}
}
}
QString readWidget(int comp, QWidget* w) {
switch (comp) {
case kCompCheckbox:
if (auto* cbx = qobject_cast<QCheckBox*>(w))
return cbx->isChecked() ? QStringLiteral("1") : QStringLiteral("0");
return {};
case kCompSelect:
case kCompTreeSelect:
if (auto* cb = qobject_cast<QComboBox*>(w)) {
const QVariant d = cb->currentData();
return d.isValid() ? d.toString() : cb->currentText();
}
return {};
case kCompDate:
if (auto* de = qobject_cast<QDateEdit*>(w))
return de->date().toString(QStringLiteral("yyyy-MM-dd"));
return {};
case kCompTime:
if (auto* te = qobject_cast<QTimeEdit*>(w))
return te->time().toString(QStringLiteral("HH:mm:ss"));
return {};
case kCompDateTime:
if (auto* dt = qobject_cast<QDateTimeEdit*>(w))
return dt->dateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
return {};
case kCompMultiline:
if (auto* te = qobject_cast<QPlainTextEdit*>(w)) return te->toPlainText();
return {};
default:
if (auto* le = qobject_cast<QLineEdit*>(w)) return le->text();
return {};
}
}
bool widgetEmpty(int comp, QWidget* w) {
if (comp == kCompCheckbox) return false; // 复选框总有值
return readWidget(comp, w).trimmed().isEmpty();
}
} // namespace
DynamicFormEditor::DynamicFormEditor(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
}
void DynamicFormEditor::setForm(const data::EditableForm& form) {
entries_.clear();
if (body_) {
body_->deleteLater();
body_ = nullptr;
}
body_ = new QWidget(this);
auto* outer = new QVBoxLayout(body_);
outer->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd,
geopro::app::space::kLg, geopro::app::space::kMd);
outer->setSpacing(geopro::app::space::kMd);
for (const auto& g : form.groups) {
if (form.groups.size() > 1 || !g.name.empty()) {
auto* sec = new QLabel(QString::fromStdString(g.name), body_);
geopro::app::applyTokenizedStyleSheet(
sec, QStringLiteral("color:{{text/secondary}};font-weight:%1;")
.arg(geopro::app::type::kWeightSemibold));
outer->addWidget(sec);
}
auto* fl = new QFormLayout();
fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
fl->setHorizontalSpacing(geopro::app::space::kMd);
fl->setVerticalSpacing(geopro::app::space::kSm);
for (const auto& f : g.fields) {
QWidget* w = buildWidget(f);
auto* lbl = new QLabel(labelText(f), body_);
lbl->setTextFormat(Qt::RichText); // 允许 * 的红色 span
fl->addRow(lbl, w);
Entry e;
e.code = QString::fromStdString(f.code);
e.name = QString::fromStdString(f.name);
e.comp = f.comp;
e.required = (f.required == kRequiredYes);
e.readonly = isReadonly(f);
e.widget = w;
entries_.push_back(e);
}
outer->addLayout(fl);
}
outer->addStretch();
layout()->addWidget(body_);
}
QMap<QString, QString> DynamicFormEditor::collectValues() const {
QMap<QString, QString> out;
for (const auto& e : entries_) out.insert(e.code, readWidget(e.comp, e.widget));
return out;
}
bool DynamicFormEditor::validateRequired(QString* missingName) const {
for (const auto& e : entries_) {
// 只读字段即使空也不拦(其值由后端核心字段或测量值产生)。
if (e.required && !e.readonly && widgetEmpty(e.comp, e.widget)) {
if (missingName) *missingName = e.name;
return false;
}
}
return true;
}
} // namespace geopro::app

View File

@ -0,0 +1,42 @@
#pragma once
#include <QMap>
#include <QString>
#include <QVector>
#include <QWidget>
#include "repo/RepoTypes.hpp"
class QLabel;
namespace geopro::app {
// 动态表单编辑器:按 EditableForm 的字段元信息渲染可编辑控件(对齐原版 project/getDynamicForm
// displayComponentTypespec §E.1 全集1/5=单行文本 2=只读文本 3=复选框 4=下拉(optionsObject)
// 6=日期 7=时间 8=日期时间 9=多行文本 10=数字 11=树选择(QComboBox 兜底);其余=步进数字。
// requiredType1=必填可编辑(标红*) 2=只读禁用 其他=可选可编辑。
// 编辑态用 properties 预填;核心字段(fieldUseType=1)的测量值只读。
// 注:本控件只负责"渲染 + 收集 + 必填校验",不发请求;提交载荷接入由上层处理。
class DynamicFormEditor : public QWidget {
Q_OBJECT
public:
explicit DynamicFormEditor(QWidget* parent = nullptr);
void setForm(const data::EditableForm& form); // 重建控件
QMap<QString, QString> collectValues() const; // fieldCode → 当前值
// 校验必填:全部满足返回 true否则返回 false 并把首个缺失字段名写入 *missingName。
bool validateRequired(QString* missingName) const;
private:
struct Entry {
QString code;
QString name;
int comp = 1;
bool required = false;
bool readonly = false; // 只读comp2 / requiredType2 / 核心测量值):不参与必填校验
QWidget* widget = nullptr; // 取值控件
};
QVector<Entry> entries_;
QWidget* body_ = nullptr; // 承载分组与字段的容器(重建时整体替换)
};
} // namespace geopro::app

View File

@ -0,0 +1,195 @@
#include "panels/ObjectAttrPanel.hpp"
#include <QFormLayout>
#include <QHBoxLayout>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
#include "Theme.hpp"
#include "api/NavLoads.hpp" // Q_DECLARE_METATYPE(EditableForm)
#include "api/NavRequest.hpp"
#include "panels/DynamicFormEditor.hpp"
#include "repo/IAsyncProjectRepository.hpp"
namespace geopro::app {
namespace {
constexpr int kConfGs = 1;
} // namespace
ObjectAttrPanel::ObjectAttrPanel(geopro::data::IAsyncProjectRepository& repo, QWidget* parent)
: QWidget(parent), repo_(repo) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
status_ = new QLabel(QStringLiteral("(选中对象后显示其属性)"), this);
status_->setAlignment(Qt::AlignCenter);
geopro::app::applyTokenizedStyleSheet(status_,
QStringLiteral("color:{{text/disabled}};padding:16px;"));
lay->addWidget(status_);
scroll_ = new QScrollArea(this);
scroll_->setWidgetResizable(true);
scroll_->setFrameShape(QFrame::NoFrame);
auto* content = new QWidget();
auto* contentLay = new QVBoxLayout(content);
contentLay->setContentsMargins(0, 0, 0, 0);
contentLay->setSpacing(0);
topBox_ = new QWidget(content); // 顶层固定字段rebuildTopFields 重填)
contentLay->addWidget(topBox_);
editor_ = new DynamicFormEditor();
contentLay->addWidget(editor_, 1);
scroll_->setWidget(content);
lay->addWidget(scroll_, 1);
auto* btnRow = new QHBoxLayout();
btnRow->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm,
geopro::app::space::kLg, geopro::app::space::kMd);
btnRow->addStretch();
saveBtn_ = new QPushButton(QStringLiteral("保存"), this);
saveBtn_->setEnabled(false);
btnRow->addWidget(saveBtn_);
lay->addLayout(btnRow);
QObject::connect(saveBtn_, &QPushButton::clicked, this, &ObjectAttrPanel::onSave);
// 初始:无选中 → 隐藏编辑区,仅占位提示。
scroll_->setVisible(false);
saveBtn_->setVisible(false);
}
void ObjectAttrPanel::showMessage(const QString& message) {
objectId_.clear();
status_->setText(message);
status_->setVisible(true);
scroll_->setVisible(false);
saveBtn_->setVisible(false);
}
void ObjectAttrPanel::loadObject(const QString& projectId, const QString& typeId,
const QString& objectId, int confType, const QString& displayName,
bool isRoot, const QString& parentId) {
// 切换对象:中止在途保存,避免旧 save 回调串台触发 saved()/启用按钮。
if (saveReq_) saveReq_->abort();
if (isRoot) { // 项目根:只读占位(仅右键可新建/属性)
showMessage(QStringLiteral("(项目根节点不可编辑)"));
return;
}
projectId_ = projectId;
confType_ = confType;
objectId_ = objectId;
typeId_ = typeId;
parentId_ = parentId;
scroll_->setVisible(true);
saveBtn_->setVisible(true);
saveBtn_->setEnabled(false);
status_->setText(QStringLiteral("加载中…"));
status_->setVisible(true);
rebuildTopFields();
if (nameEdit_) nameEdit_->setText(displayName); // 编辑态:名称预填(沿用对话框语义)
if (formReq_) formReq_->abort();
formReq_ = repo_.loadEditableFormAsync(typeId.toStdString(), objectId.toStdString(), confType,
projectId_.toStdString());
QObject::connect(formReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
const auto form = qvariant_cast<geopro::data::EditableForm>(v);
editor_->setForm(form);
status_->setVisible(false);
saveBtn_->setEnabled(true);
});
QObject::connect(formReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("加载失败:%1").arg(msg));
status_->setVisible(true);
});
}
// 顶层固定字段:编辑态(类型只读 + 名称 + GS 负责人),与 ObjectFormDialog 编辑态一致。
void ObjectAttrPanel::rebuildTopFields() {
nameEdit_ = nullptr;
responsibleEdit_ = nullptr;
if (auto* old = topBox_->layout()) {
QLayoutItem* it = nullptr;
while ((it = old->takeAt(0)) != nullptr) {
if (it->widget()) it->widget()->deleteLater();
delete it;
}
delete old;
}
auto* fl = new QFormLayout(topBox_);
fl->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd,
geopro::app::space::kLg, 0);
fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
fl->setHorizontalSpacing(geopro::app::space::kMd);
fl->setVerticalSpacing(geopro::app::space::kSm);
nameEdit_ = new QLineEdit(topBox_);
nameEdit_->setEnabled(false); // 编辑态名称禁用(与对话框一致)
fl->addRow(QStringLiteral("名称"), nameEdit_);
if (confType_ == kConfGs) {
responsibleEdit_ = new QLineEdit(topBox_);
responsibleEdit_->setPlaceholderText(QStringLiteral("负责人"));
fl->addRow(QStringLiteral("负责人"), responsibleEdit_);
}
}
void ObjectAttrPanel::onSave() {
QString missing;
if (!editor_->validateRequired(&missing)) {
QMessageBox::warning(this, QStringLiteral("校验"),
QStringLiteral("请填写必填项:%1").arg(missing));
return;
}
// 提交体口径同 ObjectFormDialog::buildBody 编辑态spec §B PUT
QJsonObject props;
const auto values = editor_->collectValues();
for (auto it = values.constBegin(); it != values.constEnd(); ++it)
props.insert(it.key(), it.value());
QJsonObject body;
body.insert(QStringLiteral("name"), nameEdit_ ? nameEdit_->text() : QString());
body.insert(QStringLiteral("projectId"), projectId_);
body.insert(QStringLiteral("properties"), props);
body.insert(QStringLiteral("id"), objectId_);
if (confType_ == kConfGs) {
body.insert(QStringLiteral("gsTypeId"), typeId_);
body.insert(QStringLiteral("responsiblePersonName"),
responsibleEdit_ ? responsibleEdit_->text() : QString());
} else {
body.insert(QStringLiteral("tmTypeId"), typeId_);
body.insert(QStringLiteral("parentId"), parentId_); // 该 TM 的父 GS/项目根 id
body.insert(QStringLiteral("parentType"), QStringLiteral("1"));
}
saveBtn_->setEnabled(false);
status_->setText(QStringLiteral("提交中…"));
status_->setVisible(true);
if (saveReq_) saveReq_->abort();
saveReq_ = repo_.submitObjectAsync(
confType_, false, QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString());
QObject::connect(saveReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) {
status_->setVisible(false);
saveBtn_->setEnabled(true);
emit saved();
});
QObject::connect(saveReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("提交失败:%1").arg(msg));
status_->setVisible(true);
saveBtn_->setEnabled(true);
});
}
} // namespace geopro::app

View File

@ -0,0 +1,67 @@
#pragma once
#include <QPointer>
#include <QString>
#include <QWidget>
namespace geopro::data {
class IAsyncProjectRepository;
class NavRequest;
} // namespace geopro::data
class QLabel;
class QLineEdit;
class QPushButton;
class QFormLayout;
class QScrollArea;
namespace geopro::app {
class DynamicFormEditor;
// 对象属性面板(右上 Tab「对象属性」可编辑动态表单 + 保存。
// 顶层固定字段与 ObjectFormDialog 编辑态一致name(可改) / 类型只读 / GS 负责人。
// 动态字段来自 project/getDynamicFormDynamicFormEditor 渲染)。
// 仅编辑既有对象(无新建/类型下拉);提交体口径与 ObjectFormDialog::buildBody 编辑态相同:
// PUT gsObject {gsTypeId,id,projectId,name,responsiblePersonName,properties}(无 parentId
// PUT tmObject {tmTypeId,id,name,properties,projectId,parentId,parentType:"1"}
// 注TM 编辑须带真实 parentId该 TM 的父 GS/项目根 id由调用方经
// ObjectTreePanel::parentObjectId 解析后传入GS 编辑不带 parentId。
class ObjectAttrPanel : public QWidget {
Q_OBJECT
public:
explicit ObjectAttrPanel(geopro::data::IAsyncProjectRepository& repo, QWidget* parent = nullptr);
// 单击对象 → 渲染可编辑表单。isRoot=项目根(只读占位,不可编辑)。
// projectId 随当前项目动态传入(面板长生命周期,不在构造期固化)。
// parentId=该对象父 GS/项目根 idTM 编辑 PUT 用GS 编辑忽略)。
void loadObject(const QString& projectId, const QString& typeId, const QString& objectId,
int confType, const QString& displayName, bool isRoot,
const QString& parentId);
void showMessage(const QString& message); // 空/占位
signals:
void saved(); // 保存成功(调用方据此 toast/刷新)
private:
void rebuildTopFields();
void onSave();
geopro::data::IAsyncProjectRepository& repo_;
QString projectId_;
QString objectId_;
QString typeId_;
QString parentId_; // TM 编辑 PUT 的父 GS/根 idGS 编辑不用)
int confType_ = 0;
QLabel* status_ = nullptr;
QScrollArea* scroll_ = nullptr;
QWidget* topBox_ = nullptr;
QLineEdit* nameEdit_ = nullptr;
QLineEdit* responsibleEdit_ = nullptr;
DynamicFormEditor* editor_ = nullptr;
QPushButton* saveBtn_ = nullptr;
QPointer<geopro::data::NavRequest> formReq_;
QPointer<geopro::data::NavRequest> saveReq_;
};
} // namespace geopro::app

View File

@ -1,8 +1,15 @@
#include "panels/ObjectTreePanel.hpp" #include "panels/ObjectTreePanel.hpp"
#include <QEvent>
#include <QLabel> #include <QLabel>
#include <QMenu>
#include <QModelIndex>
#include <QMouseEvent>
#include <QPoint>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QStringList> #include <QStringList>
#include <QStyle>
#include <QStyleOptionViewItem>
#include <QTimer> #include <QTimer>
#include <QTreeWidget> #include <QTreeWidget>
#include <QTreeWidgetItem> #include <QTreeWidgetItem>
@ -18,28 +25,32 @@ namespace geopro::app {
namespace { namespace {
constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 idGS/TM 都存) constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 idGS/TM 都存)
constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM
constexpr int kRoleTypeId = Qt::UserRole + 4; // 类型 id编辑调 getDynamicForm 用)
constexpr int kRoleIsRoot = Qt::UserRole + 5; // 项目根标记(按 GS 处理,但仅 新建GS/TM/属性)
constexpr int kConfTypeGs = 1; // GS工区 constexpr int kConfTypeGs = 1; // GS工区
constexpr int kConfTypeTm = 2; // TM 叶子 constexpr int kConfTypeTm = 2; // TM 叶子
// topLevel=true 仅用于项目根:渲染为非交互容器(既不可勾选,也不发 objectClicked // topLevel=true 仅用于项目根:按 GS 处理xlsx 第32行 + 真实数据 TM 挂根),
// 携带其 id/typeId可右键 新建GS/TM/属性;勾选随 2D/3D 批次暂不开放。
void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes, void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes,
bool topLevel) { bool topLevel) {
for (const auto& n : nodes) { for (const auto& n : nodes) {
auto* item = new QTreeWidgetItem(parent); auto* item = new QTreeWidgetItem(parent);
item->setText(0, QString::fromStdString(n.node.name)); item->setText(0, QString::fromStdString(n.node.name));
item->setData(0, kRoleObjId, QString::fromStdString(n.node.id));
item->setData(0, kRoleTypeId, QString::fromStdString(n.node.typeId));
if (topLevel) { if (topLevel) {
// 项目根:非交互容器(不设 kRoleObjId/kRoleConfType不可勾选 // 项目根:作为 GS 承载id 携带),不可勾选;菜单仅 新建GS/TM/属性。
item->setData(0, kRoleConfType, kConfTypeGs);
item->setData(0, kRoleIsRoot, true);
} else if (n.isTm) {
item->setData(0, kRoleConfType, kConfTypeTm);
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(0, Qt::Unchecked);
} else { } else {
item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); item->setData(0, kRoleConfType, kConfTypeGs); // GS
if (n.isTm) { item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate);
item->setData(0, kRoleConfType, kConfTypeTm); item->setCheckState(0, Qt::Unchecked);
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(0, Qt::Unchecked);
} else {
item->setData(0, kRoleConfType, kConfTypeGs); // GS
item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate);
item->setCheckState(0, Qt::Unchecked);
}
} }
addNodes(item, n.children, false); // 子层永远非顶层 addNodes(item, n.children, false); // 子层永远非顶层
} }
@ -64,10 +75,23 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) {
hint_->setVisible(false); hint_->setVisible(false);
lay->addWidget(hint_); lay->addWidget(hint_);
// viewport 事件过滤:记录鼠标按下是否落在复选框区,用于区分「选中」与「勾选」手势。
tree_->viewport()->installEventFilter(this);
QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) { QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) {
if (pressOnCheckbox_) { // 点的是复选框:只切换勾选态,不当作「选中」(不重载数据集列表)
pressOnCheckbox_ = false;
return;
}
const bool isRoot = item->data(0, kRoleIsRoot).toBool();
const QString id = item->data(0, kRoleObjId).toString(); const QString id = item->data(0, kRoleObjId).toString();
const int confType = item->data(0, kRoleConfType).toInt(); const int confType = item->data(0, kRoleConfType).toInt();
if (!id.isEmpty() && confType != 0) emit objectClicked(id, confType); if (id.isEmpty() || confType == 0) return;
const QString typeId = item->data(0, kRoleTypeId).toString();
// 对象属性面板:项目根也发(携 isRoot=true面板据此显只读占位
emit objectSelectedForEdit(id, confType, typeId, item->text(0), isRoot);
if (isRoot) return; // 项目根:不联动数据列表/异常(仅右键操作 + 属性占位)
emit objectClicked(id, confType);
}); });
// 勾选变化GS 级联会触发多次 itemChanged用 0ms 单发合并成一次「收集勾选叶子并发射」。 // 勾选变化GS 级联会触发多次 itemChanged用 0ms 单发合并成一次「收集勾选叶子并发射」。
QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) {
@ -88,6 +112,169 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) {
emit checkedTmsChanged(tmIds); emit checkedTmsChanged(tmIds);
}); });
}); });
// 右键菜单(对齐菜单文档:显示/隐藏、定位、属性、异常详情、编辑、新建GS/TM、导入DS、删除
// 项目根=新建GS/TM/属性GS=全项(+新建GS/TM)TM=全项(+导入DS无新建GS)。
tree_->setContextMenuPolicy(Qt::CustomContextMenu);
QObject::connect(tree_, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) {
QTreeWidgetItem* item = tree_->itemAt(pos);
if (!item) return;
const QString id = item->data(0, kRoleObjId).toString();
const int confType = item->data(0, kRoleConfType).toInt();
if (id.isEmpty() || confType == 0) return;
const QString typeId = item->data(0, kRoleTypeId).toString();
const QString name = item->text(0);
const bool isRoot = item->data(0, kRoleIsRoot).toBool();
const bool isGs = (confType == kConfTypeGs);
const bool isTm = (confType == kConfTypeTm);
QMenu menu(this);
auto add = [&](const QString& text, const QString& action) {
menu.addAction(text, this,
[this, action, id, confType, typeId, name]() {
emit contextActionRequested(action, id, confType, typeId, name);
});
};
if (isRoot) {
// 项目根(按 GS仅 新建检测对象(GS) / 新建方法对象(TM) / 属性。
add(QStringLiteral("新建检测对象"), QStringLiteral("newGs"));
add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
menu.addSeparator();
add(QStringLiteral("属性"), QStringLiteral("properties"));
menu.exec(tree_->viewport()->mapToGlobal(pos));
return;
}
add(QStringLiteral("显示 / 隐藏"), QStringLiteral("showHide"));
add(QStringLiteral("定位"), QStringLiteral("locate"));
menu.addSeparator();
add(QStringLiteral("属性"), QStringLiteral("properties"));
add(QStringLiteral("异常详情"), QStringLiteral("exceptionDetail"));
menu.addSeparator();
add(QStringLiteral("编辑"), QStringLiteral("edit"));
if (isGs) {
// GS 节点:新建检测对象 / 新建方法对象。TM 节点上不显示「新建检测对象」——xlsxtm 上新建GS 无效。)
add(QStringLiteral("新建检测对象"), QStringLiteral("newGs"));
add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
}
if (isTm) {
// TM 节点:仅「新建方法对象」(同级,父=该 TM 的父 GS/根)+ 导入 DS。
// xlsxtm 上新建GS 无效,故不显示「新建检测对象」。)
add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
add(QStringLiteral("导入数据集…"), QStringLiteral("importDs"));
}
menu.addSeparator();
add(QStringLiteral("删除"), QStringLiteral("delete"));
menu.exec(tree_->viewport()->mapToGlobal(pos));
});
}
bool ObjectTreePanel::eventFilter(QObject* watched, QEvent* event) {
if (tree_ && watched == tree_->viewport() && event->type() == QEvent::MouseButtonPress) {
auto* me = static_cast<QMouseEvent*>(event);
const QPoint pos = me->position().toPoint();
pressOnCheckbox_ = false;
const QModelIndex idx = tree_->indexAt(pos);
if (idx.isValid() && (idx.flags() & Qt::ItemIsUserCheckable)) {
// 用样式计算该项复选框指示区的精确矩形(含缩进偏移由 visualRect 给出)。
QStyleOptionViewItem opt;
opt.initFrom(tree_);
opt.rect = tree_->visualRect(idx);
opt.features |= QStyleOptionViewItem::HasCheckIndicator;
const QRect cb =
tree_->style()->subElementRect(QStyle::SE_ItemViewItemCheckIndicator, &opt, tree_);
if (cb.contains(pos)) pressOnCheckbox_ = true;
}
}
return QWidget::eventFilter(watched, event);
}
// ── 快速筛选:遍历所有 TM 叶子,对其 setCheckState。批量改用 SignalBlocker 屏蔽逐项 itemChanged
// 末尾手动触发一次 itemChanged 让既有 0ms 合并逻辑收集并发射 checkedTmsChanged。──
void ObjectTreePanel::setAllTmsChecked(bool checked) {
if (!tree_) return;
const Qt::CheckState st = checked ? Qt::Checked : Qt::Unchecked;
std::function<void(QTreeWidgetItem*)> walk = [&](QTreeWidgetItem* node) {
for (int i = 0; i < node->childCount(); ++i) {
QTreeWidgetItem* c = node->child(i);
if (c->data(0, kRoleConfType).toInt() == kConfTypeTm) c->setCheckState(0, st);
walk(c);
}
};
{
const QSignalBlocker block(tree_);
walk(tree_->invisibleRootItem());
}
emit tree_->itemChanged(nullptr, 0); // 触发既有合并发射
}
void ObjectTreePanel::invertTmChecks() {
if (!tree_) return;
std::function<void(QTreeWidgetItem*)> walk = [&](QTreeWidgetItem* node) {
for (int i = 0; i < node->childCount(); ++i) {
QTreeWidgetItem* c = node->child(i);
if (c->data(0, kRoleConfType).toInt() == kConfTypeTm)
c->setCheckState(0, c->checkState(0) == Qt::Checked ? Qt::Unchecked : Qt::Checked);
walk(c);
}
};
{
const QSignalBlocker block(tree_);
walk(tree_->invisibleRootItem());
}
emit tree_->itemChanged(nullptr, 0);
}
QString ObjectTreePanel::currentParentForNew() const {
if (!tree_) return {};
// 新建对象的父必须是 GS/项目根GS 可挂 GS/根TM 父恒为 GS/根)。
// 选中 TM 时不能以 TM 作父,上溯到其最近的 GS/根 祖先。
if (QTreeWidgetItem* cur = tree_->currentItem()) {
if (cur->data(0, kRoleConfType).toInt() == kConfTypeTm) {
for (QTreeWidgetItem* p = cur->parent(); p; p = p->parent()) {
const QString pid = p->data(0, kRoleObjId).toString();
if (!pid.isEmpty() && p->data(0, kRoleConfType).toInt() == kConfTypeGs) return pid;
}
// TM 无 GS/根祖先(异常)→ 回落项目根
} else {
const QString id = cur->data(0, kRoleObjId).toString();
if (!id.isEmpty()) return id; // GS 或 项目根
}
}
// 未选中或上溯失败回落到项目根节点顶层首个setStructure 保证结构含项目根)。
if (tree_->topLevelItemCount() > 0)
return tree_->topLevelItem(0)->data(0, kRoleObjId).toString();
return {};
}
int ObjectTreePanel::currentSelectedConfType() const {
if (!tree_) return 0;
QTreeWidgetItem* cur = tree_->currentItem();
if (!cur) return 0;
// 项目根 confType 也存为 GS(1),故 root/GS 同归为 1TM=2。
return cur->data(0, kRoleConfType).toInt();
}
QString ObjectTreePanel::parentObjectId(const QString& objectId) const {
if (!tree_ || objectId.isEmpty()) return {};
// 按 id 定位树项。
QTreeWidgetItem* found = nullptr;
std::function<void(QTreeWidgetItem*)> find = [&](QTreeWidgetItem* node) {
for (int i = 0; i < node->childCount() && !found; ++i) {
QTreeWidgetItem* c = node->child(i);
if (c->data(0, kRoleObjId).toString() == objectId) {
found = c;
return;
}
find(c);
}
};
find(tree_->invisibleRootItem());
if (!found) return {};
// 上溯到最近的 GS/根祖先(口径同 currentParentForNew 的 TM 上溯)。
for (QTreeWidgetItem* p = found->parent(); p; p = p->parent()) {
const QString pid = p->data(0, kRoleObjId).toString();
if (!pid.isEmpty() && p->data(0, kRoleConfType).toInt() == kConfTypeGs) return pid;
}
return {};
} }
void ObjectTreePanel::setStructure(const QString& projectName, void ObjectTreePanel::setStructure(const QString& projectName,

View File

@ -19,16 +19,44 @@ public:
void setStructure(const QString& projectName, const std::vector<data::StructNode>& nodes); void setStructure(const QString& projectName, const std::vector<data::StructNode>& nodes);
void showMessage(const QString& message); // 错误/空状态占位 void showMessage(const QString& message); // 错误/空状态占位
// 快速筛选器(按类型批量勾选/反选 TM 叶子;驱动既有 checkedTmsChanged 合并发射)。
void setAllTmsChecked(bool checked); // 全选 / 全不选
void invertTmChecks(); // 反选
// 面板「新建对象」按钮用:新建对象的父节点 id。
// 优先当前选中节点 id未选中时回落到项目根节点 id树空则空串。
QString currentParentForNew() const;
// 编辑 TM 用:取某对象的父 GS/项目根 idTM 上溯到最近的 GS/根祖先)。
// GS/根本身无需父 → 返回空串;找不到该对象或无 GS/根祖先 → 空串。
QString parentObjectId(const QString& objectId) const;
// 面板「新建对象」按钮按类型决定菜单:当前选中节点的 confType1=GS/项目根2=TM0=未选)。
// 选 项目根/GS → 可新建 GS + TM选 TM → 仅新建 TM同级
int currentSelectedConfType() const;
protected:
// 区分「选中」与「勾选」手势:监视 viewport 鼠标按下是否落在复选框指示区,
// 落在复选框上则该次 itemClicked 不发 objectClicked避免勾选顺带重载数据集列表
bool eventFilter(QObject* watched, QEvent* event) override;
signals: signals:
// confType: 1=GS 2=TM。单击行驱动数据列表 + 对象属性)。 // confType: 1=GS 2=TM。单击行驱动数据列表 + 对象属性)。
void objectClicked(const QString& objectId, int confType); void objectClicked(const QString& objectId, int confType);
// 单击行(含项目根,带 typeId/name/isRoot驱动对象属性面板的可编辑表单。
void objectSelectedForEdit(const QString& objectId, int confType, const QString& typeId,
const QString& name, bool isRoot);
// 当前全部被勾选的 TM 叶子 id已合并发射 // 当前全部被勾选的 TM 叶子 id已合并发射
void checkedTmsChanged(const QStringList& tmObjectIds); void checkedTmsChanged(const QStringList& tmObjectIds);
// 右键菜单动作action 取值见 .cppobjectId/confType/typeId 为右键命中项name 用于确认框/标题)。
void contextActionRequested(const QString& action, const QString& objectId, int confType,
const QString& typeId, const QString& name);
private: private:
QTreeWidget* tree_ = nullptr; // Qt 原生标准树(复选框/箭头由 Fusion 绘制,清晰可控) QTreeWidget* tree_ = nullptr; // Qt 原生标准树(复选框/箭头由 Fusion 绘制,清晰可控)
QLabel* hint_ = nullptr; QLabel* hint_ = nullptr;
bool checkPending_ = false; // 勾选合并发射防重入 bool checkPending_ = false; // 勾选合并发射防重入
bool pressOnCheckbox_ = false; // 最近一次鼠标按下是否落在复选框指示区
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -35,7 +35,7 @@ WorkbenchNavController::~WorkbenchNavController() { abortAll(); }
bool WorkbenchNavController::anyInflight() const { bool WorkbenchNavController::anyInflight() const {
if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ || if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ ||
moreFilesReq_ || datasetReq_) moreFilesReq_ || datasetReq_ || mutateReq_)
return true; return true;
for (const auto& h : checkedInflight_) for (const auto& h : checkedInflight_)
if (h) return true; if (h) return true;
@ -58,6 +58,7 @@ void WorkbenchNavController::abortAll() {
if (selDetailReq_) selDetailReq_->abort(); if (selDetailReq_) selDetailReq_->abort();
if (moreFilesReq_) moreFilesReq_->abort(); if (moreFilesReq_) moreFilesReq_->abort();
if (datasetReq_) datasetReq_->abort(); if (datasetReq_) datasetReq_->abort();
if (mutateReq_) mutateReq_->abort();
for (const auto& h : checkedInflight_) for (const auto& h : checkedInflight_)
if (h) h->abort(); if (h) h->abort();
checkedInflight_.clear(); checkedInflight_.clear();
@ -374,6 +375,76 @@ void WorkbenchNavController::selectDataset(const QString& dsObjectId) {
}); });
} }
// ── deleteObject删除 GS/TM → 成功后刷新结构switchProject 复用:重拉结构+重置选中)──
void WorkbenchNavController::deleteObject(const QString& objectId, int confType) {
if (objectId.isEmpty()) return;
if (mutateReq_) mutateReq_->abort();
NavRequest* req = repo_.deleteObjectAsync(objectId.toStdString(), confType);
mutateReq_ = req;
emitBusyIfChanged();
QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant&) {
if (req != mutateReq_) return;
mutateReq_.clear();
emit mutationSucceeded(QStringLiteral("删除成功"));
emitBusyIfChanged();
switchProject(QString::fromStdString(currentProjectId_)); // 重拉结构
});
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != mutateReq_) return;
mutateReq_.clear();
emit mutationFailed(msg);
emitBusyIfChanged();
});
}
// ── deleteDataset删除 DS → 成功后刷新当前 TM 数据集列表(重跑 selectObject──
void WorkbenchNavController::deleteDataset(const QString& dsObjectId) {
if (dsObjectId.isEmpty()) return;
if (mutateReq_) mutateReq_->abort();
NavRequest* req = repo_.deleteDatasetAsync(dsObjectId.toStdString());
mutateReq_ = req;
emitBusyIfChanged();
QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant&) {
if (req != mutateReq_) return;
mutateReq_.clear();
emit mutationSucceeded(QStringLiteral("删除成功"));
emitBusyIfChanged();
if (!currentParentId_.empty()) // 重拉当前对象的数据集列表
selectObject(QString::fromStdString(currentParentId_), currentParentConfType_);
});
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != mutateReq_) return;
mutateReq_.clear();
emit mutationFailed(msg);
emitBusyIfChanged();
});
}
// ── showObjectExceptions右键「异常详情」。GS→BFS 收集其下全部 TM 子孙 idTM→自身。
// 复用 setCheckedTms异步拉取+缓存+组装→exceptionTreeLoaded异常面板与徽标随之更新。──
void WorkbenchNavController::showObjectExceptions(const QString& objectId, int confType) {
if (objectId.isEmpty()) return;
QStringList tmIds;
if (confType == 2) { // TM 叶子
tmIds << objectId;
} else { // GS按 parentId 收集子孙中的 TM
std::unordered_map<std::string, std::vector<const StructNode*>> childrenByParent;
for (const auto& n : lastStructNodes_) childrenByParent[n.parentId].push_back(&n);
std::vector<std::string> stack{objectId.toStdString()};
while (!stack.empty()) {
const std::string cur = stack.back();
stack.pop_back();
auto it = childrenByParent.find(cur);
if (it == childrenByParent.end()) continue;
for (const StructNode* c : it->second) {
if (c->type == 2) tmIds << QString::fromStdString(c->id);
stack.push_back(c->id);
}
}
}
setCheckedTms(tmIds);
}
// ── setCheckedTms未命中缓存项并发拉取全到齐后组装新勾选 abort 旧批(以最后一次为准)── // ── setCheckedTms未命中缓存项并发拉取全到齐后组装新勾选 abort 旧批(以最后一次为准)──
void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
for (const auto& h : checkedInflight_) // abort-and-replace 旧批 for (const auto& h : checkedInflight_) // abort-and-replace 旧批

View File

@ -28,6 +28,7 @@ public:
void start(); // 启动:拉空间 → 项目 → 结构(依赖链) void start(); // 启动:拉空间 → 项目 → 结构(依赖链)
QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); } QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); }
QString currentProjectId() const { return QString::fromStdString(currentProjectId_); }
public slots: public slots:
void switchWorkspace(const QString& tenantId); void switchWorkspace(const QString& tenantId);
@ -37,6 +38,10 @@ public slots:
void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单 void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单
void loadMoreData(); void loadMoreData();
void loadMoreFiles(); void loadMoreFiles();
void deleteObject(const QString& objectId, int confType); // 删除GS/TM→成功后刷新结构
void deleteDataset(const QString& dsObjectId); // 删除DS→成功后刷新当前TM数据集列表
// 右键「异常详情」GS→收集其下全部 TM 子孙TM→自身复用 setCheckedTms 拉取并发射异常树。
void showObjectExceptions(const QString& objectId, int confType);
signals: signals:
void busyChanged(bool busy); void busyChanged(bool busy);
@ -52,6 +57,9 @@ signals:
void exceptionTreeLoaded(const std::vector<geopro::data::ObjectExceptionGroup>& groups, int exceptionCount); void exceptionTreeLoaded(const std::vector<geopro::data::ObjectExceptionGroup>& groups, int exceptionCount);
void datasetDetailLoaded(const geopro::data::DynamicForm& form); void datasetDetailLoaded(const geopro::data::DynamicForm& form);
void loadFailed(const QString& stage, const QString& message); void loadFailed(const QString& stage, const QString& message);
// 增删改结果(用于状态栏/toast 反馈;成功后控制器已自行触发相应刷新)。
void mutationSucceeded(const QString& message);
void mutationFailed(const QString& message);
private: private:
// start / switchWorkspace 依赖链:拉项目 → 拉结构(续延,复用)。 // start / switchWorkspace 依赖链:拉项目 → 拉结构(续延,复用)。
@ -76,6 +84,7 @@ private:
QPointer<data::NavRequest> selDetailReq_; // selectObject对象详情 QPointer<data::NavRequest> selDetailReq_; // selectObject对象详情
QPointer<data::NavRequest> moreFilesReq_; // loadMoreFiles数据页改客户端按根分页无在飞句柄 QPointer<data::NavRequest> moreFilesReq_; // loadMoreFiles数据页改客户端按根分页无在飞句柄
QPointer<data::NavRequest> datasetReq_; QPointer<data::NavRequest> datasetReq_;
QPointer<data::NavRequest> mutateReq_; // 删除/增改abort-and-replace 单路)
std::vector<QPointer<data::NavRequest>> checkedInflight_; // setCheckedTms未命中缓存的并发批 std::vector<QPointer<data::NavRequest>> checkedInflight_; // setCheckedTms未命中缓存的并发批
std::vector<data::ProjectSummary> lastProjects_; std::vector<data::ProjectSummary> lastProjects_;

View File

@ -1,6 +1,8 @@
#include "api/ApiProjectRepository.hpp" #include "api/ApiProjectRepository.hpp"
#include <QByteArray>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
#include <QUrl> #include <QUrl>
@ -122,4 +124,156 @@ NavRequest* ApiProjectRepository::loadExceptionsByTmAsync(const std::string& tmO
}, &isFailureA); }, &isFailureA);
} }
NavRequest* ApiProjectRepository::deleteObjectAsync(const std::string& objectId, int confType) {
// confType 1=GS → /gsObject/{id}2=TM → /tmObject/{id}。
const QString path = (confType == 1)
? QStringLiteral("/business/gsObject/%1").arg(enc(objectId))
: QStringLiteral("/business/tmObject/%1").arg(enc(objectId));
auto* call = api_.deleteAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
NavRequest* ApiProjectRepository::deleteDatasetAsync(const std::string& dsObjectId) {
const QString path = QStringLiteral("/business/dsObject/%1").arg(enc(dsObjectId));
auto* call = api_.deleteAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
NavRequest* ApiProjectRepository::loadEditableFormAsync(const std::string& typeId,
const std::string& objectId, int confType,
const std::string& projectId) {
QJsonObject body{{QStringLiteral("typeId"), QString::fromStdString(typeId)},
{QStringLiteral("type"), confType},
{QStringLiteral("projectId"), QString::fromStdString(projectId)}};
if (!objectId.empty()) body[QStringLiteral("id")] = QString::fromStdString(objectId);
auto* call = api_.postJsonAsync(QStringLiteral("/business/project/getDynamicForm"), body);
return new ApiNavRequest(call, [confType](const net::ApiResponse& r) {
return QVariant::fromValue(dto::parseEditableForm(r.data, confType));
}, &isFailureA);
}
NavRequest* ApiProjectRepository::queryTmMethodTypesAsync(const std::string& projectId) {
// 全局方法类型GET /business/project/tmList/{projectId} → value:[{tmTypeId, name, ...}]。
const QString path = QStringLiteral("/business/project/tmList/%1").arg(enc(projectId));
auto* call = api_.getAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return QVariant::fromValue(
dto::parseTmMethodTypes(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
}
NavRequest* ApiProjectRepository::queryGsTypesAsync(const std::string& projectId) {
const QString path = QStringLiteral("/business/project/gsList/%1").arg(enc(projectId));
auto* call = api_.getAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return QVariant::fromValue(dto::parseGsTypes(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
}
NavRequest* ApiProjectRepository::loadTmImportTypesAsync(const std::string& tmObjectId) {
// 复用 TM getDetail其 dsList 含可承载的数据类型canImport=true 的可导入)。
const QString path = QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(tmObjectId));
auto* call = api_.getAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return QVariant::fromValue(
dto::parseDsImportTypes(r.data.value(QStringLiteral("dsList")).toArray()));
}, &isFailureA);
}
NavRequest* ApiProjectRepository::queryImportScriptsAsync(const std::string& dsTypeId,
const std::string& tmTypeBaseConfId) {
const QString path = QStringLiteral("/business/dsObject/query/script?dsTypeId=%1&tmTypeBaseConfId=%2")
.arg(enc(dsTypeId), enc(tmTypeBaseConfId));
auto* call = api_.getAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return QVariant::fromValue(dto::parseScripts(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
}
NavRequest* ApiProjectRepository::checkImportAsync(const std::string& bodyJson) {
const QJsonObject body = QJsonDocument::fromJson(QByteArray::fromStdString(bodyJson)).object();
auto* call = api_.postJsonAsync(QStringLiteral("/business/dsObject/checkImport"), body);
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
NavRequest* ApiProjectRepository::importDatasetAsync(const std::string& queryString,
const std::string& filePath) {
QString path = QStringLiteral("/business/dsObject/import");
if (!queryString.empty()) path += QStringLiteral("?") + QString::fromStdString(queryString);
// 参数走 query stringfile 为 multipart 上传项spec §B
auto* call = api_.postMultipartAsync(path, QMap<QString, QString>{},
QStringLiteral("file"), QString::fromStdString(filePath));
if (!call) return new FailedNavRequest(QStringLiteral("无法读取所选文件"));
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
NavRequest* ApiProjectRepository::queryExportTemplatesAsync(const std::string& tmTypeBaseConfId) {
if (tmTypeBaseConfId.empty())
return new FailedNavRequest(QStringLiteral("缺少方法类型配置,无法取导出模板"));
const QString path =
QStringLiteral("/business/templateExport/queryDataType/%1").arg(enc(tmTypeBaseConfId));
auto* call = api_.getAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return QVariant::fromValue(
dto::parseExportTemplates(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
}
NavRequest* ApiProjectRepository::exportDatasetAsync(const std::string& bodyJson) {
const QJsonObject body = QJsonDocument::fromJson(QByteArray::fromStdString(bodyJson)).object();
auto* call = api_.postJsonAsync(QStringLiteral("/business/templateExport/export"), body);
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
NavRequest* ApiProjectRepository::submitObjectAsync(int confType, bool isCreate,
const std::string& bodyJson) {
const QString path = (confType == 1) ? QStringLiteral("/business/gsObject")
: QStringLiteral("/business/tmObject");
const QJsonObject body =
QJsonDocument::fromJson(QByteArray::fromStdString(bodyJson)).object();
auto* call = isCreate ? api_.postJsonAsync(path, body) : api_.putJsonAsync(path, body);
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
NavRequest* ApiProjectRepository::listModelsAsync() {
auto* call = api_.getAsync(QStringLiteral("/business/model/list"));
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return QVariant::fromValue(dto::parseModels(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
}
NavRequest* ApiProjectRepository::loadDatasetDetailAsync(const std::string& dsObjectId) {
const QString path = QStringLiteral("/business/dsObject/getDetail/%1").arg(enc(dsObjectId));
auto* call = api_.getAsync(path);
// 取现有描述纯文本attachedParameters.deltaContentQuill delta ops各 insert 串接。
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
const QJsonArray ops = r.data.value(QStringLiteral("attachedParameters"))
.toObject()
.value(QStringLiteral("deltaContent"))
.toArray();
QString text;
for (const auto& op : ops) {
const QJsonValue ins = op.toObject().value(QStringLiteral("insert"));
if (ins.isString()) text += ins.toString(); // 仅串接纯文本 insert忽略嵌入对象
}
// 剥单个尾随换行:保存侧 insert 末尾固定补 "\n"Quill 习惯),回填时去掉以免往返累积空行。
if (text.endsWith(QLatin1Char('\n'))) text.chop(1);
return QVariant::fromValue(text);
}, &isFailureA);
}
NavRequest* ApiProjectRepository::updateDatasetAsync(const std::string& bodyJson) {
const QJsonObject body = QJsonDocument::fromJson(QByteArray::fromStdString(bodyJson)).object();
// 末尾斜杠为服务端实证要求updateDsObject/)。
auto* call = api_.putJsonAsync(QStringLiteral("/business/dsObject/updateDsObject/"), body);
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
} // namespace geopro::data } // namespace geopro::data

View File

@ -25,6 +25,24 @@ public:
NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override; NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override;
NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override; NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override;
NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override; NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override;
NavRequest* deleteObjectAsync(const std::string& objectId, int confType) override;
NavRequest* deleteDatasetAsync(const std::string& dsObjectId) override;
NavRequest* loadEditableFormAsync(const std::string& typeId, const std::string& objectId,
int confType, const std::string& projectId) override;
NavRequest* queryTmMethodTypesAsync(const std::string& projectId) override;
NavRequest* queryGsTypesAsync(const std::string& projectId) override;
NavRequest* loadTmImportTypesAsync(const std::string& tmObjectId) override;
NavRequest* queryImportScriptsAsync(const std::string& dsTypeId,
const std::string& tmTypeBaseConfId) override;
NavRequest* checkImportAsync(const std::string& bodyJson) override;
NavRequest* importDatasetAsync(const std::string& queryString,
const std::string& filePath) override;
NavRequest* queryExportTemplatesAsync(const std::string& tmTypeBaseConfId) override;
NavRequest* exportDatasetAsync(const std::string& bodyJson) override;
NavRequest* submitObjectAsync(int confType, bool isCreate, const std::string& bodyJson) override;
NavRequest* listModelsAsync() override;
NavRequest* loadDatasetDetailAsync(const std::string& dsObjectId) override;
NavRequest* updateDatasetAsync(const std::string& bodyJson) override;
private: private:
net::ApiClient& api_; net::ApiClient& api_;

View File

@ -11,4 +11,11 @@ Q_DECLARE_METATYPE(std::vector<geopro::data::StructNode>)
Q_DECLARE_METATYPE(geopro::data::DsPage) Q_DECLARE_METATYPE(geopro::data::DsPage)
Q_DECLARE_METATYPE(geopro::data::DynamicForm) Q_DECLARE_METATYPE(geopro::data::DynamicForm)
Q_DECLARE_METATYPE(std::vector<geopro::data::ExceptionRow>) Q_DECLARE_METATYPE(std::vector<geopro::data::ExceptionRow>)
Q_DECLARE_METATYPE(geopro::data::EditableForm)
Q_DECLARE_METATYPE(std::vector<geopro::data::TmTypeOption>)
Q_DECLARE_METATYPE(std::vector<geopro::data::GsTypeOption>)
Q_DECLARE_METATYPE(std::vector<geopro::data::DsImportType>)
Q_DECLARE_METATYPE(std::vector<geopro::data::ScriptOption>)
Q_DECLARE_METATYPE(std::vector<geopro::data::ExportTemplate>)
Q_DECLARE_METATYPE(std::vector<geopro::data::ModelInfo>)
// bool 已内置 QMetaType。 // bool 已内置 QMetaType。

View File

@ -3,6 +3,7 @@
#include <QObject> #include <QObject>
#include <QPointer> #include <QPointer>
#include <QString> #include <QString>
#include <QTimer>
#include <QVariant> #include <QVariant>
#include "IApiCall.hpp" #include "IApiCall.hpp"
@ -36,4 +37,20 @@ private:
bool aborted_ = false; bool aborted_ = false;
}; };
// 立即失败句柄:本地前置条件不满足(如文件打开失败)时返回,异步 emit failed(message)。
class FailedNavRequest : public NavRequest {
Q_OBJECT
public:
explicit FailedNavRequest(QString message, QObject* parent = nullptr)
: NavRequest(parent), message_(std::move(message)) {
QTimer::singleShot(0, this, [this]() {
emit failed(message_);
deleteLater();
});
}
void abort() override { deleteLater(); }
private:
QString message_;
};
} // namespace geopro::data } // namespace geopro::data

View File

@ -106,6 +106,7 @@ std::vector<StructNode> parseStructNodes(const QJsonArray& arr) {
n.parentId = str(o, "parentId"); n.parentId = str(o, "parentId");
n.typeName = str(o, "typeName"); n.typeName = str(o, "typeName");
n.confCode = str(o, "confCode"); n.confCode = str(o, "confCode");
n.typeId = str(o, "typeId");
n.type = o.value(QStringLiteral("type")).toInt(); n.type = o.value(QStringLiteral("type")).toInt();
out.push_back(std::move(n)); out.push_back(std::move(n));
} }
@ -212,6 +213,164 @@ DynamicForm parseDynamicForm(const QJsonObject& data) {
return form; return form;
} }
EditableForm parseEditableForm(const QJsonObject& data, int confType) {
EditableForm form;
form.typeId = str(data, "typeId");
form.name = str(data, "name");
form.confType = confType;
const QJsonObject props = data.value(QStringLiteral("properties")).toObject();
QJsonArray groups = data.value(QStringLiteral("formList")).toArray();
std::vector<QJsonObject> gv;
gv.reserve(static_cast<size_t>(groups.size()));
for (const QJsonValue& g : groups) gv.push_back(g.toObject());
std::stable_sort(gv.begin(), gv.end(), [](const QJsonObject& a, const QJsonObject& b) {
return a.value(QStringLiteral("groupSort")).toInt() <
b.value(QStringLiteral("groupSort")).toInt();
});
for (const QJsonObject& g : gv) {
EditFieldGroup grp;
grp.name = str(g, "groupName");
grp.sort = g.value(QStringLiteral("groupSort")).toInt();
QJsonArray vals = g.value(QStringLiteral("values")).toArray();
std::vector<QJsonObject> fv;
fv.reserve(static_cast<size_t>(vals.size()));
for (const QJsonValue& v : vals) fv.push_back(v.toObject());
std::stable_sort(fv.begin(), fv.end(), [](const QJsonObject& a, const QJsonObject& b) {
return a.value(QStringLiteral("displaySort")).toInt() <
b.value(QStringLiteral("displaySort")).toInt();
});
for (const QJsonObject& f : fv) {
EditField ef;
ef.code = str(f, "fieldCode");
ef.name = str(f, "fieldName");
ef.comp = f.value(QStringLiteral("displayComponentType")).toInt(1);
// requiredType 缺省按 0=可选可编辑避免误判只读2 才是只读禁用)。
ef.required = f.value(QStringLiteral("requiredType")).toInt(0);
ef.dataType = f.value(QStringLiteral("fieldDataType")).toInt(4);
ef.useType = f.value(QStringLiteral("fieldUseType")).toInt(2);
ef.sort = f.value(QStringLiteral("displaySort")).toInt();
const QString code = QString::fromStdString(ef.code);
if (props.contains(code)) {
ef.value = props.value(code).toVariant().toString().toStdString();
ef.fromProps = true;
}
const QJsonObject cfg = f.value(QStringLiteral("fieldConfigJsonObject")).toObject();
ef.limitMin = cfg.value(QStringLiteral("limitMin")).toVariant().toString().toStdString();
ef.limitMax = cfg.value(QStringLiteral("limitMax")).toVariant().toString().toStdString();
const QJsonValue ov = f.value(QStringLiteral("optionsObject"));
if (ov.isArray()) {
for (const QJsonValue& o : ov.toArray()) {
const QJsonObject oo = o.toObject();
EditFieldOption opt;
opt.label = str(oo, "label");
opt.value = oo.value(QStringLiteral("value")).toVariant().toString().toStdString();
ef.options.push_back(std::move(opt));
}
}
grp.fields.push_back(std::move(ef));
}
form.groups.push_back(std::move(grp));
}
return form;
}
std::vector<TmTypeOption> parseTmMethodTypes(const QJsonArray& arr) {
// tmList 项:{tmTypeId, baseConfId, name, confCode, ...} → label=name, value=tmTypeId, code=confCode。
std::vector<TmTypeOption> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
TmTypeOption t;
t.label = str(o, "name");
t.value = o.value(QStringLiteral("tmTypeId")).toVariant().toString().toStdString();
t.code = str(o, "confCode");
out.push_back(std::move(t));
}
return out;
}
std::vector<GsTypeOption> parseGsTypes(const QJsonArray& arr) {
std::vector<GsTypeOption> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
GsTypeOption g;
g.name = str(o, "name");
// 兼容字段名:优先 gsTypeId回退 id / value。
g.gsTypeId = o.value(QStringLiteral("gsTypeId")).toVariant().toString().toStdString();
if (g.gsTypeId.empty()) g.gsTypeId = o.value(QStringLiteral("id")).toVariant().toString().toStdString();
if (g.gsTypeId.empty()) g.gsTypeId = o.value(QStringLiteral("value")).toVariant().toString().toStdString();
out.push_back(std::move(g));
}
return out;
}
std::vector<DsImportType> parseDsImportTypes(const QJsonArray& arr) {
std::vector<DsImportType> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
DsImportType d;
d.dsTypeId = o.value(QStringLiteral("dsTypeId")).toVariant().toString().toStdString();
// 类型名优先中文 nameChn回退 name / nameEng。
d.name = str(o, "nameChn");
if (d.name.empty()) d.name = str(o, "name");
if (d.name.empty()) d.name = str(o, "nameEng");
d.canImport = o.value(QStringLiteral("canImport")).toBool();
if (d.canImport) out.push_back(std::move(d));
}
return out;
}
std::vector<ScriptOption> parseScripts(const QJsonArray& arr) {
std::vector<ScriptOption> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
ScriptOption s;
// 脚本参数 getDynamicForm 需 typeId=scriptId兼容 id/scriptId 字段名。
s.scriptId = o.value(QStringLiteral("scriptId")).toVariant().toString().toStdString();
if (s.scriptId.empty()) s.scriptId = o.value(QStringLiteral("id")).toVariant().toString().toStdString();
s.scriptCode = str(o, "scriptCode");
s.name = str(o, "name");
if (s.name.empty()) s.name = str(o, "scriptName");
out.push_back(std::move(s));
}
return out;
}
std::vector<ExportTemplate> parseExportTemplates(const QJsonArray& arr) {
std::vector<ExportTemplate> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
ExportTemplate t;
t.id = o.value(QStringLiteral("id")).toVariant().toString().toStdString();
if (t.id.empty()) t.id = o.value(QStringLiteral("templateId")).toVariant().toString().toStdString();
t.name = str(o, "name");
if (t.name.empty()) t.name = str(o, "templateName");
out.push_back(std::move(t));
}
return out;
}
std::vector<ModelInfo> parseModels(const QJsonArray& arr) {
std::vector<ModelInfo> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
ModelInfo m;
m.id = o.value(QStringLiteral("id")).toVariant().toString().toStdString();
m.scriptCode = str(o, "scriptCode");
m.scriptName = str(o, "scriptName");
m.operationType = o.value(QStringLiteral("scriptOperationType")).toInt();
out.push_back(std::move(m));
}
return out;
}
std::vector<ExceptionRow> parseExceptions(const QJsonArray& arr) { std::vector<ExceptionRow> parseExceptions(const QJsonArray& arr) {
std::vector<ExceptionRow> out; std::vector<ExceptionRow> out;
out.reserve(static_cast<size_t>(arr.size())); out.reserve(static_cast<size_t>(arr.size()));

View File

@ -43,6 +43,29 @@ std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat)
// 表头 name 取 data["name"]。 // 表头 name 取 data["name"]。
DynamicForm parseDynamicForm(const QJsonObject& data); DynamicForm parseDynamicForm(const QJsonObject& data);
// project/getDynamicForm 的 data → 可编辑表单(保留字段元信息驱动控件)。
// 组按 groupSort、字段按 displaySort 排序;值取 properties[fieldCode](命中即标记 fromProps
// confType 由调用方传入1=GS 2=TM
EditableForm parseEditableForm(const QJsonObject& data, int confType);
// project/tmList/{projectId} 的 data["value"] 数组 → TM 方法类型选项label=name, value=tmTypeId, code=confCode
std::vector<TmTypeOption> parseTmMethodTypes(const QJsonArray& arr);
// project/gsList/{projectId} 的 data["value"] 数组 → GS 类型选项 [{name, gsTypeId}]。
std::vector<GsTypeOption> parseGsTypes(const QJsonArray& arr);
// TM getDetail 的 dsList 数组 → 可导入数据类型(仅含 canImport=true 的项)。
std::vector<DsImportType> parseDsImportTypes(const QJsonArray& arr);
// dsObject/query/script 的 data["value"] 数组 → 导入脚本选项。
std::vector<ScriptOption> parseScripts(const QJsonArray& arr);
// 导出模板列表templateExport/queryDataType 等)的 data["value"] 数组 → 模板选项。
std::vector<ExportTemplate> parseExportTemplates(const QJsonArray& arr);
// model/list 的 data["value"] 数组 → 模型/插件 [{id,scriptCode,scriptName,operationType}]。
std::vector<ModelInfo> parseModels(const QJsonArray& arr);
// ExceptionVO 数组 → [ExceptionRow]。字段id、name=exceptionName、typeName=exceptionTypeName、 // ExceptionVO 数组 → [ExceptionRow]。字段id、name=exceptionName、typeName=exceptionTypeName、
// createTimeconsortium* 取自 consortiumId/consortiumName/consortiumType来源待 live 验证); // createTimeconsortium* 取自 consortiumId/consortiumName/consortiumType来源待 live 验证);
// detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。 // detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。

View File

@ -26,6 +26,43 @@ public:
virtual NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) = 0; // DynamicForm virtual NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) = 0; // DynamicForm
virtual NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm virtual NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm
virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector<ExceptionRow> virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector<ExceptionRow>
// 删除confType 1=GS 2=TMDS 单独。payload=bool成功标志
virtual NavRequest* deleteObjectAsync(const std::string& objectId, int confType) = 0; // bool
virtual NavRequest* deleteDatasetAsync(const std::string& dsObjectId) = 0; // bool
// 可编辑动态表单(新建/编辑对象objectId 空=新建(仅取空表单),非空=编辑(含 properties 预填)。
virtual NavRequest* loadEditableFormAsync(const std::string& typeId, const std::string& objectId,
int confType, const std::string& projectId) = 0; // EditableForm
// 项目可建的 TM 方法类型列表全局GET /business/project/tmList/{projectId} → [{tmTypeId, name, ...}])。
virtual NavRequest* queryTmMethodTypesAsync(const std::string& projectId) = 0; // std::vector<TmTypeOption>
// 项目可建的 GS 测试对象类型列表GET /business/project/gsList/{projectId})。
virtual NavRequest* queryGsTypesAsync(const std::string& projectId) = 0; // std::vector<GsTypeOption>
// 某 TM 可导入的数据类型(来源 TM getDetail.dsList仅 canImport=true
virtual NavRequest* loadTmImportTypesAsync(const std::string& tmObjectId) = 0; // std::vector<DsImportType>
// 某数据类型可用的导入脚本GET /business/dsObject/query/script
virtual NavRequest* queryImportScriptsAsync(const std::string& dsTypeId,
const std::string& tmTypeBaseConfId) = 0; // std::vector<ScriptOption>
// 导入前坐标/轨迹文件校验POST /business/dsObject/checkImport。bodyJson=校验请求体。payload=bool。
virtual NavRequest* checkImportAsync(const std::string& bodyJson) = 0; // bool
// 导入数据集POST /business/dsObject/importmultipart参数走 query stringfile 上传)。
// queryString 含 dsTypeId/projectId/structParent*/scriptCode/scriptParamListJsonStr 等;
// filePath = 本地文件路径multipart "file" 项。payload=bool。
virtual NavRequest* importDatasetAsync(const std::string& queryString,
const std::string& filePath) = 0; // bool
// 导出可选模板列表(按方法类型 templateExport/queryDataType/{tmTypeBaseConfId})。
virtual NavRequest* queryExportTemplatesAsync(const std::string& tmTypeBaseConfId) = 0; // std::vector<ExportTemplate>
// 模板导出POST /business/templateExport/export。bodyJson=导出请求体。payload=bool。
virtual NavRequest* exportDatasetAsync(const std::string& bodyJson) = 0; // bool
// 提交对象confType 1=GS 2=TMisCreate=true→POST(新建)false→PUT(编辑)。
// bodyJson 为序列化后的请求体(由上层按 getDynamicForm 结构拼装。payload=bool。
virtual NavRequest* submitObjectAsync(int confType, bool isCreate,
const std::string& bodyJson) = 0; // bool
// 模型/插件列表(数据集右键「插件」用)。
virtual NavRequest* listModelsAsync() = 0; // std::vector<ModelInfo>
// 数据集详情GET dsObject/getDetail/{id})→ 现有描述纯文本attachedParameters.deltaContent
// 的各 insert 串接无则空串。payload=QString。
virtual NavRequest* loadDatasetDetailAsync(const std::string& dsObjectId) = 0; // QString描述文本
// 更新数据集描述PUT dsObject/updateDsObject/注意末尾斜杠。bodyJson=请求体。payload=bool。
virtual NavRequest* updateDatasetAsync(const std::string& bodyJson) = 0; // bool
}; };
} // namespace geopro::data } // namespace geopro::data

View File

@ -34,13 +34,54 @@ struct ProjectType { std::string id, name; };
struct ProjectListPage { std::vector<ProjectSummary> rows; int total = 0; }; 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; }; // typeId对象的类型 id编辑时调 getDynamicForm 必需)。
struct StructNode { std::string id, name, parentId, typeName, confCode, typeId; int type = 0; };
// 动态表单GS/TM/DS 详情统一模型)。值已与字段定义合并、已按 sort 排好序。 // 动态表单GS/TM/DS 详情统一模型)。值已与字段定义合并、已按 sort 排好序。
struct DynamicFormField { std::string name, value; }; struct DynamicFormField { std::string name, value; };
struct DynamicFormGroup { std::string name; std::vector<DynamicFormField> fields; }; struct DynamicFormGroup { std::string name; std::vector<DynamicFormField> fields; };
struct DynamicForm { std::string name; std::vector<DynamicFormGroup> groups; }; struct DynamicForm { std::string name; std::vector<DynamicFormGroup> groups; };
// ── 可编辑动态表单(新建/编辑对象用,来源 project/getDynamicForm──
// 保留字段元信息以驱动控件渲染(区别于上面只读的 DynamicForm 仅 name/value
struct EditFieldOption { std::string label, value; }; // 下拉项
struct EditField {
std::string code, name; // fieldCode提交键/ fieldName标签
int comp = 1; // displayComponentType: 1/5=文本 2=只读文本 3=复选 4=下拉 6=日期 7=时间 8=日期时间 9=多行 10=数字 11=树选
int required = 0; // requiredType: 1=必填可编辑 2=只读禁用 其他(含0)=可选可编辑
int dataType = 4; // fieldDataType: 2=整数 3=浮点 4=字符串 5=日期 6=日期时间 ...
int useType = 2; // fieldUseType: 1=核心字段
int sort = 0; // displaySort
std::string value; // 预填值(来自 properties新建为空
bool fromProps = false; // 值来自 properties编辑态测量值通常只读
std::string limitMin, limitMax; // 数值范围comp1 + dataType 2/3
std::vector<EditFieldOption> options; // comp4 下拉项(可空:远程设备列表等)
};
struct EditFieldGroup { std::string name; int sort = 0; std::vector<EditField> fields; };
struct EditableForm {
std::string typeId, name; // 类型 id / 类型名(弹窗标题用)
int confType = 0; // 1=GS 2=TM
std::vector<EditFieldGroup> groups;
};
// TM 类型项(新建 TM 选择方法类型用,来源 tmObject/queryTmType
struct TmTypeOption { std::string label, value, code; };
// GS 类型项(新建 GS 选择测试对象类型用,来源 project/gsList/{projectId} → [{name, gsTypeId}])。
struct GsTypeOption { std::string name, gsTypeId; };
// 导入 DS 可选数据类型(来源 TM getDetail.dsList仅 canImport=true 进入选择)。
struct DsImportType { std::string dsTypeId, name; bool canImport = false; };
// 导入脚本项(来源 dsObject/query/scripttype=6 getDynamicForm 取脚本参数)。
struct ScriptOption { std::string scriptId, scriptCode, name; };
// 导出模板项(来源 templateExport/queryDataType 等;下拉选择 templateId
struct ExportTemplate { std::string id, name; };
// 模型/插件(数据集右键「插件」列表用,来源 model/list
struct ModelInfo { std::string id, scriptCode, scriptName; int operationType = 0; };
// 异常树叶本轮只读。consortium* 空 = 独立异常detailSummary = 详情展开内联显示。 // 异常树叶本轮只读。consortium* 空 = 独立异常detailSummary = 详情展开内联显示。
struct ExceptionRow { struct ExceptionRow {
std::string id, name, typeName, createTime; std::string id, name, typeName, createTime;

View File

@ -2,6 +2,9 @@
#include "ApiCall.hpp" #include "ApiCall.hpp"
#include <QFile>
#include <QFileInfo>
#include <QHttpMultiPart>
#include <QJsonDocument> #include <QJsonDocument>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
@ -53,4 +56,55 @@ IApiCall* ApiClient::postJsonAsync(const QString& path, const QJsonObject& body)
return new ApiCall(reply); return new ApiCall(reply);
} }
IApiCall* ApiClient::putJsonAsync(const QString& path, const QJsonObject& body) {
QNetworkRequest req = impl_->buildRequest(path);
const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact);
QNetworkReply* reply = impl_->nam.put(req, payload);
return new ApiCall(reply);
}
IApiCall* ApiClient::deleteAsync(const QString& path) {
QNetworkRequest req = impl_->buildRequest(path);
QNetworkReply* reply = impl_->nam.deleteResource(req);
return new ApiCall(reply);
}
IApiCall* ApiClient::postMultipartAsync(const QString& path,
const QMap<QString, QString>& formFields,
const QString& fileFieldName, const QString& filePath) {
// 文件项必须可读否则放弃请求multipart 无文件会被服务端拒)。
auto* file = new QFile(filePath);
if (!file->open(QIODevice::ReadOnly)) {
delete file;
return nullptr;
}
auto* multi = new QHttpMultiPart(QHttpMultiPart::FormDataType);
file->setParent(multi); // 随 multipart 释放
for (auto it = formFields.constBegin(); it != formFields.constEnd(); ++it) {
QHttpPart part;
part.setHeader(QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"%1\"").arg(it.key()));
part.setBody(it.value().toUtf8());
multi->append(part);
}
QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/octet-stream"));
filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"%1\"; filename=\"%2\"")
.arg(fileFieldName, QFileInfo(filePath).fileName()));
filePart.setBodyDevice(file);
multi->append(filePart);
// multipart 自带 boundary 的 Content-Type用不含 JSON 头的请求,只保留 token。
QNetworkRequest req{QUrl(impl_->baseUrl + path)};
if (!impl_->token.isEmpty()) req.setRawHeader(QByteArray(kTokenHeader), impl_->token.toUtf8());
QNetworkReply* reply = impl_->nam.post(req, multi);
multi->setParent(reply); // multipart 生命周期绑定到 reply
return new ApiCall(reply);
}
} // namespace geopro::net } // namespace geopro::net

View File

@ -1,6 +1,7 @@
#pragma once #pragma once
#include <QJsonObject> #include <QJsonObject>
#include <QMap>
#include <QString> #include <QString>
#include <memory> #include <memory>
@ -36,9 +37,17 @@ public:
// 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。 // 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。
void setToken(const QString& token); void setToken(const QString& token);
// 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。 // 异步 GET / POST / PUT(JSON) / DELETE:立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。
IApiCall* getAsync(const QString& path); IApiCall* getAsync(const QString& path);
IApiCall* postJsonAsync(const QString& path, const QJsonObject& body); IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);
IApiCall* putJsonAsync(const QString& path, const QJsonObject& body);
// DELETE后端多数以路径 id 标识资源、无请求体。
IApiCall* deleteAsync(const QString& path);
// multipart/form-data POST文件上传。path 可含 query string导入参数走 query
// formFields = 额外文本表单项name→valuefileFieldName/filePath = 单个文件项。
// 文件读取失败(路径不存在/不可读)返回 nullptr调用方据此报错。
IApiCall* postMultipartAsync(const QString& path, const QMap<QString, QString>& formFields,
const QString& fileFieldName, const QString& filePath);
private: private:
struct Impl; struct Impl;

View File

@ -61,6 +61,45 @@ struct StubAsyncRepo : data::IAsyncProjectRepository {
exceptions.push_back(r); exceptions.push_back(r);
return r; return r;
} }
data::NavRequest* deleteObjectAsync(const std::string&, int) override {
return lastMutate = new StubNavRequest;
}
data::NavRequest* deleteDatasetAsync(const std::string&) override {
return lastMutate = new StubNavRequest;
}
data::NavRequest* loadEditableFormAsync(const std::string&, const std::string&, int,
const std::string&) override {
return new StubNavRequest;
}
data::NavRequest* queryTmMethodTypesAsync(const std::string&) override {
return new StubNavRequest;
}
data::NavRequest* queryGsTypesAsync(const std::string&) override { return new StubNavRequest; }
data::NavRequest* loadTmImportTypesAsync(const std::string&) override {
return new StubNavRequest;
}
data::NavRequest* queryImportScriptsAsync(const std::string&, const std::string&) override {
return new StubNavRequest;
}
data::NavRequest* checkImportAsync(const std::string&) override { return new StubNavRequest; }
data::NavRequest* importDatasetAsync(const std::string&, const std::string&) override {
return new StubNavRequest;
}
data::NavRequest* queryExportTemplatesAsync(const std::string&) override {
return new StubNavRequest;
}
data::NavRequest* exportDatasetAsync(const std::string&) override { return new StubNavRequest; }
data::NavRequest* submitObjectAsync(int, bool, const std::string&) override {
return lastMutate = new StubNavRequest;
}
data::NavRequest* listModelsAsync() override { return new StubNavRequest; }
data::NavRequest* loadDatasetDetailAsync(const std::string&) override {
return new StubNavRequest;
}
data::NavRequest* updateDatasetAsync(const std::string&) override {
return lastMutate = new StubNavRequest;
}
StubNavRequest* lastMutate = nullptr;
}; };
QVariant wsVar() { QVariant wsVar() {

View File

@ -68,10 +68,10 @@ TEST(NavDto, ParseStructNodesMapsParentAndType) {
TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) { TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) {
const std::vector<StructNode> flat = { const std::vector<StructNode> flat = {
{"gs1", "工区1", "", "GS", "", 1}, {"gs1", "工区1", "", "GS", "", "", 1},
{"tm1", "测线1", "gs1", "TM", "", 2}, {"tm1", "测线1", "gs1", "TM", "", "", 2},
{"tm2", "测线2", "gs1", "TM", "", 2}, {"tm2", "测线2", "gs1", "TM", "", "", 2},
{"tmD", "直挂测线", "", "TM", "", 2}, // TM 直挂项目(无 GS {"tmD", "直挂测线", "", "TM", "", "", 2}, // TM 直挂项目(无 GS
}; };
const auto roots = dto::buildStructTree(flat); const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 2u); // gs1 + tmD ASSERT_EQ(roots.size(), 2u); // gs1 + tmD
@ -86,7 +86,7 @@ TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) {
TEST(NavDto, BuildStructTreeOrphanParentBecomesRoot) { TEST(NavDto, BuildStructTreeOrphanParentBecomesRoot) {
const std::vector<StructNode> flat = { const std::vector<StructNode> flat = {
{"tmX", "孤儿测线", "ghost", "TM", "", 2}, // parentId 不在集合内 {"tmX", "孤儿测线", "ghost", "TM", "", "", 2}, // parentId 不在集合内
}; };
const auto roots = dto::buildStructTree(flat); const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 1u); ASSERT_EQ(roots.size(), 1u);
@ -101,10 +101,10 @@ TEST(NavDto, BuildStructTreeEmpty) {
TEST(NavDto, BuildStructTreeHandlesCycleWithoutInfiniteRecursion) { TEST(NavDto, BuildStructTreeHandlesCycleWithoutInfiniteRecursion) {
// 不可信数据:重复 id 形成可达环R→X→Y→重复X…。必须终止、不崩。 // 不可信数据:重复 id 形成可达环R→X→Y→重复X…。必须终止、不崩。
const std::vector<StructNode> flat = { const std::vector<StructNode> flat = {
{"R", "", "", "GS", "", 1}, {"R", "", "", "GS", "", "", 1},
{"X", "x", "R", "GS", "", 1}, {"X", "x", "R", "GS", "", "", 1},
{"Y", "y", "X", "GS", "", 1}, {"Y", "y", "X", "GS", "", "", 1},
{"X", "x2", "Y", "TM", "", 2}, // 重复 id X父=Y → 若不防环将无限递归 {"X", "x2", "Y", "TM", "", "", 2}, // 重复 id X父=Y → 若不防环将无限递归
}; };
const auto roots = dto::buildStructTree(flat); // 不挂起即通过 const auto roots = dto::buildStructTree(flat); // 不挂起即通过
ASSERT_EQ(roots.size(), 1u); ASSERT_EQ(roots.size(), 1u);
@ -131,11 +131,12 @@ TEST(NavDto, ParseProjectListArrayMapsItem) {
TEST(NavDto, BuildStructTreeDropsDsAndTmStaysLeaf) { TEST(NavDto, BuildStructTreeDropsDsAndTmStaysLeaf) {
// 真实形态:项目(1) → TM(2) → DS(3)。DS 不进树;带 DS 子节点的 TM 仍是 TM 叶子。 // 真实形态:项目(1) → TM(2) → DS(3)。DS 不进树;带 DS 子节点的 TM 仍是 TM 叶子。
// 字段序id,name,parentId,typeName,confCode,typeId,typetypeId 为新增,测试填空串)。
const std::vector<StructNode> flat = { const std::vector<StructNode> flat = {
{"P", "项目", "0", "PRJ", "", 1}, {"P", "项目", "0", "PRJ", "", "", 1},
{"T1", "ERT1", "P", "ERT", "ERT", 2}, {"T1", "ERT1", "P", "ERT", "ERT", "", 2},
{"D1", "批次1","T1", "", "", 3}, // DS应被过滤 {"D1", "批次1","T1", "", "", "", 3}, // DS应被过滤
{"T2", "ERT2", "P", "ERT", "ERT", 2}, {"T2", "ERT2", "P", "ERT", "ERT", "", 2},
}; };
const auto roots = dto::buildStructTree(flat); const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 1u); // 仅项目根parentId "0" ASSERT_EQ(roots.size(), 1u); // 仅项目根parentId "0"
@ -308,3 +309,69 @@ TEST(NavDto, GroupExceptionsAllLooseWhenNoConsortium) {
EXPECT_TRUE(g.consortia.empty()); EXPECT_TRUE(g.consortia.empty());
EXPECT_EQ(g.loose.size(), 2u); EXPECT_EQ(g.loose.size(), 2u);
} }
TEST(NavDto, ParseGsTypesMapsNameAndId) {
const auto arr = arrOf(R"([
{"name":"测区","gsTypeId":"gt1"},
{"name":"测点","id":"gt2"}
])");
const auto types = dto::parseGsTypes(arr);
ASSERT_EQ(types.size(), 2u);
EXPECT_EQ(types[0].name, "测区");
EXPECT_EQ(types[0].gsTypeId, "gt1");
EXPECT_EQ(types[1].gsTypeId, "gt2"); // 回退 id
}
TEST(NavDto, ParseDsImportTypesKeepsOnlyCanImport) {
const auto arr = arrOf(R"([
{"dsTypeId":"d1","nameChn":"电阻率","canImport":true},
{"dsTypeId":"d2","nameChn":"轨迹","canImport":false}
])");
const auto t = dto::parseDsImportTypes(arr);
ASSERT_EQ(t.size(), 1u);
EXPECT_EQ(t[0].dsTypeId, "d1");
EXPECT_EQ(t[0].name, "电阻率");
EXPECT_TRUE(t[0].canImport);
}
TEST(NavDto, ParseEditableFormPreservesCompAndRequired) {
const auto data = QJsonDocument::fromJson(QByteArray(R"({
"typeId":"tt1","name":"测区",
"formList":[{"groupName":"基本","groupSort":0,"values":[
{"fieldCode":"topography","fieldName":"地形","displayComponentType":8,"requiredType":2,"displaySort":1},
{"fieldCode":"device","fieldName":"设备","displayComponentType":4,"requiredType":1,"displaySort":0,
"optionsObject":[{"label":"A","value":"a"}]}
]}],
"properties":{"topography":"山地"}
})")).object();
const auto f = dto::parseEditableForm(data, 1);
EXPECT_EQ(f.confType, 1);
ASSERT_EQ(f.groups.size(), 1u);
ASSERT_EQ(f.groups[0].fields.size(), 2u);
// 已按 displaySort 排序device(sort0) 在前。
EXPECT_EQ(f.groups[0].fields[0].code, "device");
EXPECT_EQ(f.groups[0].fields[0].comp, 4);
EXPECT_EQ(f.groups[0].fields[0].required, 1);
ASSERT_EQ(f.groups[0].fields[0].options.size(), 1u);
EXPECT_EQ(f.groups[0].fields[0].options[0].value, "a");
// topographycomp8 只读、有 properties 预填。
EXPECT_EQ(f.groups[0].fields[1].comp, 8);
EXPECT_EQ(f.groups[0].fields[1].required, 2);
EXPECT_EQ(f.groups[0].fields[1].value, "山地");
EXPECT_TRUE(f.groups[0].fields[1].fromProps);
}
TEST(NavDto, ParseEditableFormDefaultsRequiredTypeToOptional) {
// 缺省 requiredType → required==0语义可选可编辑非必填亦非只读
const auto data = QJsonDocument::fromJson(QByteArray(R"({
"typeId":"tt1","name":"测区",
"formList":[{"groupName":"基本","groupSort":0,"values":[
{"fieldCode":"note","fieldName":"备注","displayComponentType":1,"displaySort":0}
]}],
"properties":{}
})")).object();
const auto f = dto::parseEditableForm(data, 1);
ASSERT_EQ(f.groups.size(), 1u);
ASSERT_EQ(f.groups[0].fields.size(), 1u);
EXPECT_EQ(f.groups[0].fields[0].required, 0);
}