diff --git a/docs/superpowers/plans/2026-06-13-object-dataset-interactions.md b/docs/superpowers/plans/2026-06-13-object-dataset-interactions.md new file mode 100644 index 0000000..2fac2fb --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-object-dataset-interactions.md @@ -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.` 嵌套、displayComponentType 全集映射、 + requiredType(1=必填 / 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,简化为纯文本/富文本 QTextEdit,deltaContent 用最小 delta 承载 | +| **P1** | **#3 面板级「添加+」按钮**(对象列表→新建GS/TM;数据集列表→导入) | 纯前端,复用现有 newGs/newTm/import 入口 | **本轮做** | +| P2 | #4 异常→异常体 拖拽合并(对象异常面板) | `exception/*`(合并接口待查) | 未做 | +| P2 | #5 数据集任务:新增处理任务/保存处理结果 | 与模型/插件流程耦合 | 未做(随插件) | +| P3 | #2 属性面板内联编辑 + 实时跳转(属性指向另一 ds 时点击跳转新建详情页) | — | 未做 | +| P3 | #6 数据详情处理类编辑(异常标注/色阶/白化/滤波/另存为) | 与渲染耦合 | 未做(随 2D/3D 或专项) | + +## 四、暂缓 / 排除 + +- **插件机制**(spec §E.3):web 无对应交互,需产品/后端决策(客户端反查 `model/list`+各模型 `dsObjectList`,或后端加"按 ds 列模型"接口)。**待与用户深入讨论后再实现。** +- **2D/3D 相关**:显示/隐藏、定位、双击地图获焦 —— 占位,随 2D/3D 批次。 +- GS 勾选三态语义(屏蔽不改状态、可恢复)、项目根勾选 —— 随 2D/3D 批次。 +- 动态表单只读规则细节:以 §E.1 的 `displayComponentType=2` / `requiredType=2` 为准。 diff --git a/docs/superpowers/specs/2026-06-13-batch2-object-dataset-dialogs.md b/docs/superpowers/specs/2026-06-13-batch2-object-dataset-dialogs.md new file mode 100644 index 0000000..29a8d3d --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-batch2-object-dataset-dialogs.md @@ -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 = 右键所在节点 id(GS 或项目根);parentType 恒字符串 "1" + +POST /business/dsObject/import (参数走 query string;file 为 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.` 下。** +证据:编辑弹窗真实字段 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.` | 是 | —— | 随 bundle | ✅ 源码 + 运行时双证 | +> 注:拦截-阻断的运行时抓包在本数据集走不通(变更表单必填项多、arco 校验拦截、且不写生产库),但**源码分析已足以确证 body**,无需真实写入。 + +### C. 客户端待改项(对照现有实现) + +1. **`ObjectFormDialog` 提交体字段错误**(最关键): + - 现:`{typeId, type, projectId, id?, properties}` + extraBody `{structParentId, structParentConfType}` + - 应(按 bundle):GS→`{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 + name;TM 新建须含 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.`。 +- **`requiredType` 语义纠正:1=必填可编辑;2=只读禁用(`disabled: isDisabled||requiredType===2`,required 仅 ===1);其他=可选可编辑。** +- comp 类型由后端按类型配置(如"测区"把地形地貌配成 6=日期选择器),客户端按本表渲染即可。 + +**E.2 导出 body(ExportModal `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": }` +→ 返回结构同 getDetail: +``` +{ + typeId, confCode, name(类型名), description, + formList: [ { groupName, values: [ FieldDef... ] } ], // 可多组(对应弹窗内分页签:基本信息/测线布设/数据质量检查...) + properties: { : <当前值> } // 编辑预填;新建时为空 +} +``` +(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:, 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 为上传项(multipart);scriptParamListJsonStr 结构未捕获。 + +## 五、导出 +`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-5:gsTypeId/responsiblePersonName/name、tmTypeId/name)。**displayComponentType 全集映射仍需核实。** +2. **修编辑/新建提交体字段**(§C-1):GS→gsTypeId+parentId(仅新建)+name+responsiblePersonName;TM→tmTypeId+parentId+parentType:"1"+name。 +3. 项目根菜单按 GS 处理(§C-2);新建TM 类型用 queryTmType(§C-3);父对象=右键节点(§C-4)。 +4. 导入 DS(dsList 选类型 → query/script → 文件 → checkImport → import,body 见 §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**(会改数据,谨慎)。 diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 04437d0..4190b53 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -29,6 +29,9 @@ add_executable(geopro_desktop WIN32 panels/DatasetListPanel.cpp panels/ObjectTreePanel.cpp panels/DynamicFormView.cpp + panels/DynamicFormEditor.cpp + panels/ObjectAttrPanel.cpp + panels/DatasetAttrPanel.cpp panels/ObjectExceptionPanel.cpp panels/DescriptionPanel.cpp panels/chart/RawDataChartView.cpp @@ -53,6 +56,9 @@ add_executable(geopro_desktop WIN32 panels/DatasetDetailPanel.cpp CentralScene.cpp ProjectListDialog.cpp + ObjectFormDialog.cpp + ImportDatasetDialog.cpp + ExportDatasetDialog.cpp SettingsDialog.cpp Logging.cpp) diff --git a/src/app/ExportDatasetDialog.cpp b/src/app/ExportDatasetDialog.cpp new file mode 100644 index 0000000..2311c67 --- /dev/null +++ b/src/app/ExportDatasetDialog.cpp @@ -0,0 +1,112 @@ +#include "ExportDatasetDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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>(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; + } + // 模板导出 body(spec §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 diff --git a/src/app/ExportDatasetDialog.hpp b/src/app/ExportDatasetDialog.hpp new file mode 100644 index 0000000..eb1831d --- /dev/null +++ b/src/app/ExportDatasetDialog.hpp @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include +#include + +#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 templates_; + + QComboBox* templateCombo_ = nullptr; + QLabel* status_ = nullptr; + QPushButton* okBtn_ = nullptr; + QPointer tplReq_; + QPointer expReq_; +}; + +} // namespace geopro::app diff --git a/src/app/ImportDatasetDialog.cpp b/src/app/ImportDatasetDialog.cpp new file mode 100644 index 0000000..402f624 --- /dev/null +++ b/src/app/ImportDatasetDialog.cpp @@ -0,0 +1,266 @@ +#include "ImportDatasetDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; // structParentConfType:TM=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(&QComboBox::currentIndexChanged), this, + [this](int) { onTypeChanged(); }); + QObject::connect(scriptCombo_, qOverload(&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>(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(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>(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(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 string(spec §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 diff --git a/src/app/ImportDatasetDialog.hpp b/src/app/ImportDatasetDialog.hpp new file mode 100644 index 0000000..eba774b --- /dev/null +++ b/src/app/ImportDatasetDialog.hpp @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include + +#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.dsList,canImport=true) +// 2) 选脚本(dsObject/query/script)→ getDynamicForm(typeId=scriptId, type=6) 取脚本参数表单 +// 3) 选文件 +// 4) POST dsObject/checkImport 校验 → POST dsObject/import(multipart) +// 注:部分接口细节(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 types_; + std::vector scripts_; + + QComboBox* typeCombo_ = nullptr; + QComboBox* scriptCombo_ = nullptr; + QLineEdit* fileEdit_ = nullptr; + DynamicFormEditor* paramEditor_ = nullptr; + QLabel* status_ = nullptr; + QPushButton* okBtn_ = nullptr; + + QPointer typeReq_; + QPointer scriptReq_; + QPointer formReq_; + QPointer checkReq_; + QPointer importReq_; +}; + +} // namespace geopro::app diff --git a/src/app/ObjectFormDialog.cpp b/src/app/ObjectFormDialog.cpp new file mode 100644 index 0000000..20018a1 --- /dev/null +++ b/src/app/ObjectFormDialog.cpp @@ -0,0 +1,303 @@ +#include "ObjectFormDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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/根 id(GS 编辑忽略) + 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(&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>(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>(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(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 diff --git a/src/app/ObjectFormDialog.hpp b/src/app/ObjectFormDialog.hpp new file mode 100644 index 0000000..8848f41 --- /dev/null +++ b/src/app/ObjectFormDialog.hpp @@ -0,0 +1,79 @@ +#pragma once +#include +#include +#include +#include +#include + +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 禁用)。 +// 新建 GS:newGs(parentId)(dialog 拉 gsList 渲染 gsTypeId 下拉,切换重载动态表单)。 +// 新建 TM:newTm(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/项目根 id(TM 编辑 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 / tmTypeId(GS 新建时随下拉变化) + 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; // GS:responsiblePersonName + + DynamicFormEditor* editor_ = nullptr; + QWidget* topBox_ = nullptr; // 顶层字段容器(重建时整体替换) + QLabel* status_ = nullptr; + QPushButton* okBtn_ = nullptr; + QPointer req_; + QPointer subReq_; + QPointer typeReq_; // gsList / tmList 拉取(新建 GS/TM 共用) +}; + +} // namespace geopro::app diff --git a/src/app/PanelHeader.cpp b/src/app/PanelHeader.cpp index 8b2fea7..3566ef0 100644 --- a/src/app/PanelHeader.cpp +++ b/src/app/PanelHeader.cpp @@ -66,6 +66,7 @@ QWidget* makeActionButton(QWidget* parent, const HeaderAction& a) { auto* btn = new QToolButton(parent); btn->setObjectName(QStringLiteral("panelAction")); + btn->setProperty("glyphId", static_cast(a.first)); // 供调用方按图标定位并连接真实功能 setThemedGlyph(btn, a.first, kActionIcon); btn->setIconSize(QSize(kActionIcon, kActionIcon)); btn->setCursor(Qt::PointingHandCursor); diff --git a/src/app/main.cpp b/src/app/main.cpp index e602d49..94618f8 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -37,9 +37,15 @@ #include #include #include +#include #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -84,7 +90,11 @@ #include "TopBar.hpp" #include "CentralScene.hpp" #include "ProjectListDialog.hpp" +#include "ObjectFormDialog.hpp" +#include "ImportDatasetDialog.hpp" #include "WorkbenchNavController.hpp" +#include "api/NavRequest.hpp" +#include "api/NavLoads.hpp" #include "DatasetDetailController.hpp" #include "panels/chart/ErtInversionStrategy.hpp" #include "panels/chart/MeasurementStrategy.hpp" @@ -98,6 +108,8 @@ #include "panels/DatasetListPanel.hpp" #include "panels/DatasetDetailPanel.hpp" #include "panels/DynamicFormView.hpp" +#include "panels/ObjectAttrPanel.hpp" +#include "panels/DatasetAttrPanel.hpp" #include "panels/ObjectExceptionPanel.hpp" #include "CameraPreset.hpp" @@ -417,9 +429,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 左上 dock:对象树(真实结构:项目根 → GS → TM)。被动视图,数据由控制器推送。 auto* objectTree = new geopro::app::ObjectTreePanel(); auto* leftDock = new ads::CDockWidget(QStringLiteral("对象")); - leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象"), - objectTree, - {{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}})); + auto* objectBox = wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象"), objectTree, + {{geopro::app::Glyph::Filter, QStringLiteral("筛选")}, + {geopro::app::Glyph::Plus, QStringLiteral("新建对象")}}); + leftDock->setWidget(objectBox); auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock); // 左下 dock:数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。 @@ -449,7 +462,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 右上 dock:异常列表 / 对象属性 合并为带 Tab 表头的面板(对齐原型上半)。 auto* exceptionPanel = new geopro::app::ObjectExceptionPanel(); - auto* objAttrView = new geopro::app::DynamicFormView(); + auto* objAttrView = new geopro::app::ObjectAttrPanel(projectRepo); auto anomalyPanel = geopro::app::buildTabbedPanel( {{geopro::app::Glyph::Anomaly, QStringLiteral("对象异常"), exceptionPanel, true}, @@ -470,8 +483,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re rightDock->setWidget(anomalyPanel.container); auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock); - // 右下 dock:属性(数据集属性,键值;对齐原型下半,独立面板)。 - auto* propView = new geopro::app::DynamicFormView(); + // 右下 dock:属性(数据集属性,上半只读元字段 + 下半可编辑描述)。 + auto* propView = new geopro::app::DatasetAttrPanel(projectRepo); auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性")); propDock->setWidget( wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propView)); @@ -500,14 +513,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ── 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()) { nav.loadMoreData(); return; } const QString dsId = item->data(0, geopro::app::kDsIdRole).toString(); if (dsId.isEmpty()) return; - nav.selectDataset(dsId); // 属性表单(现状) + nav.selectDataset(dsId); // 只读元字段表单(datasetDetailLoaded) + propView->selectDataset(dsId); // 可编辑描述:回填 + 启用保存 detailCtrl.focusDataset(dsId); // 单击=聚焦已开页 }); @@ -692,12 +706,346 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re &geopro::controller::WorkbenchNavController::selectObject); QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &nav, &geopro::controller::WorkbenchNavController::setCheckedTms); - - // 控制器详情/异常/数据集表单 → 三个被动面板。 - QObject::connect(&nav, &geopro::controller::WorkbenchNavController::objectDetailLoaded, objAttrView, - [objAttrView](const QString&, const geopro::data::DynamicForm& form) { - objAttrView->setForm(form); + // 单击对象 → 对象属性面板渲染可编辑表单(projectId 取当前项目;项目根只读占位)。 + QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectSelectedForEdit, objAttrView, + [objAttrView, objectTree, &nav](const QString& id, int confType, + const QString& typeId, const QString& name, + bool isRoot) { + objAttrView->loadObject(nav.currentProjectId(), typeId, id, confType, name, + isRoot, objectTree->parentObjectId(id)); }); + + // 当前选中的 TM id(confType==2 时记录,其它选中清空):数据集面板「上传」按钮据此定父对象。 + auto currentTmId = std::make_shared(); + 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&) { + 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(g); + for (auto* b : box->findChildren(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 渲染可编辑表单; + // 确定→校验+提交(PUT,body 为推断结构,确切性以服务端为准)→成功刷新结构。 + 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")) { + // 导入 DS:TM 右键 → 选数据类型/脚本/文件 → checkImport → import(multipart)。 + 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>(); + { + auto* mReq = projectRepo.listModelsAsync(); + QObject::connect(mReq, &geopro::data::NavRequest::done, &window, + [modelsCache](const QVariant& v) { + *modelsCache = qvariant_cast>(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>(); // 当前被取消勾选的类型 + auto minDate = std::make_shared(); // 创建日期下限(无效=不限) + auto reapply = [datasetList, hiddenTypes, minDate]() { + QSet 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&) { + 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, exceptionPanel, [exceptionPanel, anomalyBadge]( diff --git a/src/app/panels/DatasetAttrPanel.cpp b/src/app/panels/DatasetAttrPanel.cpp new file mode 100644 index 0000000..8fde6b2 --- /dev/null +++ b/src/app/panels/DatasetAttrPanel.cpp @@ -0,0 +1,128 @@ +#include "panels/DatasetAttrPanel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 diff --git a/src/app/panels/DatasetAttrPanel.hpp b/src/app/panels/DatasetAttrPanel.hpp new file mode 100644 index 0000000..7087ada --- /dev/null +++ b/src/app/panels/DatasetAttrPanel.hpp @@ -0,0 +1,52 @@ +#pragma once +#include +#include +#include + +#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 loadReq_; + QPointer saveReq_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/DatasetListPanel.cpp b/src/app/panels/DatasetListPanel.cpp index d2e2ab8..1e418f5 100644 --- a/src/app/panels/DatasetListPanel.cpp +++ b/src/app/panels/DatasetListPanel.cpp @@ -123,6 +123,14 @@ QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) { item->setData(0, kDsDdTypeRole, QString::fromStdString(d.ddCode)); item->setData(0, kDsDdCodeRole, QString::fromStdString(d.ddCode)); 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; } } // namespace @@ -188,4 +196,60 @@ void applyDatasetCardDelegate(QAbstractItemView* view) { [view]() { view->viewport()->update(); }); } +QStringList collectDatasetTypeNames(QTreeWidget* tree) { + QStringList types; + if (!tree) return types; + QSet 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& 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& 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& visibleTypes, const QDate& minDate) { + if (!tree) return; + for (int i = 0; i < tree->topLevelItemCount(); ++i) + applyFilterRec(tree->topLevelItem(i), visibleTypes, minDate); +} + } // namespace geopro::app diff --git a/src/app/panels/DatasetListPanel.hpp b/src/app/panels/DatasetListPanel.hpp index a06d937..1f437b5 100644 --- a/src/app/panels/DatasetListPanel.hpp +++ b/src/app/panels/DatasetListPanel.hpp @@ -1,6 +1,10 @@ #pragma once #include +#include +#include +#include + #include "repo/RepoTypes.hpp" class QListWidget; @@ -16,6 +20,8 @@ constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2(文件下载 url constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行 constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4(ddCode,双击详情选策略用) constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5(dsName,详情页签标题用) +constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6(类型名,快速筛选用) +constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7(创建时间,按日期筛选用) // 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。 // 每项列0:文本 = dsName +「创建时间 · 类型名」;data(0,角色) 存 dsId/ddCode/dsName。 @@ -28,4 +34,10 @@ void populateFileList(QListWidget* list, const std::vector& // 接受 QListWidget(文件)或 QTreeWidget(数据树)——故形参为其共同基类 QAbstractItemView。 void applyDatasetCardDelegate(QAbstractItemView* view); +// 快速筛选辅助:收集数据集树中出现过的全部类型名(去重,按出现序)。 +QStringList collectDatasetTypeNames(QTreeWidget* tree); +// 按类型名集合 + 创建日期下限(minDate 为空=不限)过滤显示:不匹配的项隐藏。 +// 含子节点时:父项只要自身或任一可见后代匹配即保持可见(树完整性)。 +void applyDatasetFilter(QTreeWidget* tree, const QSet& visibleTypes, const QDate& minDate); + } // namespace geopro::app diff --git a/src/app/panels/DynamicFormEditor.cpp b/src/app/panels/DynamicFormEditor.cpp new file mode 100644 index 0000000..ae93415 --- /dev/null +++ b/src/app/panels/DynamicFormEditor.cpp @@ -0,0 +1,275 @@ +#include "panels/DynamicFormEditor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +namespace { +// fieldDataType:2=整数 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)。 + +// requiredType:1=必填可编辑;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(" *") + .arg(QString::fromUtf8(geopro::app::semantic::kDanger)); + return t; +} + +// 把 optionsObject 顶层平铺为 (label,value) 对。childList 暂未递归;comp11 树选择用 +// QComboBox 兜底降级(见 buildWidget),故仅平铺顶层项。 +void flattenOptions(const std::vector& 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(w)) + return cbx->isChecked() ? QStringLiteral("1") : QStringLiteral("0"); + return {}; + case kCompSelect: + case kCompTreeSelect: + if (auto* cb = qobject_cast(w)) { + const QVariant d = cb->currentData(); + return d.isValid() ? d.toString() : cb->currentText(); + } + return {}; + case kCompDate: + if (auto* de = qobject_cast(w)) + return de->date().toString(QStringLiteral("yyyy-MM-dd")); + return {}; + case kCompTime: + if (auto* te = qobject_cast(w)) + return te->time().toString(QStringLiteral("HH:mm:ss")); + return {}; + case kCompDateTime: + if (auto* dt = qobject_cast(w)) + return dt->dateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); + return {}; + case kCompMultiline: + if (auto* te = qobject_cast(w)) return te->toPlainText(); + return {}; + default: + if (auto* le = qobject_cast(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 DynamicFormEditor::collectValues() const { + QMap 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 diff --git a/src/app/panels/DynamicFormEditor.hpp b/src/app/panels/DynamicFormEditor.hpp new file mode 100644 index 0000000..38f706e --- /dev/null +++ b/src/app/panels/DynamicFormEditor.hpp @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include +#include + +#include "repo/RepoTypes.hpp" + +class QLabel; + +namespace geopro::app { + +// 动态表单编辑器:按 EditableForm 的字段元信息渲染可编辑控件(对齐原版 project/getDynamicForm)。 +// displayComponentType(spec §E.1 全集):1/5=单行文本 2=只读文本 3=复选框 4=下拉(optionsObject) +// 6=日期 7=时间 8=日期时间 9=多行文本 10=数字 11=树选择(QComboBox 兜底);其余=步进数字。 +// requiredType:1=必填可编辑(标红*) 2=只读禁用 其他=可选可编辑。 +// 编辑态用 properties 预填;核心字段(fieldUseType=1)的测量值只读。 +// 注:本控件只负责"渲染 + 收集 + 必填校验",不发请求;提交载荷接入由上层处理。 +class DynamicFormEditor : public QWidget { + Q_OBJECT +public: + explicit DynamicFormEditor(QWidget* parent = nullptr); + + void setForm(const data::EditableForm& form); // 重建控件 + QMap 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 entries_; + QWidget* body_ = nullptr; // 承载分组与字段的容器(重建时整体替换) +}; + +} // namespace geopro::app diff --git a/src/app/panels/ObjectAttrPanel.cpp b/src/app/panels/ObjectAttrPanel.cpp new file mode 100644 index 0000000..4f38bea --- /dev/null +++ b/src/app/panels/ObjectAttrPanel.cpp @@ -0,0 +1,195 @@ +#include "panels/ObjectAttrPanel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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(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 diff --git a/src/app/panels/ObjectAttrPanel.hpp b/src/app/panels/ObjectAttrPanel.hpp new file mode 100644 index 0000000..ba85c80 --- /dev/null +++ b/src/app/panels/ObjectAttrPanel.hpp @@ -0,0 +1,67 @@ +#pragma once +#include +#include +#include + +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/getDynamicForm(DynamicFormEditor 渲染)。 +// 仅编辑既有对象(无新建/类型下拉);提交体口径与 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/项目根 id(TM 编辑 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/根 id(GS 编辑不用) + 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 formReq_; + QPointer saveReq_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp index 1b20b29..32bc913 100644 --- a/src/app/panels/ObjectTreePanel.cpp +++ b/src/app/panels/ObjectTreePanel.cpp @@ -1,8 +1,15 @@ #include "panels/ObjectTreePanel.hpp" +#include #include +#include +#include +#include +#include #include #include +#include +#include #include #include #include @@ -18,28 +25,32 @@ namespace geopro::app { namespace { constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 id(GS/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 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& nodes, bool topLevel) { for (const auto& n : nodes) { auto* item = new QTreeWidgetItem(parent); 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) { - // 项目根:非交互容器(不设 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 { - item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); - if (n.isTm) { - item->setData(0, kRoleConfType, kConfTypeTm); - 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); - } + item->setData(0, kRoleConfType, kConfTypeGs); // GS + item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); + item->setCheckState(0, Qt::Unchecked); } addNodes(item, n.children, false); // 子层永远非顶层 } @@ -64,10 +75,23 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { hint_->setVisible(false); lay->addWidget(hint_); + // viewport 事件过滤:记录鼠标按下是否落在复选框区,用于区分「选中」与「勾选」手势。 + tree_->viewport()->installEventFilter(this); + 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 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 单发合并成一次「收集勾选叶子并发射」。 QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { @@ -88,6 +112,169 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { 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 节点上不显示「新建检测对象」——xlsx:tm 上新建GS 无效。) + add(QStringLiteral("新建检测对象"), QStringLiteral("newGs")); + add(QStringLiteral("新建方法对象"), QStringLiteral("newTm")); + } + if (isTm) { + // TM 节点:仅「新建方法对象」(同级,父=该 TM 的父 GS/根)+ 导入 DS。 + // (xlsx:tm 上新建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(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 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 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 同归为 1;TM=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 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, diff --git a/src/app/panels/ObjectTreePanel.hpp b/src/app/panels/ObjectTreePanel.hpp index 2fb454d..e94ea59 100644 --- a/src/app/panels/ObjectTreePanel.hpp +++ b/src/app/panels/ObjectTreePanel.hpp @@ -19,16 +19,44 @@ public: void setStructure(const QString& projectName, const std::vector& nodes); void showMessage(const QString& message); // 错误/空状态占位 + // 快速筛选器(按类型批量勾选/反选 TM 叶子;驱动既有 checkedTmsChanged 合并发射)。 + void setAllTmsChecked(bool checked); // 全选 / 全不选 + void invertTmChecks(); // 反选 + + // 面板「新建对象」按钮用:新建对象的父节点 id。 + // 优先当前选中节点 id;未选中时回落到项目根节点 id;树空则空串。 + QString currentParentForNew() const; + + // 编辑 TM 用:取某对象的父 GS/项目根 id(TM 上溯到最近的 GS/根祖先)。 + // GS/根本身无需父 → 返回空串;找不到该对象或无 GS/根祖先 → 空串。 + QString parentObjectId(const QString& objectId) const; + + // 面板「新建对象」按钮按类型决定菜单:当前选中节点的 confType(1=GS/项目根,2=TM,0=未选)。 + // 选 项目根/GS → 可新建 GS + TM;选 TM → 仅新建 TM(同级)。 + int currentSelectedConfType() const; + +protected: + // 区分「选中」与「勾选」手势:监视 viewport 鼠标按下是否落在复选框指示区, + // 落在复选框上则该次 itemClicked 不发 objectClicked(避免勾选顺带重载数据集列表)。 + bool eventFilter(QObject* watched, QEvent* event) override; + signals: // confType: 1=GS 2=TM。单击行(驱动数据列表 + 对象属性)。 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(已合并发射)。 void checkedTmsChanged(const QStringList& tmObjectIds); + // 右键菜单动作(action 取值见 .cpp;objectId/confType/typeId 为右键命中项,name 用于确认框/标题)。 + void contextActionRequested(const QString& action, const QString& objectId, int confType, + const QString& typeId, const QString& name); private: QTreeWidget* tree_ = nullptr; // Qt 原生标准树(复选框/箭头由 Fusion 绘制,清晰可控) QLabel* hint_ = nullptr; - bool checkPending_ = false; // 勾选合并发射防重入 + bool checkPending_ = false; // 勾选合并发射防重入 + bool pressOnCheckbox_ = false; // 最近一次鼠标按下是否落在复选框指示区 }; } // namespace geopro::app diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 9993240..c36bb49 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -35,7 +35,7 @@ WorkbenchNavController::~WorkbenchNavController() { abortAll(); } bool WorkbenchNavController::anyInflight() const { if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ || - moreFilesReq_ || datasetReq_) + moreFilesReq_ || datasetReq_ || mutateReq_) return true; for (const auto& h : checkedInflight_) if (h) return true; @@ -58,6 +58,7 @@ void WorkbenchNavController::abortAll() { if (selDetailReq_) selDetailReq_->abort(); if (moreFilesReq_) moreFilesReq_->abort(); if (datasetReq_) datasetReq_->abort(); + if (mutateReq_) mutateReq_->abort(); for (const auto& h : checkedInflight_) if (h) h->abort(); 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 子孙 id;TM→自身。 +// 复用 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> childrenByParent; + for (const auto& n : lastStructNodes_) childrenByParent[n.parentId].push_back(&n); + std::vector 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 旧批(以最后一次为准)── void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { for (const auto& h : checkedInflight_) // abort-and-replace 旧批 diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index 6869e03..29fe3a3 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -28,6 +28,7 @@ public: void start(); // 启动:拉空间 → 项目 → 结构(依赖链) QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); } + QString currentProjectId() const { return QString::fromStdString(currentProjectId_); } public slots: void switchWorkspace(const QString& tenantId); @@ -37,6 +38,10 @@ public slots: void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单 void loadMoreData(); 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: void busyChanged(bool busy); @@ -52,6 +57,9 @@ signals: void exceptionTreeLoaded(const std::vector& groups, int exceptionCount); void datasetDetailLoaded(const geopro::data::DynamicForm& form); void loadFailed(const QString& stage, const QString& message); + // 增删改结果(用于状态栏/toast 反馈;成功后控制器已自行触发相应刷新)。 + void mutationSucceeded(const QString& message); + void mutationFailed(const QString& message); private: // start / switchWorkspace 依赖链:拉项目 → 拉结构(续延,复用)。 @@ -76,6 +84,7 @@ private: QPointer selDetailReq_; // selectObject:对象详情 QPointer moreFilesReq_; // loadMoreFiles(数据页改客户端按根分页,无在飞句柄) QPointer datasetReq_; + QPointer mutateReq_; // 删除/增改(abort-and-replace 单路) std::vector> checkedInflight_; // setCheckedTms:未命中缓存的并发批 std::vector lastProjects_; diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index 273a977..8b31def 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -1,6 +1,8 @@ #include "api/ApiProjectRepository.hpp" +#include #include +#include #include #include #include @@ -122,4 +124,156 @@ NavRequest* ApiProjectRepository::loadExceptionsByTmAsync(const std::string& tmO }, &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 string,file 为 multipart 上传项(spec §B)。 + auto* call = api_.postMultipartAsync(path, QMap{}, + 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.deltaContent(Quill 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 diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index 28f10c9..72c4218 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -25,6 +25,24 @@ public: NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override; NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) 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: net::ApiClient& api_; diff --git a/src/data/api/NavLoads.hpp b/src/data/api/NavLoads.hpp index e4238d6..c3492cc 100644 --- a/src/data/api/NavLoads.hpp +++ b/src/data/api/NavLoads.hpp @@ -11,4 +11,11 @@ Q_DECLARE_METATYPE(std::vector) Q_DECLARE_METATYPE(geopro::data::DsPage) Q_DECLARE_METATYPE(geopro::data::DynamicForm) Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(geopro::data::EditableForm) +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(std::vector) +Q_DECLARE_METATYPE(std::vector) // bool 已内置 QMetaType。 diff --git a/src/data/api/NavRequest.hpp b/src/data/api/NavRequest.hpp index 173c007..83187e2 100644 --- a/src/data/api/NavRequest.hpp +++ b/src/data/api/NavRequest.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "IApiCall.hpp" @@ -36,4 +37,20 @@ private: 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 diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 6f38f88..47626ef 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -106,6 +106,7 @@ std::vector parseStructNodes(const QJsonArray& arr) { n.parentId = str(o, "parentId"); n.typeName = str(o, "typeName"); n.confCode = str(o, "confCode"); + n.typeId = str(o, "typeId"); n.type = o.value(QStringLiteral("type")).toInt(); out.push_back(std::move(n)); } @@ -212,6 +213,164 @@ DynamicForm parseDynamicForm(const QJsonObject& data) { 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 gv; + gv.reserve(static_cast(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 fv; + fv.reserve(static_cast(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 parseTmMethodTypes(const QJsonArray& arr) { + // tmList 项:{tmTypeId, baseConfId, name, confCode, ...} → label=name, value=tmTypeId, code=confCode。 + std::vector out; + out.reserve(static_cast(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 parseGsTypes(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 parseDsImportTypes(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 parseScripts(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 parseExportTemplates(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 parseModels(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(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 parseExceptions(const QJsonArray& arr) { std::vector out; out.reserve(static_cast(arr.size())); diff --git a/src/data/dto/NavDto.hpp b/src/data/dto/NavDto.hpp index 48649f9..e1fba00 100644 --- a/src/data/dto/NavDto.hpp +++ b/src/data/dto/NavDto.hpp @@ -43,6 +43,29 @@ std::vector buildStructTree(const std::vector& flat) // 表头 name 取 data["name"]。 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 parseTmMethodTypes(const QJsonArray& arr); + +// project/gsList/{projectId} 的 data["value"] 数组 → GS 类型选项 [{name, gsTypeId}]。 +std::vector parseGsTypes(const QJsonArray& arr); + +// TM getDetail 的 dsList 数组 → 可导入数据类型(仅含 canImport=true 的项)。 +std::vector parseDsImportTypes(const QJsonArray& arr); + +// dsObject/query/script 的 data["value"] 数组 → 导入脚本选项。 +std::vector parseScripts(const QJsonArray& arr); + +// 导出模板列表(templateExport/queryDataType 等)的 data["value"] 数组 → 模板选项。 +std::vector parseExportTemplates(const QJsonArray& arr); + +// model/list 的 data["value"] 数组 → 模型/插件 [{id,scriptCode,scriptName,operationType}]。 +std::vector parseModels(const QJsonArray& arr); + // ExceptionVO 数组 → [ExceptionRow]。字段:id、name=exceptionName、typeName=exceptionTypeName、 // createTime;consortium* 取自 consortiumId/consortiumName/consortiumType(来源待 live 验证); // detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。 diff --git a/src/data/repo/IAsyncProjectRepository.hpp b/src/data/repo/IAsyncProjectRepository.hpp index d7d354a..591b239 100644 --- a/src/data/repo/IAsyncProjectRepository.hpp +++ b/src/data/repo/IAsyncProjectRepository.hpp @@ -26,6 +26,43 @@ public: virtual NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) = 0; // DynamicForm virtual NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector + // 删除(confType 1=GS 2=TM;DS 单独)。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 + // 项目可建的 GS 测试对象类型列表(GET /business/project/gsList/{projectId})。 + virtual NavRequest* queryGsTypesAsync(const std::string& projectId) = 0; // std::vector + // 某 TM 可导入的数据类型(来源 TM getDetail.dsList,仅 canImport=true)。 + virtual NavRequest* loadTmImportTypesAsync(const std::string& tmObjectId) = 0; // std::vector + // 某数据类型可用的导入脚本(GET /business/dsObject/query/script)。 + virtual NavRequest* queryImportScriptsAsync(const std::string& dsTypeId, + const std::string& tmTypeBaseConfId) = 0; // std::vector + // 导入前坐标/轨迹文件校验(POST /business/dsObject/checkImport)。bodyJson=校验请求体。payload=bool。 + virtual NavRequest* checkImportAsync(const std::string& bodyJson) = 0; // bool + // 导入数据集(POST /business/dsObject/import,multipart:参数走 query string,file 上传)。 + // 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 + // 模板导出(POST /business/templateExport/export)。bodyJson=导出请求体。payload=bool。 + virtual NavRequest* exportDatasetAsync(const std::string& bodyJson) = 0; // bool + // 提交对象(confType 1=GS 2=TM):isCreate=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 + // 数据集详情(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 diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index 47261f5..d58a70b 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -34,13 +34,54 @@ struct ProjectType { std::string id, name; }; struct ProjectListPage { std::vector rows; int total = 0; }; // 项目结构扁平节点(仅 GS / TM)。客户端按 parentId 建树,叶子=TM。 -struct StructNode { std::string id, name, parentId, typeName, confCode; int type = 0; }; +// typeId:对象的类型 id(编辑时调 getDynamicForm 必需)。 +struct StructNode { std::string id, name, parentId, typeName, confCode, typeId; int type = 0; }; // 动态表单(GS/TM/DS 详情统一模型)。值已与字段定义合并、已按 sort 排好序。 struct DynamicFormField { std::string name, value; }; struct DynamicFormGroup { std::string name; std::vector fields; }; struct DynamicForm { std::string name; std::vector 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 options; // comp4 下拉项(可空:远程设备列表等) +}; +struct EditFieldGroup { std::string name; int sort = 0; std::vector fields; }; +struct EditableForm { + std::string typeId, name; // 类型 id / 类型名(弹窗标题用) + int confType = 0; // 1=GS 2=TM + std::vector 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/script,type=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 = 详情展开内联显示。 struct ExceptionRow { std::string id, name, typeName, createTime; diff --git a/src/net/ApiClient.cpp b/src/net/ApiClient.cpp index 8945278..f8a94df 100644 --- a/src/net/ApiClient.cpp +++ b/src/net/ApiClient.cpp @@ -2,6 +2,9 @@ #include "ApiCall.hpp" +#include +#include +#include #include #include #include @@ -53,4 +56,55 @@ IApiCall* ApiClient::postJsonAsync(const QString& path, const QJsonObject& body) 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& 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 diff --git a/src/net/ApiClient.hpp b/src/net/ApiClient.hpp index 64091cb..02d13ff 100644 --- a/src/net/ApiClient.hpp +++ b/src/net/ApiClient.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -36,9 +37,17 @@ public: // 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。 void setToken(const QString& token); - // 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。 + // 异步 GET / POST / PUT(JSON) / DELETE:立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。 IApiCall* getAsync(const QString& path); 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→value);fileFieldName/filePath = 单个文件项。 + // 文件读取失败(路径不存在/不可读)返回 nullptr,调用方据此报错。 + IApiCall* postMultipartAsync(const QString& path, const QMap& formFields, + const QString& fileFieldName, const QString& filePath); private: struct Impl; diff --git a/tests/controller/test_workbench_nav_controller.cpp b/tests/controller/test_workbench_nav_controller.cpp index a74ba2f..ac9b99b 100644 --- a/tests/controller/test_workbench_nav_controller.cpp +++ b/tests/controller/test_workbench_nav_controller.cpp @@ -61,6 +61,45 @@ struct StubAsyncRepo : data::IAsyncProjectRepository { exceptions.push_back(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() { diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index b42f9cf..4d37552 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -68,10 +68,10 @@ TEST(NavDto, ParseStructNodesMapsParentAndType) { TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) { const std::vector flat = { - {"gs1", "工区1", "", "GS", "", 1}, - {"tm1", "测线1", "gs1", "TM", "", 2}, - {"tm2", "测线2", "gs1", "TM", "", 2}, - {"tmD", "直挂测线", "", "TM", "", 2}, // TM 直挂项目(无 GS) + {"gs1", "工区1", "", "GS", "", "", 1}, + {"tm1", "测线1", "gs1", "TM", "", "", 2}, + {"tm2", "测线2", "gs1", "TM", "", "", 2}, + {"tmD", "直挂测线", "", "TM", "", "", 2}, // TM 直挂项目(无 GS) }; const auto roots = dto::buildStructTree(flat); ASSERT_EQ(roots.size(), 2u); // gs1 + tmD @@ -86,7 +86,7 @@ TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) { TEST(NavDto, BuildStructTreeOrphanParentBecomesRoot) { const std::vector flat = { - {"tmX", "孤儿测线", "ghost", "TM", "", 2}, // parentId 不在集合内 + {"tmX", "孤儿测线", "ghost", "TM", "", "", 2}, // parentId 不在集合内 }; const auto roots = dto::buildStructTree(flat); ASSERT_EQ(roots.size(), 1u); @@ -101,10 +101,10 @@ TEST(NavDto, BuildStructTreeEmpty) { TEST(NavDto, BuildStructTreeHandlesCycleWithoutInfiniteRecursion) { // 不可信数据:重复 id 形成可达环(R→X→Y→重复X…)。必须终止、不崩。 const std::vector flat = { - {"R", "根", "", "GS", "", 1}, - {"X", "x", "R", "GS", "", 1}, - {"Y", "y", "X", "GS", "", 1}, - {"X", "x2", "Y", "TM", "", 2}, // 重复 id X,父=Y → 若不防环将无限递归 + {"R", "根", "", "GS", "", "", 1}, + {"X", "x", "R", "GS", "", "", 1}, + {"Y", "y", "X", "GS", "", "", 1}, + {"X", "x2", "Y", "TM", "", "", 2}, // 重复 id X,父=Y → 若不防环将无限递归 }; const auto roots = dto::buildStructTree(flat); // 不挂起即通过 ASSERT_EQ(roots.size(), 1u); @@ -131,11 +131,12 @@ TEST(NavDto, ParseProjectListArrayMapsItem) { TEST(NavDto, BuildStructTreeDropsDsAndTmStaysLeaf) { // 真实形态:项目(1) → TM(2) → DS(3)。DS 不进树;带 DS 子节点的 TM 仍是 TM 叶子。 + // 字段序:id,name,parentId,typeName,confCode,typeId,type(typeId 为新增,测试填空串)。 const std::vector flat = { - {"P", "项目", "0", "PRJ", "", 1}, - {"T1", "ERT1", "P", "ERT", "ERT", 2}, - {"D1", "批次1","T1", "", "", 3}, // DS:应被过滤 - {"T2", "ERT2", "P", "ERT", "ERT", 2}, + {"P", "项目", "0", "PRJ", "", "", 1}, + {"T1", "ERT1", "P", "ERT", "ERT", "", 2}, + {"D1", "批次1","T1", "", "", "", 3}, // DS:应被过滤 + {"T2", "ERT2", "P", "ERT", "ERT", "", 2}, }; const auto roots = dto::buildStructTree(flat); ASSERT_EQ(roots.size(), 1u); // 仅项目根(parentId "0") @@ -308,3 +309,69 @@ TEST(NavDto, GroupExceptionsAllLooseWhenNoConsortium) { EXPECT_TRUE(g.consortia.empty()); 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"); + // topography:comp8 只读、有 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); +}