From 12813bd8d051404d306112c7379bfed7a2ee6b63 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 23 Jun 2026 09:21:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(detail):=20=E6=95=B0=E6=8D=AE=E9=9B=86?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E8=A7=86=E5=9B=BE=E4=BA=A4=E4=BA=92=E5=A4=8D?= =?UTF-8?q?=E5=88=BB(measurement/inversion/grid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照原版 web 1:1 复刻数据集详情视图的写操作交互,补齐既有视图的全部 可交互能力。 基础设施 - 新增写操作命令仓储 IDatasetCommandRepository + ApiDatasetCommandRepository (26 个写/查接口,端点逐字对照原版 apis),回调式异步沿用 ApiColorTemplateRepository 模式 - 写操作注入链平行 setColorTemplateRepo:main→Panel→Page→DetailViewFactory→视图, 透传 cmdRepo + dsIdGetter - 新增共享对话框 InversionFormDialog/SaveAsDialog/ScatterFilterDialog/GridWizardDialog/ WhiteningDialog/FilterDialog/ExceptionDialog/ExceptionDetailDialog/AutoAnnotationDialog - 纯函数 InversionFormParse/ScatterDataOps/InversionProcessOps/ContourSimplify + 单测 measurement(M1-M13):可见性持久化、数据过滤、X/Y/V轴、值类型、色阶配置、 生成视电阻率、反演运算、另存为、导出DAT、信息点选 inversion 网格(I1-I15):网格化向导、白化、滤波、等值线提示、简化容差(真生效)、 异常增删改查+定位、自动标注、描述保存、另存为 inversion 原数据(O1-O3) + grid 反演(G1,functionList 驱动) 后置/降级(台账 §6.4):M14框选、M2行级可见性、M3过滤直方图、I9图上绘形、 I14富文本(Qt无Quill)、I3白化tmObjectId透传 测试 285/285 通过 --- .../2026-06-22-2d-dataset-vtk-view-design.md | 22 +- ...taset-detail-interaction-replica-ledger.md | 168 +++++++ src/app/CMakeLists.txt | 14 + src/app/main.cpp | 9 +- src/app/panels/AnomalyTablePanel.cpp | 43 +- src/app/panels/AnomalyTablePanel.hpp | 13 +- src/app/panels/DatasetDetailPage.cpp | 11 +- src/app/panels/DatasetDetailPage.hpp | 8 + src/app/panels/DatasetDetailPanel.cpp | 5 + src/app/panels/DatasetDetailPanel.hpp | 7 + src/app/panels/DescriptionPanel.cpp | 28 +- src/app/panels/DescriptionPanel.hpp | 11 +- src/app/panels/chart/AutoAnnotationDialog.cpp | 255 ++++++++++ src/app/panels/chart/AutoAnnotationDialog.hpp | 57 +++ src/app/panels/chart/ContourHoverTip.cpp | 60 +++ src/app/panels/chart/ContourHoverTip.hpp | 34 ++ src/app/panels/chart/ContourPlotItem.cpp | 75 ++- src/app/panels/chart/ContourPlotItem.hpp | 17 +- src/app/panels/chart/ContourSimplify.cpp | 51 ++ src/app/panels/chart/ContourSimplify.hpp | 14 + src/app/panels/chart/DataTableView.cpp | 63 +++ src/app/panels/chart/DataTableView.hpp | 27 + src/app/panels/chart/DetailViewFactory.cpp | 26 +- src/app/panels/chart/DetailViewFactory.hpp | 6 +- .../panels/chart/ExceptionDetailDialog.cpp | 140 ++++++ .../panels/chart/ExceptionDetailDialog.hpp | 44 ++ src/app/panels/chart/ExceptionDialog.cpp | 214 ++++++++ src/app/panels/chart/ExceptionDialog.hpp | 51 ++ src/app/panels/chart/FilterDialog.cpp | 342 +++++++++++++ src/app/panels/chart/FilterDialog.hpp | 64 +++ src/app/panels/chart/GridDataChartView.cpp | 241 ++++++++- src/app/panels/chart/GridDataChartView.hpp | 30 ++ src/app/panels/chart/GridWizardDialog.cpp | 217 ++++++++ src/app/panels/chart/GridWizardDialog.hpp | 60 +++ src/app/panels/chart/InversionFormDialog.cpp | 200 ++++++++ src/app/panels/chart/InversionFormDialog.hpp | 63 +++ src/app/panels/chart/InversionFormParse.cpp | 52 ++ src/app/panels/chart/InversionFormParse.hpp | 35 ++ src/app/panels/chart/InversionProcessOps.cpp | 98 ++++ src/app/panels/chart/InversionProcessOps.hpp | 77 +++ src/app/panels/chart/RawDataChartView.cpp | 464 +++++++++++++++++- src/app/panels/chart/RawDataChartView.hpp | 53 ++ src/app/panels/chart/SaveAsDialog.cpp | 110 +++++ src/app/panels/chart/SaveAsDialog.hpp | 50 ++ src/app/panels/chart/ScatterDataOps.cpp | 68 +++ src/app/panels/chart/ScatterDataOps.hpp | 43 ++ src/app/panels/chart/ScatterFilterDialog.cpp | 113 +++++ src/app/panels/chart/ScatterFilterDialog.hpp | 40 ++ src/app/panels/chart/WhiteningDialog.cpp | 173 +++++++ src/app/panels/chart/WhiteningDialog.hpp | 51 ++ src/core/model/Anomaly.hpp | 1 + src/core/model/Field.hpp | 9 + src/core/model/detail/DetailPayloads.hpp | 11 + src/data/CMakeLists.txt | 1 + src/data/api/ApiDatasetCommandRepository.cpp | 425 ++++++++++++++++ src/data/api/ApiDatasetCommandRepository.hpp | 112 +++++ src/data/dto/DatasetChartDto.cpp | 4 + src/data/dto/GridDto.cpp | 13 + src/data/dto/GridDto.hpp | 1 + src/data/dto/MeasurementDto.cpp | 19 +- src/data/repo/IDatasetCommandRepository.hpp | 217 ++++++++ tests/CMakeLists.txt | 20 + tests/app/test_contour_simplify.cpp | 42 ++ tests/app/test_inversion_form_parse.cpp | 87 ++++ tests/app/test_inversion_process_ops.cpp | 130 +++++ tests/app/test_scatter_data_ops.cpp | 87 ++++ tests/data/test_dataset_chart_dto.cpp | 15 + tests/data/test_grid_dto.cpp | 31 ++ tests/data/test_measurement_dto.cpp | 20 + 69 files changed, 5287 insertions(+), 75 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-22-dataset-detail-interaction-replica-ledger.md create mode 100644 src/app/panels/chart/AutoAnnotationDialog.cpp create mode 100644 src/app/panels/chart/AutoAnnotationDialog.hpp create mode 100644 src/app/panels/chart/ContourHoverTip.cpp create mode 100644 src/app/panels/chart/ContourHoverTip.hpp create mode 100644 src/app/panels/chart/ContourSimplify.cpp create mode 100644 src/app/panels/chart/ContourSimplify.hpp create mode 100644 src/app/panels/chart/ExceptionDetailDialog.cpp create mode 100644 src/app/panels/chart/ExceptionDetailDialog.hpp create mode 100644 src/app/panels/chart/ExceptionDialog.cpp create mode 100644 src/app/panels/chart/ExceptionDialog.hpp create mode 100644 src/app/panels/chart/FilterDialog.cpp create mode 100644 src/app/panels/chart/FilterDialog.hpp create mode 100644 src/app/panels/chart/GridWizardDialog.cpp create mode 100644 src/app/panels/chart/GridWizardDialog.hpp create mode 100644 src/app/panels/chart/InversionFormDialog.cpp create mode 100644 src/app/panels/chart/InversionFormDialog.hpp create mode 100644 src/app/panels/chart/InversionFormParse.cpp create mode 100644 src/app/panels/chart/InversionFormParse.hpp create mode 100644 src/app/panels/chart/InversionProcessOps.cpp create mode 100644 src/app/panels/chart/InversionProcessOps.hpp create mode 100644 src/app/panels/chart/SaveAsDialog.cpp create mode 100644 src/app/panels/chart/SaveAsDialog.hpp create mode 100644 src/app/panels/chart/ScatterDataOps.cpp create mode 100644 src/app/panels/chart/ScatterDataOps.hpp create mode 100644 src/app/panels/chart/ScatterFilterDialog.cpp create mode 100644 src/app/panels/chart/ScatterFilterDialog.hpp create mode 100644 src/app/panels/chart/WhiteningDialog.cpp create mode 100644 src/app/panels/chart/WhiteningDialog.hpp create mode 100644 src/data/api/ApiDatasetCommandRepository.cpp create mode 100644 src/data/api/ApiDatasetCommandRepository.hpp create mode 100644 src/data/repo/IDatasetCommandRepository.hpp create mode 100644 tests/app/test_contour_simplify.cpp create mode 100644 tests/app/test_inversion_form_parse.cpp create mode 100644 tests/app/test_inversion_process_ops.cpp create mode 100644 tests/app/test_scatter_data_ops.cpp diff --git a/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md b/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md index 22926e1..455410a 100644 --- a/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md +++ b/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md @@ -42,16 +42,20 @@ | ddCode | 详情组件 / 含义 | 空间形态 | |---|---|---| | dd_trajectory_data | ElectrodeCom 电极/测线 | **线**(lat/lon 序列)| -| dd_transient_electromagnetic_trajectory_data | BatteryComMapLineCom 瞬变电磁测线 | **线** | -| dd_radar_channel_trajectory / dd_radar_rtk_trajectory | 雷达通道/RTK 轨迹 | **线** | -| dd_inversion_data | ContourCom 等值面 | **竖直剖面**(沿测线距离 × 深度)| -| dd_ert_measurement_data | ScattersCom 散点 | **拟剖面**(电极对 × 视深,散点)| -| dd_ert_measurement_gr_data | GroundResistanceCom 接地电阻 | 沿线序列 | -| dd_gpr_channel_image / dd_radar / dd_gpr_channel_detail | 雷达图像/成果 | **竖直 B-scan**(沿轨迹 × 时深)| -| dd_grid | DdGridCom 网格 | 网格(**面 or 剖面,依数据而定,存疑**)| -| dd_voxel / dd_Structual3D / dd_Property3D | 体素 / 结构 / 属性 3D | **三维体** | -| dd_3d_show | ThreeModelCom 3D 模型 | **三维模型** | +| //dd_transient_electromagnetic_trajectory_data | BatteryComMapLineCom 瞬变电磁测线 | **线** | +| //dd_radar_channel_trajectory / dd_radar_rtk_trajectory | 雷达通道/RTK 轨迹 | **线** | +| dd_inversion_data | ContourCom 等值面 | **剖面** | +| dd_ert_measurement_data | ScattersCom 散点 | **原始数据**(电极对 × 视深,散点) | +| //dd_ert_measurement_gr_data | GroundResistanceCom 接地电阻 | 沿线序列 | +| //dd_gpr_channel_image / dd_radar / //dd_gpr_channel_detail | 雷达图像/成果,//重新分析 | **竖直 B-scan**(沿轨迹 × 时深)| +| dd_grid | DdGridCom 网格,//扩展支持接地电阻 | 网格(**面 or 剖面,依数据而定,存疑**)| +| dd_voxel / (dd_Structual3D) / //dd_Property3D | 体素 / (结构) / //属性 3D | **三维体** | +| //dd_3d_show | ThreeModelCom 3D 模型 | **三维模型** | | dd_slice | 切片 | 三维分析 | +| ??dd_time_sensor | TimingSensor 时序传感器(折线图+数据表)| 时序曲线(非空间)**待确认** | +| ??dd_current_method_indicator | DdElectricDetectionCom 电流法指标(指标表+散点状态图)| 指标/状态(非空间)**待确认** | + +> `??` 标记:原版 datasetInfo 有此详情组件,但初版台账漏列,详情视图是否纳入客户端范围**待确认**(2026-06-22 补)。雷达/GPR 家族(`//` 标记)因客户端将重构、客户需求未定,整体搁置。 --- diff --git a/docs/superpowers/specs/2026-06-22-dataset-detail-interaction-replica-ledger.md b/docs/superpowers/specs/2026-06-22-dataset-detail-interaction-replica-ledger.md new file mode 100644 index 0000000..24cddfe --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-dataset-detail-interaction-replica-ledger.md @@ -0,0 +1,168 @@ +# 数据集详情视图 · 交互 100% 复刻台账 + +> 目标:详情视图(datasetInfo)逐交互 1:1 复刻原版 web(`D:\Git\lanbingtech\commercial-admin`)。 +> 原则:对照原版源码找全差距;凡已实现的视图,其后端接口均已具备(客户端只需新增对应调用)。 +> 范围:仅**已实现的 5 个 ddCode 视图**。雷达/GPR/电磁/3D 模型家族(客户端将重构、需求未定)整体搁置。 +> 日期:2026-06-22 立账。 + +## 0. 总览结论 + +| 视图 (ddCode) | 客户端类 | 复刻完成度 | 差距集中点 | +|---|---|---|---| +| 电极轨迹 dd_trajectory_data | TrajectoryStrategy / TrajectoryMapView 等 | ✅ **无差距** | 地图/列表/高程齐全;原版「导出」按钮原版自身亦未实现 | +| 接地电阻 dd_ert_measurement_gr_data | GrMeasurementStrategy / BarChartView | ✅ **无差距** | 柱状图/列表齐全;原版地面信息/模型/脚本/导出按钮原版自身亦未实现 | +| ERT 原始数据 dd_ert_measurement_data | MeasurementStrategy / RawDataChartView | ✅ **基本接通** | 工具条 1:1,写操作全接;仅 M14 框选后置(重型,已登记) | +| 反演等值面 dd_inversion_data | ErtInversionStrategy / RawDataChartView(原数据) + GridDataChartView(网格) | ✅ **基本接通** | 网格/白化/滤波/异常CRUD/自动标注/另存为/色阶均接;仅 I9 图上绘制、I14 富文本、I3 tmObjectId 透传后置 | +| 网格白化 dd_grid | GridStrategy / DataTableView | ✅ **已通** | 分页列表 + 「反演」功能按钮(载荷 functionList 驱动,复用 InversionFormDialog) | + +**架构事实**:客户端 `ApiDatasetRepository` 当前只有 load(读)操作,无写操作。所有反演/保存/过滤/白化/滤波/异常写接口需新增客户端调用,沿用 `ApiClient(get/postJsonAsync) → ApiBatch → ApiDetailLoad` 模式(或为写操作引入独立的 command 调用路径)。 + +> ⚠️ 实现前务必直接读原版 `src/apis/datasetInfo/index.js` 与对应 `.vue` 复核请求体字段名(本台账 API 字段来自探查 agent 摘录,端点/方法可信,个别字段名以源码为准)。 + +--- + +## 1. 共享基础设施(先建,多视图复用) + +这些组件被多个交互复用,应优先抽象,避免各视图各写一份。 + +### 1.1 反演动态表单对话框(InversionForm) +- **复用于**:measurement「反演运算」「生成视电阻率」、grid「反演」。 +- **流程**:① 查模型列表 → ② 选模型 → ③ 按 typeId 拉动态表单字段 → ④ 填参 → ⑤ 提交。 +- **API**: + - 模型列表 `GET /business/outerInversion/query/script?dsObjectId=`(measurement/grid 通用) + - 动态表单 `POST /business/project/getDynamicForm` body `{projectId, type:6, typeId}` + - 提交反演 `POST /business/outerInversion/submitInversionTask` body `{dsId, properties, ...}` + - 生成视电阻率 `POST /business/dd/ert/measurement/createVisualResistivityData` body `{dsObjectId, scriptId, scriptParamListJsonStr}` +- **客户端现状**:无。需建 `InversionFormDialog`(动态字段渲染:分组卡片 + Select 为主)。 + +### 1.2 色阶配置编辑器(已存在,复用) +- **已有**:`ColorScaleConfigDialog`(被 GridDataChartView 使用,且与三维体右键色阶共用)。 +- **复用目标**:measurement 散点「色阶配置」、inversion 原数据散点「色阶配置」。 +- **API**:查 `POST /business/lvl/colorGradation/getDetail`,存 `POST /business/lvl/colorGradation`。 +- **注意**:measurement 走 `businessCode=R0, type=3`;inversion 原数据 `type=1`、网格 `type=2`。散点上色用 `colorSvc_`,编辑后需重建并重绘散点。 + +### 1.3 另存为对话框(SaveAs) +- **复用于**:measurement 另存为、inversion 另存为。 +- **measurement**:`POST /business/dd/ert/measurement/saveRawData` `{dsId, operationType(1新增/0覆盖), name?}`(含新增/覆盖单选) +- **inversion**:`POST /business/dd/ert/inversion/saveAsData` `{dsObjectId, name}`(仅名称) + +--- + +## 2. ERT 原始数据(measurement / RawDataChartView) + +客户端 measurement 工具条已 1:1 建出(`buildMeasurementToolbar`)。逐控件差距: + +| # | 控件 | 原版行为 | 原版 API | 客户端现状 | 实现要点 | +|---|---|---|---|---|---| +| M1 | 显示/隐藏 | popconfirm→改全部点可见性(持久化) | `POST /business/dd/ert/measurement/saveDisplayStatus` `{dsObjectId, ids[], status}` | ✅ **已通**(onShowHide:确认→调接口→本地切换) | — | +| M2 | 表格行可见性 switch | 行级 popconfirm→改单点 | 同 M1(ids=[record.id],status 取反) | 🔸 **后置**(DataTableView 行级开关列交互重,源 saveDisplayStatus 已具备) | 见 §6 | +| M3 | 数据过滤 | 弹窗(直方图+min/max)→生成过滤后数据集 | 查 `GET /business/scatterPlotDataFilter/getDataFilterConfig?dsObjectId&vFieldCode`;应用 `POST /business/scatterPlotDataFilter` `{sourceDsObjectId, sourceVFieldCode, min, max}` | ✅ **已通**(ScatterFilterDialog:范围 min/max + 应用);直方图绘制后置 | 直方图见 §6 | +| M4 | X 轴下拉(平距/斜距) | 本地换列重绘 | 无 | ✅ 已通(replotForAxis) | — | +| M5 | Y 轴下拉(伪深度/+高程/层数) | 本地换列重绘 | 无 | ✅ 已通 | 层数为 no-op(原版亦无数据) | +| M6 | V 值下拉 | 重新请求散点+色阶 | `GET .../scatter/graph?vFieldCode=` + `POST .../getDetail{businessCode=新V}` | ✅ **已通**(reloadForVValue 带 vFieldCode 重载) | — | +| M7 | 值类型下拉(线性/倒数/对数) | 本地换显示 | 无 | ✅ **已通**(applyValueType 本地变换重上色) | — | +| M8 | 色阶配置 | 弹窗编辑+保存 | getDetail/colorGradation(见 1.2) | ✅ **已通**(复用 ColorScaleConfigDialog;properties 含 colorBar+lineConfig+labelConfig) | — | +| M9 | 生成视电阻率 | 反演弹窗(模型锁定视电阻率脚本) | createVisualResistivityData(见 1.1) | ✅ **已通**(InversionFormDialog::ApparentResistivity:下拉 disabled+锁 `script_visual_resistivity_data`,对齐原版) | — | +| M10 | 反演运算 | 反演弹窗 | submitInversionTask(见 1.1) | ✅ **已通**(InversionFormDialog::Inversion) | — | +| M11 | 另存为 | 新增/覆盖弹窗 | saveRawData(见 1.3) | ✅ **已通**(SaveAsDialog::RawData) | — | +| M12 | 导出 DAT | 下载 base64 | `GET /business/dd/ert/measurement/rs2d/export?dsId&electrodePosition=2&ipDataMark=0&typeMeasurement=0` | ✅ **已通**(exportDat,参数对齐原版) | — | +| M13 | [i] 信息 | 点选看 A/B/M/N/Pseu/Row | 无(本地) | ✅ **已通**(toggleInfoMode:信息模式点选散点看属性) | — | +| M14 | 框选/点选模式 | enter/exitSelectMode | 无(本地) | 🔸 **后置**(Qwt 橡皮筋框选+选区联动隐藏成本高,保留占位提示) | 见 §6 | + +--- + +## 3. 反演等值面(inversion) + +### 3.1 网格视图(GridDataChartView) + +| # | 控件 | 原版行为 | 原版 API | 客户端现状 | 实现要点 | +|---|---|---|---|---|---| +| I1 | 网格(化) | 2步向导(选算法+参数)→网格化 | `GET .../queryAlgorithmModel/{ds}`;`GET .../getRawData/{ds}`;`POST /business/dd/ert/inversion/grid`(actionCode,x/y min/max,**xsize/ysize=点数**,xSpacing/ySpacing=间距,vmin/vmax,saveDataValueType) | ✅ **已通**(GridWizardDialog 2步;xsize=点数 xPoints/ySize=yPoints,间距走 xSpacing/ySpacing,对齐原版 toGridTheData) | — | +| I2 | 色阶配置 | 弹窗 | getDetail/colorGradation | ✅ **已通**(本地生效;网格视图色阶不持久化到后端,与原版网格路径一致) | — | +| I3 | 白化 | 弹窗(3种方式) | `POST /business/dd/ert/inversion/whitenedData`;文件列表 `POST /business/dsObject/queryWhitenedDataList` | ✅ **已通**(白化弹窗+提交);`tmObjectId` 暂兜底空串 | tmObjectId 透传见 §6 | +| I4 | 滤波处理 | 弹窗(滤波器树+矩阵) | 列表 `GET /business/filter/queryFilter`;增 `POST /business/filter`;删 `DELETE /business/filter/delete/{id}`;应用 `POST /business/dd/ert/inversion/filterData` | ✅ **已通**(滤波弹窗含滤波器 CRUD+应用) | — | +| I5 | 显示异常 | 本地显隐 | 无 | ✅ 已通 | — | +| I6 | 显示等值线标注 | 本地显隐 | 无 | ✅ 已通 | — | +| I7 | 显示等值线提示 tooltip | 本地显隐 | 无 | ✅ **已通**(chkContourTip 接 hover tooltip 显隐) | — | +| I8 | 简化容差滑块 | 防抖本地重算等值线(0~2,步0.1) | 无 | ✅ **已通**(防抖 applySimplify→setSimplifyTolerance 真生效) | — | +| I9 | 异常 创建 | 弹窗(类型/名称/备注)+图上绘形 | 类型 `GET .../queryExceptionTypeByProjectIdAndType/{pid}/{type}`;名建议 `POST /business/exception/getExceptionName`;新增 `POST /business/exception` | ✅ **表单已通**(ExceptionDialog:类型/名建议/备注+提交);🔸 **图上绘形后置** | 图上绘制见 §6 | +| I10 | 异常 删除 | 表格行删 | `DELETE /business/exception/{id}` | ✅ **已通**(AnomalyTablePanel deleteRequested) | — | +| I11 | 异常 详情/编辑 | 抽屉(名称/备注/样式) | `PUT /business/exception` | ✅ **已通**(ExceptionDetailDialog;只发 `{id, exceptionName, remark}`,对齐原版局部更新) | — | +| I12 | 异常 定位 | 本地高亮+缩放(防抖) | 无 | ✅ **已通**(AnomalyTablePanel locateRequested→图上定位) | — | +| I13 | 自动标注 | 弹窗(规则+预览) | 预演 `POST /business/exception/exception-mark/execute`;批量存 `POST /business/exception/batch/create` | ✅ **已通**(openAutoAnnotation 自动标注弹窗) | — | +| I14 | 富文本描述保存 | Quill→保存 | 存 `PUT /business/dsObject/updateDsObject/`;取 `GET /business/dsObject/getDetail/{ds}` | ✅ **保存链路已通**(DescriptionPanel saveRequested→saveDescription,取/存均通);🔸 **富文本降级为纯文本** | Quill 富文本见 §6 | +| I15 | 另存为 | 弹窗(名称) | `POST /business/dd/ert/inversion/saveAsData` | ✅ **已通**(SaveAsDialog) | — | + +### 3.2 原数据散点视图(RawDataChartView 默认工具条) + +| # | 控件 | 原版行为 | 客户端现状 | 实现要点 | +|---|---|---|---|---| +| O1 | 网格 | 同 I1 网格化向导 | ✅ **已通**(复用 GridWizardDialog) | — | +| O2 | 色阶配置 | 散点色阶(type1,businessCode 空) | ✅ **已通**(openInversionColorScale;properties 含 colorBar+lineConfig+labelConfig) | — | +| O3 | 另存为 | 同 I15 | ✅ **已通**(SaveAsDialog::Inversion) | — | +| O4 | 图形格式下拉 | 散点↔2D直方图等值线(原版 disabled) | ✅ 不做(原版自身禁用) | — | + +--- + +## 4. 网格白化数据(grid / DataTableView) + +| # | 控件 | 原版行为 | 原版 API | 客户端现状 | 实现要点 | +|---|---|---|---|---|---| +| G1 | 反演 | 反演弹窗 | submitInversionTask(见 1.1) | ✅ 已接 | DataTableView 顶部功能按钮行(载荷 functionList 驱动,仅 dd_grid 非空),点 inversion → 复用 InversionFormDialog(Mode::Inversion) | +| G2 | 分页 | 服务端分页 | grid/rows pageNo/pageSize | ✅ 已通 | — | + +--- + +## 5. 建议执行顺序(分阶段) + +1. **阶段 A · 共享基础设施** + - InversionFormDialog(模型列表+动态表单+提交)→ 一次解锁 M9/M10/G1。 + - SaveAs 弹窗(measurement + inversion 两形态)。 + - 色阶配置接入散点(复用 ColorScaleConfigDialog)→ M8/O2。 +2. **阶段 B · measurement 主交互** + - M1/M2 可见性持久化、M11 另存为、M12 导出、M6 V值重载、M7 值类型、M3 数据过滤。 + - M13/M14 信息/框选(交互重,最后)。 +3. **阶段 C · inversion 写操作** + - I1/O1 网格化向导、I15/O3 另存为。 + - I9~I13 异常 CRUD + 自动标注。 + - I3 白化、I4 滤波。 + - I7 tooltip、I8 简化容差真生效、I14 描述保存复核。 +4. **阶段 D · grid** + - G1 反演按钮(阶段 A 完成后顺带)。 + +> 每个交互按 TDD:先对 repository 写方法/解析写测试(mock ApiClient),再接 UI;UI 视觉对照原版。 + +--- + +## 6. 复刻收尾状态(2026-06-22) + +三份审查后的修正项已落地,build 通过 + 测试全过(285/285)。 + +### 6.1 已 100% 接通项 + +- **measurement(M1~M13)**:显隐持久化、数据过滤(范围)、X/Y/V/值类型下拉、色阶配置、生成视电阻率(下拉锁 `script_visual_resistivity_data`)、反演运算、另存为、导出 DAT、[i] 信息点选——全部接通并对齐原版。 +- **inversion 网格(I1~I8、I10~I13、I15)**:网格化向导(xsize/ysize=点数,对齐 toGridTheData)、色阶、白化、滤波(含滤波器 CRUD)、显示异常/标注/tooltip、简化容差真生效、异常删除/详情编辑/定位、自动标注、另存为——全部接通。 +- **inversion 原数据散点(O1~O3)**:网格化、色阶(type1)、另存为——全部接通;O4 原版自身禁用,不做。 +- **grid 白化(G1/G2)**:反演按钮(functionList 驱动)、分页——已通。 + +### 6.2 审查修正项(本轮) + +- **重复 connect(M13/M14)**:删除 `btnInfo`/`btnMarquee` 残留的 `clicked→showNotImplemented`,消除信息按钮多弹「暂未实现」、框选按钮单击弹两次;btnInfo 保留 checkable+toggled(信息模式),btnMarquee 保留单条占位提示。 +- **异常详情更新字段(I11)**:原版 `contourPage.vue onOk` 走 `PUT /business/exception` 局部更新,**仅发 `{id, exceptionName, remark}`**(线样式是另一条独立 PUT,且抽屉样式控件 disabled)。客户端 `ExceptionDetailDialog::onConfirm` 已对齐:不再附带/覆盖 `legend`。 +- **色阶 properties 补齐(M8/O2)**:原版散点路径 `newLvlColorLevel` 的 `properties` 含 `colorBar` + `lineConfig{showLines,color,lineType}` + `labelConfig{showLabels,color}`(battery/scatters 仅这三块,不含等值面专属的 lvlSchemeType/logLinesCount/equalAreaLayerCount)。客户端两处散点保存已补齐这三块(新增 `buildColorScaleProperties` 复用 `dlg.lineConfig()`)。`templateId` 原版非必需,客户端按原版处理。 + +### 6.3 self-check 结论(无需改) + +- **#4 视电阻率模型锁定**:`InversionFormDialog::ApparentResistivity` 已 `modelCombo_->setEnabled(false)` 且锁定 `code==script_visual_resistivity_data` 的项——与原版 `InversionDialog.vue`(静态 `disabled` + 锁脚本)一致。 +- **#5 网格 xsize/ysize 绑点数**:`GridWizardDialog` 的 `xSize_/ySize_` 是「X/Y点数」(1~300,默认 100),`buildGridToBody` 映射 `xsize←xSize`、间距走独立 `xSpacing←xSpacing_`——与原版 `GridDialog.vue toGridTheData`(`xsize:xPoints`、`xSpacing:xInterval`)一致。 + +### 6.4 明确后置 / 降级项(本次不实现,重型或 Qt 受限) + +| 项 | 原因 | 后续所需 | +|---|---|---| +| **M14 框选/点选模式** | Qwt 橡皮筋框选 + 选区联动隐藏成本高,原版 enter/exitSelectMode 交互重 | 接入 QwtPlotPicker(RubberBand 矩形)+ 选区命中→批量 saveDisplayStatus;保留占位提示 | +| **M2 行级可见性 switch** | DataTableView 需新增可选开关列 + 行级 popconfirm 交互 | 给 measurement 列表加 optional 开关列,复用 saveDisplayStatus(ids=[record.id],status 取反) | +| **M3 过滤直方图** | 过滤范围已通,仅缺直方图绘制(须取 getDataFilterConfig 分桶并渲染) | 在 ScatterFilterDialog 加直方图视图(分桶 + min/max 区间叠加) | +| **I9 异常图上绘形** | 表单已通;图上交互绘制多边形/折线/点(橡皮筋 + 顶点编辑)属重型 Qwt 交互 | 接入图上绘制工具(绘形→坐标回填 location),与表单提交合流 | +| **I14 Quill 富文本** | 原版 attachedParameters.deltaContent 为 Quill Delta;Qt 暂降级为纯文本 | 引入富文本编辑器(QTextEdit 富文本 ↔ Delta 互转)或保持纯文本兜底 | +| **I3 白化 tmObjectId 透传** | 客户端视图未透传 `structParentId`(白化模板列表用),现兜底空串 | 上游改造:数据集列表把 `structParentId` 接进视图(属上游数据流改造) | diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index bb76077..fd35334 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -39,6 +39,20 @@ add_executable(geopro_desktop WIN32 panels/ObjectExceptionPanel.cpp panels/DescriptionPanel.cpp panels/chart/RawDataChartView.cpp + panels/chart/InversionFormDialog.cpp + panels/chart/InversionFormParse.cpp + panels/chart/ScatterDataOps.cpp + panels/chart/SaveAsDialog.cpp + panels/chart/ScatterFilterDialog.cpp + panels/chart/InversionProcessOps.cpp + panels/chart/GridWizardDialog.cpp + panels/chart/WhiteningDialog.cpp + panels/chart/FilterDialog.cpp + panels/chart/ExceptionDialog.cpp + panels/chart/ExceptionDetailDialog.cpp + panels/chart/AutoAnnotationDialog.cpp + panels/chart/ContourSimplify.cpp + panels/chart/ContourHoverTip.cpp panels/chart/GridDataChartView.cpp panels/chart/DataTableView.cpp panels/chart/TablePager.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index b9ab563..fc89bf7 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -123,6 +123,7 @@ #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" #include "api/ApiColorTemplateRepository.hpp" +#include "api/ApiDatasetCommandRepository.hpp" #include "api/Api3dRepository.hpp" #include "panels/ObjectTreePanel.hpp" #include "login/LoginWindow.hpp" @@ -234,6 +235,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re geopro::data::IAsyncProjectRepository& projectRepo, geopro::data::IAsyncDatasetRepository& datasetRepo, geopro::data::IColorTemplateRepository& colorTplRepo, + geopro::data::IDatasetCommandRepository& cmdRepo, geopro::controller::WorkbenchNavController& nav, geopro::controller::DatasetDetailController& detailCtrl) { @@ -947,6 +949,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto* detailPanel = new geopro::app::DatasetDetailPanel(); // 注入色阶模板仓储 + 当前 projectId 取值回调(网格剖面「色阶配置」编辑器的另存/打开/新建色阶)。 detailPanel->setColorTemplateRepo(&colorTplRepo, [&nav] { return nav.currentProjectId(); }); + // 注入反演命令仓储(measurement 反演运算/生成视电阻率)。projectId 取值仍由页内 projectIdGetter 提供。 + detailPanel->setCommandRepo(&cmdRepo); auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情")); // ForceNoScrollArea:禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。 // 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充; @@ -1844,6 +1848,8 @@ int main(int argc, char* argv[]) geopro::data::ApiDatasetRepository datasetRepo(api); // 色阶模板仓储(lvl 模板 + clr 色阶):同一共享会话 ApiClient,注入 2D/3D 色阶编辑器。 geopro::data::ApiColorTemplateRepository colorTplRepo(api); + // 反演命令仓储(反演运算 / 生成视电阻率 / 模型列表 / 动态表单):同一共享会话 ApiClient。 + geopro::data::ApiDatasetCommandRepository cmdRepo(api); // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。 geopro::controller::ChartStrategyRegistry chartRegistry; chartRegistry.add(std::make_unique()); @@ -1863,7 +1869,8 @@ int main(int argc, char* argv[]) // 启动防护:工作台构建期间任何同步加载失败(如样本数据缺失)都不应让进程静默退出, // 而是给出可见错误提示。否则登录后窗口未显示、进程消失,用户无从排查。 try { - buildWorkbench(*window, repo, projectRepo, datasetRepo, colorTplRepo, nav, detailCtrl); + buildWorkbench(*window, repo, projectRepo, datasetRepo, colorTplRepo, cmdRepo, nav, + detailCtrl); } catch (const std::exception& e) { QMessageBox::critical( nullptr, QStringLiteral("启动失败"), diff --git a/src/app/panels/AnomalyTablePanel.cpp b/src/app/panels/AnomalyTablePanel.cpp index 84c7c0b..ad6794e 100644 --- a/src/app/panels/AnomalyTablePanel.cpp +++ b/src/app/panels/AnomalyTablePanel.cpp @@ -1,8 +1,10 @@ #include "panels/AnomalyTablePanel.hpp" -#include -#include +#include #include +#include +#include #include +#include namespace geopro::app { static QString markName(int t) { return t == 1 ? "点" : t == 3 ? "多边形" : "多段线"; } @@ -23,18 +25,45 @@ void AnomalyTablePanel::setAnomalies(const std::vector& l table_->setRowCount(static_cast(list.size())); for (int i = 0; i < static_cast(list.size()); ++i) { const auto& a = list[i]; + // 创建时间/备注:优先用形参(兼容),否则回退 Anomaly 字段(DTO 已解析)。 + const QString ct = i < (int)createTimes.size() && !createTimes[i].isEmpty() + ? createTimes[i] : QString::fromStdString(a.createTime); + const QString rm = i < (int)remarks.size() && !remarks[i].isEmpty() + ? remarks[i] : QString::fromStdString(a.remark); table_->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(a.name))); table_->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(a.typeName))); table_->setItem(i, 2, new QTableWidgetItem(markName(static_cast(a.markType)))); - table_->setItem(i, 3, new QTableWidgetItem(i < (int)createTimes.size() ? createTimes[i] : "")); - table_->setItem(i, 4, new QTableWidgetItem(i < (int)remarks.size() ? remarks[i] : "")); - auto* eye = new QToolButton(table_); eye->setCheckable(true); eye->setChecked(true); - eye->setText("👁"); + table_->setItem(i, 3, new QTableWidgetItem(ct)); + table_->setItem(i, 4, new QTableWidgetItem(rm)); + + // 操作列:定位 / 详情 / 删除(对照原版 contourPage 操作列),保留眼睛显隐。 + auto* ops = new QWidget(table_); + auto* opLay = new QHBoxLayout(ops); + opLay->setContentsMargins(2, 0, 2, 0); + opLay->setSpacing(2); + auto* eye = new QToolButton(ops); eye->setCheckable(true); eye->setChecked(true); + eye->setText("👁"); eye->setToolTip(QStringLiteral("显示/隐藏")); connect(eye, &QToolButton::toggled, this, [this, i](bool on) { if (on) hidden_.erase(i); else hidden_.insert(i); emit hiddenChanged(hidden_); }); - table_->setCellWidget(i, 5, eye); + auto* btnLocate = new QToolButton(ops); btnLocate->setText(QStringLiteral("定位")); + connect(btnLocate, &QToolButton::clicked, this, [this, i]() { emit locateRequested(i); }); + auto* btnDetail = new QToolButton(ops); btnDetail->setText(QStringLiteral("详情")); + connect(btnDetail, &QToolButton::clicked, this, [this, i]() { emit detailRequested(i); }); + auto* btnDelete = new QToolButton(ops); btnDelete->setText(QStringLiteral("删除")); + connect(btnDelete, &QToolButton::clicked, this, [this, i]() { + // 原版 a-popconfirm 二次确认 → 这里用 QMessageBox 确认。 + if (QMessageBox::question(this, QStringLiteral("提示"), + QStringLiteral("确定删除该异常?")) == QMessageBox::Yes) + emit deleteRequested(i); + }); + opLay->addWidget(eye); + opLay->addWidget(btnLocate); + opLay->addWidget(btnDetail); + opLay->addWidget(btnDelete); + opLay->addStretch(); + table_->setCellWidget(i, 5, ops); } } } // namespace geopro::app diff --git a/src/app/panels/AnomalyTablePanel.hpp b/src/app/panels/AnomalyTablePanel.hpp index b765759..50e3aa6 100644 --- a/src/app/panels/AnomalyTablePanel.hpp +++ b/src/app/panels/AnomalyTablePanel.hpp @@ -6,16 +6,23 @@ class QTableWidget; namespace geopro::app { -// ds 级异常表:名称/异常类型/几何类型/创建时间/备注/操作(显隐眼睛)。行显隐 → 信号驱动图表叠加。 +// ds 级异常表:名称/异常类型/几何类型/创建时间/备注/操作。 +// 操作列对照原版 contourPage:定位 / 详情 / 删除(删除带二次确认)。 +// 行级显隐(眼睛)保留:信号驱动图表叠加(与原版分开,眼睛为客户端既有能力)。 class AnomalyTablePanel : public QWidget { Q_OBJECT public: explicit AnomalyTablePanel(QWidget* parent = nullptr); + // createTimes/remarks 形参保留兼容旧调用;为空时回退到 Anomaly 自带字段。 void setAnomalies(const std::vector& list, - const std::vector& createTimes, - const std::vector& remarks); + const std::vector& createTimes = {}, + const std::vector& remarks = {}); signals: void hiddenChanged(const std::set& hiddenIndices); + // 操作列:传出异常在当前列表中的下标(调用方据此取 Anomaly 拿 id/坐标)。 + void locateRequested(int index); + void detailRequested(int index); + void deleteRequested(int index); private: QTableWidget* table_; std::set hidden_; diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index fb5fccd..f3388f4 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -26,6 +26,10 @@ void DatasetDetailPage::setColorTemplateRepo(geopro::data::IColorTemplateReposit projectIdGetter_ = std::move(projectIdGetter); } +void DatasetDetailPage::setCommandRepo(geopro::data::IDatasetCommandRepository* repo) { + cmdRepo_ = repo; +} + void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QString& dsName, const std::vector& tabs) { Q_ASSERT(views_.empty()); // build() 仅一次:views_ 为已 release 的裸指针,二次构建会泄漏旧视图 @@ -43,9 +47,10 @@ void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QVector panelTabs; for (size_t i = 0; i < tabs.size(); ++i) { const auto& spec = tabs[i]; - // 仓储与 projectId 回调透传给工厂(仅 FilledContour 视图消费)。 - auto view = makeDetailView(spec.kind, this, colorTplRepo_, - projectIdGetter_); // 抛出由调用栈兜底(GuardedApplication) + // 仓储与 projectId 回调透传给工厂(FilledContour 用色阶模板仓储;Scatter 用反演命令仓储)。 + // dsIdGetter 用本页 dsId_(此处已赋值),随项目/数据集稳定。 + auto view = makeDetailView(spec.kind, this, colorTplRepo_, projectIdGetter_, cmdRepo_, + [this] { return dsId_; }); // 抛出由调用栈兜底(GuardedApplication) IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期 views_[i] = raw; // lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget,随其尺寸覆盖图区)。 diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index 3b487f6..1e9680a 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -9,6 +9,7 @@ namespace geopro::data { class IColorTemplateRepository; +class IDatasetCommandRepository; } namespace geopro::app { @@ -27,6 +28,10 @@ public: void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, std::function projectIdGetter); + // 反演命令仓储注入(measurement 反演运算/生成视电阻率,须在 build 前设置)。 + // dsId 用本页 dsId_(build 内构造 dsIdGetter,此时 dsId_ 已赋值);projectId 复用上面的 getter。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo); + // 按页签集构建页签(首次打开调一次)。dsId/ddCode/dsName 用于 tabNeeded。 void build(const QString& dsId, const QString& ddCode, const QString& dsName, const std::vector& tabs); @@ -63,6 +68,9 @@ private: // 色阶模板仓储注入(透传给 makeDetailView → 网格剖面色阶编辑器)。 geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr; std::function projectIdGetter_; + + // 反演命令仓储注入(透传给 makeDetailView → measurement 散点视图反演按钮)。 + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; }; } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 4df5a9a..e2796c0 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -11,6 +11,10 @@ void DatasetDetailPanel::setColorTemplateRepo(geopro::data::IColorTemplateReposi projectIdGetter_ = std::move(projectIdGetter); } +void DatasetDetailPanel::setCommandRepo(geopro::data::IDatasetCommandRepository* repo) { + cmdRepo_ = repo; +} + DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) { setTabsClosable(true); connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { widget(i)->deleteLater(); }); @@ -35,6 +39,7 @@ void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddC p = new DatasetDetailPage(this); // 注入须在 build 前(build 内造视图时即透传给工厂)。 p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_); + p->setCommandRepo(cmdRepo_); p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带 const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id) const int idx = addTab(p, title); diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp index f02a51a..66450c8 100644 --- a/src/app/panels/DatasetDetailPanel.hpp +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -7,6 +7,7 @@ #include "DatasetDetailTab.hpp" // geopro::controller::TabSpec namespace geopro::data { class IColorTemplateRepository; +class IDatasetCommandRepository; } namespace geopro::app { class DatasetDetailPage; @@ -21,6 +22,9 @@ public: void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, std::function projectIdGetter); + // 反演命令仓储:透传给每个新建的详情页(measurement 反演运算/生成视电阻率用)。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo); + // 数据集打开:find-or-create 页 → build(tabs) → 加/抬该面板页签。 void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, const std::vector& tabs); @@ -42,5 +46,8 @@ private: // 色阶模板仓储注入(新页 build 前 setColorTemplateRepo 透传)。 geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr; std::function projectIdGetter_; + + // 反演命令仓储注入(新页 build 前 setCommandRepo 透传)。 + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; }; } // namespace geopro::app diff --git a/src/app/panels/DescriptionPanel.cpp b/src/app/panels/DescriptionPanel.cpp index 5289b9d..bd07361 100644 --- a/src/app/panels/DescriptionPanel.cpp +++ b/src/app/panels/DescriptionPanel.cpp @@ -1,22 +1,38 @@ #include "panels/DescriptionPanel.hpp" +#include +#include #include #include +#include "Theme.hpp" + namespace geopro::app { DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); - lay->setContentsMargins(8, 8, 8, 8); + lay->setContentsMargins(geopro::app::space::kMd, geopro::app::space::kMd, + geopro::app::space::kMd, geopro::app::space::kMd); edit_ = new QTextEdit(this); - edit_->setReadOnly(true); edit_->setPlaceholderText(QStringLiteral("暂无描述")); - lay->addWidget(edit_); + lay->addWidget(edit_, 1); + + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + saveBtn_ = new QPushButton(QStringLiteral("保存"), this); + saveBtn_->setEnabled(false); // 注入 cmdRepo 后启用 + btnLay->addWidget(saveBtn_); + lay->addLayout(btnLay); + + connect(saveBtn_, &QPushButton::clicked, this, + [this]() { emit saveRequested(edit_->toPlainText()); }); } -void DescriptionPanel::setText(const QString& text) { - edit_->setPlainText(text); -} +void DescriptionPanel::setText(const QString& text) { edit_->setPlainText(text); } + +QString DescriptionPanel::text() const { return edit_->toPlainText(); } + +void DescriptionPanel::setSaveEnabled(bool on) { saveBtn_->setEnabled(on); } } // namespace geopro::app diff --git a/src/app/panels/DescriptionPanel.hpp b/src/app/panels/DescriptionPanel.hpp index b364fae..05928de 100644 --- a/src/app/panels/DescriptionPanel.hpp +++ b/src/app/panels/DescriptionPanel.hpp @@ -2,17 +2,26 @@ #include class QTextEdit; +class QPushButton; namespace geopro::app { -// 数据集描述面板:只读文本,供网格数据底部页签「描述」使用。 +// 数据集描述面板:可编辑文本 + 保存按钮(I14)。 +// 原版用 Quill 富文本(Delta),Qt 无对应控件 → 退化为纯文本编辑 + 保存; +// 保存时由调用方组装 {description, attachedParameters:{deltaContent}}(见 GridDataChartView)。 class DescriptionPanel : public QWidget { Q_OBJECT public: explicit DescriptionPanel(QWidget* parent = nullptr); void setText(const QString& text); + QString text() const; + // 注入「保存」可用性:无 cmdRepo/dsId 时禁用保存按钮(占位)。 + void setSaveEnabled(bool on); +signals: + void saveRequested(const QString& text); private: QTextEdit* edit_; + QPushButton* saveBtn_; }; } // namespace geopro::app diff --git a/src/app/panels/chart/AutoAnnotationDialog.cpp b/src/app/panels/chart/AutoAnnotationDialog.cpp new file mode 100644 index 0000000..f38e300 --- /dev/null +++ b/src/app/panels/chart/AutoAnnotationDialog.cpp @@ -0,0 +1,255 @@ +#include "panels/chart/AutoAnnotationDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" // scaledPx +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr int kDefaultMinPoints = 4; // 原版 minPointCount 默认 4 +// 面异常类型固定 remarkSourceType="3"(原版自动标注仅支持面/polygon)。 +const QString kPolygonType = QStringLiteral("3"); +} // namespace + +AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, + QString dsObjectId, QString projectId, QWidget* parent) + : QDialog(parent), + repo_(repo), + dsObjectId_(std::move(dsObjectId)), + projectId_(std::move(projectId)) { + setWindowTitle(QStringLiteral("自动标注")); + setModal(true); + resize(820, 520); + + auto* root = formkit::dialogRoot(this); + auto* split = new QHBoxLayout(); + + // ── 左:规则列表 ──────────────────────────────────────────────── + auto* leftCol = new QVBoxLayout(); + leftCol->addWidget(new QLabel(QStringLiteral("标注规则:"), this)); + auto* ruleContainer = new QWidget(this); + ruleHost_ = new QVBoxLayout(ruleContainer); + ruleHost_->setContentsMargins(0, 0, 0, 0); + leftCol->addWidget(ruleContainer); + auto* addBtn = new QPushButton(QStringLiteral("加规则"), this); + connect(addBtn, &QPushButton::clicked, this, [this]() { addRule(); }); + leftCol->addWidget(addBtn); + leftCol->addStretch(); + split->addLayout(leftCol, 1); + + // ── 右:预览表 ────────────────────────────────────────────────── + auto* rightCol = new QVBoxLayout(); + rightCol->addWidget(new QLabel(QStringLiteral("预览:"), this)); + previewTable_ = new QTableWidget(0, 4, this); + previewTable_->setHorizontalHeaderLabels( + {QStringLiteral("异常名称"), QStringLiteral("异常类型"), QStringLiteral("阈值范围"), + QStringLiteral("阈值模式")}); + previewTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + previewTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); + rightCol->addWidget(previewTable_, 1); + split->addLayout(rightCol, 1); + + root->addLayout(split, 1); + + // ── 底部按钮 ──────────────────────────────────────────────────── + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + auto* execBtn = new QPushButton(QStringLiteral("执行自动标注"), this); + saveBtn_ = new QPushButton(QStringLiteral("确定保存"), this); + saveBtn_->setEnabled(false); // 必须先执行得到预览才能保存 + btnLay->addWidget(cancelBtn); + btnLay->addWidget(execBtn); + btnLay->addWidget(saveBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(execBtn, &QPushButton::clicked, this, &AutoAnnotationDialog::onExecute); + connect(saveBtn_, &QPushButton::clicked, this, &AutoAnnotationDialog::onSave); + + addRule(); // 默认一条规则 + loadExceptionTypes(); // 拉面异常类型 +} + +void AutoAnnotationDialog::loadExceptionTypes() { + if (!repo_) return; + QPointer self(this); + repo_->listExceptionTypes(projectId_, kPolygonType, [self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->exceptionTypeOptions_ = {}; + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + QString name = o.value(QStringLiteral("exceptionTypeName")).toString(); + if (name.isEmpty()) name = o.value(QStringLiteral("label")).toString(); + QString id = o.value(QStringLiteral("id")).toString(); + if (id.isEmpty()) id = o.value(QStringLiteral("value")).toString(); + if (!id.isEmpty()) + self->exceptionTypeOptions_.append( + QJsonObject{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}}); + } + // 回填已存在的规则行下拉。 + for (auto& r : self->rules_) { + r.type->clear(); + for (const QJsonValue& ov : self->exceptionTypeOptions_) { + const QJsonObject o = ov.toObject(); + r.type->addItem(o.value(QStringLiteral("name")).toString(), + o.value(QStringLiteral("id")).toString()); + } + } + }); +} + +void AutoAnnotationDialog::addRule() { + auto* card = new QFrame(this); + card->setFrameShape(QFrame::StyledPanel); + auto* lay = new QHBoxLayout(card); + lay->setContentsMargins(4, 4, 4, 4); + + RuleRow row; + row.mode = new QComboBox(card); + row.mode->addItem(QStringLiteral("数值"), 1); + row.mode->addItem(QStringLiteral("百分位"), 2); + row.min = new QLineEdit(card); + row.min->setPlaceholderText(QStringLiteral("min")); + row.min->setFixedWidth(scaledPx(60)); + row.max = new QLineEdit(card); + row.max->setPlaceholderText(QStringLiteral("max")); + row.max->setFixedWidth(scaledPx(60)); + row.minPoints = new QSpinBox(card); + row.minPoints->setRange(1, 100000); + row.minPoints->setValue(kDefaultMinPoints); + row.type = new QComboBox(card); + for (const QJsonValue& ov : exceptionTypeOptions_) { + const QJsonObject o = ov.toObject(); + row.type->addItem(o.value(QStringLiteral("name")).toString(), + o.value(QStringLiteral("id")).toString()); + } + + lay->addWidget(new QLabel(QStringLiteral("模式"), card)); + lay->addWidget(row.mode); + lay->addWidget(row.min); + lay->addWidget(row.max); + lay->addWidget(new QLabel(QStringLiteral("最小点数"), card)); + lay->addWidget(row.minPoints); + lay->addWidget(row.type, 1); + + rules_.push_back(row); + ruleHost_->addWidget(card); +} + +QJsonArray AutoAnnotationDialog::buildRuleList() const { + QJsonArray arr; + for (const auto& r : rules_) { + QJsonObject rule{ + {QStringLiteral("exceptionTypeId"), r.type->currentData().toString()}, + {QStringLiteral("thresholdMode"), r.mode->currentData().toInt()}, + {QStringLiteral("minPointCount"), r.minPoints->value()}, + }; + // min/max:空 → null(对照原版 Number(...) 或 null)。 + const QString mn = r.min->text().trimmed(); + const QString mx = r.max->text().trimmed(); + rule.insert(QStringLiteral("thresholdMin"), + mn.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(mn.toDouble())); + rule.insert(QStringLiteral("thresholdMax"), + mx.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(mx.toDouble())); + arr.append(rule); + } + return arr; +} + +void AutoAnnotationDialog::onExecute() { + if (!repo_) return; + const QJsonArray rules = buildRuleList(); + if (rules.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请至少添加一条规则")); + return; + } + QJsonObject body{ + {QStringLiteral("dsObjectId"), dsObjectId_}, + {QStringLiteral("projectId"), projectId_}, + {QStringLiteral("exceptionMarkRuleList"), rules}, + }; + QPointer self(this); + repo_->executeExceptionMark(body, [self](bool ok, QJsonObject data, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("执行失败") : msg); + return; + } + // 预览异常:兼容 data 直接为数组(wireObject 包成 value) 或 data.list。 + QJsonArray list = data.value(QStringLiteral("value")).toArray(); + if (list.isEmpty()) list = data.value(QStringLiteral("list")).toArray(); + self->previewExceptions_ = list; + self->previewTable_->setRowCount(list.size()); + for (int i = 0; i < list.size(); ++i) { + const QJsonObject o = list[i].toObject(); + self->previewTable_->setItem( + i, 0, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString())); + self->previewTable_->setItem( + i, 1, + new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString())); + self->previewTable_->setItem( + i, 2, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString())); + self->previewTable_->setItem( + i, 3, + new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString())); + } + self->saveBtn_->setEnabled(!list.isEmpty()); + if (list.isEmpty()) + QMessageBox::information(self, self->windowTitle(), + QStringLiteral("未生成异常(无满足规则的区域)")); + }); +} + +void AutoAnnotationDialog::onSave() { + if (!repo_ || previewExceptions_.isEmpty()) return; + // 组装 exceptionList:保留 execute 返回项的关键字段。 + QJsonArray exceptionList; + for (const QJsonValue& v : previewExceptions_) { + const QJsonObject o = v.toObject(); + exceptionList.append(QJsonObject{ + {QStringLiteral("remarkSourceType"), o.value(QStringLiteral("exceptionMarkType"))}, + {QStringLiteral("exceptionName"), o.value(QStringLiteral("exceptionName"))}, + {QStringLiteral("exceptionTypeId"), o.value(QStringLiteral("exceptionTypeId"))}, + {QStringLiteral("location"), o.value(QStringLiteral("location"))}, + {QStringLiteral("remark"), o.value(QStringLiteral("remark"))}, + }); + } + QJsonObject body{ + {QStringLiteral("projectId"), projectId_}, + {QStringLiteral("remarkSourceId"), dsObjectId_}, + {QStringLiteral("exceptionList"), exceptionList}, + }; + saveBtn_->setEnabled(false); + QPointer self(this); + repo_->batchCreateException(body, [self](bool ok, QString msg) { + if (!self) return; + self->saveBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("保存失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/AutoAnnotationDialog.hpp b/src/app/panels/chart/AutoAnnotationDialog.hpp new file mode 100644 index 0000000..0c3e9c4 --- /dev/null +++ b/src/app/panels/chart/AutoAnnotationDialog.hpp @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +#include + +class QComboBox; +class QLineEdit; +class QSpinBox; +class QTableWidget; +class QPushButton; +class QVBoxLayout; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 自动标注对话框(I13,复刻原版 AutoAnnotationDialog): +// 左:规则列表(阈值模式 数值/百分位、min/max、最小点数、异常类型)。 +// 执行 → executeExceptionMark(预演) → 预览表;确定保存 → batchCreateException → reloadGrid。 +// 异常类型仅支持面/polygon(原版同),故 listExceptionTypes 取 remarkSourceType="3"。 +class AutoAnnotationDialog : public QDialog { + Q_OBJECT +public: + AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, + QString projectId, QWidget* parent = nullptr); + +private: + struct RuleRow { + QComboBox* mode = nullptr; // 1 数值 / 2 百分位 + QLineEdit* min = nullptr; + QLineEdit* max = nullptr; + QSpinBox* minPoints = nullptr; + QComboBox* type = nullptr; // userData = 异常类型 id + }; + + void loadExceptionTypes(); // 拉面异常类型(填充所有规则行下拉) + void addRule(); // 加一条规则卡片 + QJsonArray buildRuleList() const; // 组装 exceptionMarkRuleList + void onExecute(); // executeExceptionMark → 预览 + void onSave(); // batchCreateException + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsObjectId_; + QString projectId_; + + QVBoxLayout* ruleHost_ = nullptr; + std::vector rules_; + QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则行复用 + QTableWidget* previewTable_ = nullptr; + QJsonArray previewExceptions_; // execute 返回的预览异常(confirm 时批量存) + QPushButton* saveBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourHoverTip.cpp b/src/app/panels/chart/ContourHoverTip.cpp new file mode 100644 index 0000000..cae50f1 --- /dev/null +++ b/src/app/panels/chart/ContourHoverTip.cpp @@ -0,0 +1,60 @@ +#include "panels/chart/ContourHoverTip.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include "panels/chart/ContourPlotItem.hpp" + +namespace geopro::app { + +ContourHoverTip::ContourHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent) + : QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) { + if (plot_ && plot_->canvas()) { + plot_->canvas()->setMouseTracking(true); // hover 需开启鼠标跟踪 + plot_->canvas()->installEventFilter(this); + } +} + +bool ContourHoverTip::eventFilter(QObject* obj, QEvent* ev) { + if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev); + if (!enabled_ || !item_) return false; // 未开启提示则不处理 + + if (ev->type() == QEvent::Leave) { + QToolTip::hideText(); + return false; + } + if (ev->type() != QEvent::MouseMove) return QObject::eventFilter(obj, ev); + + auto* me = static_cast(ev); + if (me->buttons() != Qt::NoButton) return false; // 拖动平移中不弹(交给 LivePanner) + + const QwtScaleMap xMap = plot_->canvasMap(xAxis_); + const QwtScaleMap yMap = plot_->canvasMap(yAxis_); + const QPointF mp = me->position(); + const double dataX = xMap.invTransform(mp.x()); + const double dataY = yMap.invTransform(mp.y()); + // 像素命中半径换算到数据坐标(取 x 方向比例;x:y 等比锁定,足够近似)。 + const double dx1 = xMap.invTransform(mp.x() + kHitRadiusPx) - dataX; + const double radius = std::fabs(dx1); + + const double level = item_->contourLevelNear(dataX, dataY, radius); + if (std::isnan(level)) { + QToolTip::hideText(); + return false; + } + // 对照原版 customHoverFormatter:数值 + 坐标。 + const QString text = QStringLiteral("等值线信息
数值: %1
坐标: (%2, %3)") + .arg(level, 0, 'f', 2) + .arg(dataX, 0, 'f', 2) + .arg(dataY, 0, 'f', 2); + QToolTip::showText(me->globalPosition().toPoint(), text, plot_->canvas()); + return false; // 不消费 +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourHoverTip.hpp b/src/app/panels/chart/ContourHoverTip.hpp new file mode 100644 index 0000000..0d94ace --- /dev/null +++ b/src/app/panels/chart/ContourHoverTip.hpp @@ -0,0 +1,34 @@ +#pragma once +#include + +class QwtPlot; + +namespace geopro::app { + +class ContourPlotItem; + +// 等值线提示(I7「显示等值线提示信息」):监听画布鼠标移动(无按键时), +// 命中最近等值线则 QToolTip 显示其数值与坐标(对照原版 contour hover tooltip)。 +// 默认关闭,由工具条复选框 setEnabled(true) 开启。不消费事件,与 LivePanner 共存。 +class ContourHoverTip : public QObject { + Q_OBJECT +public: + ContourHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr); + + void setItem(const ContourPlotItem* item) { item_ = item; } + void setEnabled(bool on) { enabled_ = on; } + +protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + +private: + QwtPlot* plot_; + int xAxis_; + int yAxis_; + const ContourPlotItem* item_ = nullptr; + bool enabled_ = false; + + static constexpr double kHitRadiusPx = 6.0; // 命中半径(像素) +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp index 8dcf982..531d66d 100644 --- a/src/app/panels/chart/ContourPlotItem.cpp +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -11,6 +11,7 @@ #include #include "panels/chart/ColorMapService.hpp" +#include "panels/chart/ContourSimplify.hpp" namespace geopro::app { @@ -41,6 +42,7 @@ void ContourPlotItem::setData(const core::Grid& g, ColorMapService* svc, static_cast(g.y.size()) < ny) { fillImage_ = QImage(); dataBBox_ = QRectF(); + linesRaw_.clear(); lines_.clear(); return; } @@ -57,15 +59,60 @@ void ContourPlotItem::setData(const core::Grid& g, ColorMapService* svc, opt.upsample = 2; opt.makeLines = true; auto res = render::buildContourBands(g, svc->scale(), opt); - lines_ = std::move(res.lines); + linesRaw_ = std::move(res.lines); + lines_ = linesRaw_; // buildContourBands 当前未回填 level(恒 0);在此按线上代表点采网格值并吸附到最近色阶级, // 使标注显示真实等值线值。 resolveLineLevels(g, svc->scale()); + linesRaw_ = lines_; // level 回填后同步到原始集(简化保留 level)。 + applySimplify(); // 按当前容差抽稀(首次 tol=0 即原样)。 } else { + linesRaw_.clear(); lines_.clear(); } } +void ContourPlotItem::setSimplifyTolerance(double tol) { + simplifyTol_ = tol < 0.0 ? 0.0 : tol; + applySimplify(); +} + +void ContourPlotItem::applySimplify() { + if (simplifyTol_ <= 0.0) { + lines_ = linesRaw_; + return; + } + lines_.clear(); + lines_.reserve(linesRaw_.size()); + for (const auto& ln : linesRaw_) { + render::ContourLine s; + s.level = ln.level; + s.pts = douglasPeucker(ln.pts, simplifyTol_); + lines_.push_back(std::move(s)); + } +} + +double ContourPlotItem::contourLevelNear(double dataX, double dataY, double hitDataRadius) const { + // 在已绘制等值线(简化后)找最近线段,命中半径内返回该线 level。 + double bestD2 = hitDataRadius * hitDataRadius; + double bestLevel = std::nan(""); + for (const auto& ln : lines_) { + if (std::isnan(ln.level)) continue; + for (std::size_t i = 1; i < ln.pts.size(); ++i) { + const auto& a = ln.pts[i - 1]; + const auto& b = ln.pts[i]; + const double dx = b.x - a.x, dy = b.y - a.y; + const double len2 = dx * dx + dy * dy; + double t = len2 > 0 ? ((dataX - a.x) * dx + (dataY - a.y) * dy) / len2 : 0.0; + t = std::clamp(t, 0.0, 1.0); + const double px = a.x + t * dx, py = a.y + t * dy; + const double d2 = (dataX - px) * (dataX - px) + (dataY - py) * (dataY - py); + if (d2 < bestD2) { bestD2 = d2; bestLevel = ln.level; } + } + } + return bestLevel; +} + void ContourPlotItem::resolveLineLevels(const core::Grid& g, const core::ColorScale& cs) { const auto stops = cs.stopValues(); if (stops.empty() || lines_.empty()) return; @@ -158,6 +205,19 @@ QRectF ContourPlotItem::boundingRect() const { return dataBBox_; } +QRectF ContourPlotItem::anomalyBoundingRect(int index) const { + if (index < 0 || index >= static_cast(anoms_.size())) return {}; + const auto& pts = anoms_[index].localPts; + if (pts.empty()) return {}; + double minX = pts.front().x, maxX = pts.front().x; + double minY = pts.front().y, maxY = pts.front().y; + for (const auto& p : pts) { + minX = std::min(minX, p.x); maxX = std::max(maxX, p.x); + minY = std::min(minY, p.y); maxY = std::max(maxY, p.y); + } + return QRectF(minX, minY, maxX - minX, maxY - minY); +} + void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap, const QRectF& /*canvasRect*/) const { if (dataBBox_.isNull()) return; @@ -253,18 +313,21 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt painter->save(); painter->setRenderHint(QPainter::Antialiasing, true); painter->setBrush(Qt::NoBrush); - for (const auto& a : anoms_) { + for (int ai = 0; ai < static_cast(anoms_.size()); ++ai) { + const auto& a = anoms_[ai]; if (a.localPts.empty()) continue; + const bool hl = (ai == highlightIdx_); // I12 当前定位高亮 QColor col(QString::fromStdString(a.lineColor)); if (!col.isValid()) col = QColor(0, 0, 0); - QPen pen(col); - pen.setWidthF(a.lineWidth > 0 ? a.lineWidth : 1.0); - pen.setStyle(a.dashed ? Qt::DashLine : Qt::SolidLine); + QPen pen(hl ? QColor(255, 255, 0) : col); // 高亮黄(对照原版 #ffff00) + pen.setWidthF(hl ? std::max(3.0, a.lineWidth) : (a.lineWidth > 0 ? a.lineWidth : 1.0)); + pen.setStyle(hl ? Qt::SolidLine : (a.dashed ? Qt::DashLine : Qt::SolidLine)); painter->setPen(pen); if (a.markType == core::AnomalyMarkType::Point) { const QPointF c = mapPt(a.localPts.front()); - painter->drawRect(QRectF(c.x() - 3, c.y() - 3, 6, 6)); + const double r = hl ? 5.0 : 3.0; + painter->drawRect(QRectF(c.x() - r, c.y() - r, 2 * r, 2 * r)); } else { QPolygonF poly; poly.reserve(static_cast(a.localPts.size())); diff --git a/src/app/panels/chart/ContourPlotItem.hpp b/src/app/panels/chart/ContourPlotItem.hpp index 06db8de..ca23ddc 100644 --- a/src/app/panels/chart/ContourPlotItem.hpp +++ b/src/app/panels/chart/ContourPlotItem.hpp @@ -33,11 +33,22 @@ public: void setShowLines(bool on) { showLines_ = on; } void setShowLabels(bool on) { showLabels_ = on; } void setShowAnomalies(bool on) { showAnomalies_ = on; } + // I8 简化容差:对等值线做 Douglas-Peucker 抽稀(数据坐标系,tol>0 生效)。 + // 改容差即重算 lines_(从原始 linesRaw_),调用方随后 replot。 + void setSimplifyTolerance(double tol); + // I7 等值线提示:按数据坐标命中最近等值线,返回其 level(无命中返回 NaN)。 + // hitDataRadius 为命中半径(数据坐标,由调用方按像素半径换算)。 + double contourLevelNear(double dataX, double dataY, double hitDataRadius) const; // 线形⚙ 配置(色阶编辑器下发):等值线色/线型(虚实)、标注色。默认黑实线。 void setLineColor(const core::Rgba& c) { lineColor_ = c; } void setLineDashed(bool dashed) { lineDashed_ = dashed; } void setLabelColor(const core::Rgba& c) { labelColor_ = c; } + // I12 定位:高亮指定下标的异常(黄色加粗描边),-1=清除。调用方随后 replot。 + void setHighlightedAnomaly(int index) { highlightIdx_ = index; } + // I12 定位:取某异常的数据坐标包围盒(用于视图缩放);下标越界/无点返回 null。 + QRectF anomalyBoundingRect(int index) const; + int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } QRectF boundingRect() const override; @@ -48,10 +59,13 @@ public: private: void buildFillImage(const core::Grid& g, ColorMapService* svc); void resolveLineLevels(const core::Grid& g, const core::ColorScale& cs); + void applySimplify(); // 从 linesRaw_ 按 simplifyTol_ 重算 lines_(保留 level) QImage fillImage_; // 预渲染填充热力图(ARGB32,含透明无数据区) QRectF dataBBox_; // 数据包围盒(x[xmin,xmax] y[ymin,ymax]) - std::vector lines_; // 矢量等值线(含 level) + std::vector linesRaw_; // 原始等值线(简化前,作简化数据源) + std::vector lines_; // 当前绘制等值线(按容差简化后,含 level) + double simplifyTol_ = 0.0; // I8 简化容差(数据坐标,0=不简化) std::vector anoms_; // 异常叠加 bool showLines_ = true; @@ -60,6 +74,7 @@ private: core::Rgba lineColor_{0, 0, 0, 255}; // 等值线色(默认黑) bool lineDashed_ = false; // 等值线虚实(默认实线) core::Rgba labelColor_{0, 0, 0, 255}; // 标注色(默认黑) + int highlightIdx_ = -1; // I12 当前高亮异常下标(-1=无) }; } // namespace geopro::app diff --git a/src/app/panels/chart/ContourSimplify.cpp b/src/app/panels/chart/ContourSimplify.cpp new file mode 100644 index 0000000..4956ed2 --- /dev/null +++ b/src/app/panels/chart/ContourSimplify.cpp @@ -0,0 +1,51 @@ +#include "panels/chart/ContourSimplify.hpp" + +#include + +namespace geopro::app { + +namespace { +// 点 p 到线段 ab 的垂距(ab 退化为点时取点距)。 +double perpDistance(const geopro::core::Vec2& p, const geopro::core::Vec2& a, + const geopro::core::Vec2& b) { + const double dx = b.x - a.x, dy = b.y - a.y; + const double len2 = dx * dx + dy * dy; + if (len2 <= 0.0) return std::hypot(p.x - a.x, p.y - a.y); + // 叉积 / 段长 = 垂距。 + const double cross = std::fabs((p.x - a.x) * dy - (p.y - a.y) * dx); + return cross / std::sqrt(len2); +} + +// 递归对 [lo,hi] 区间抽稀,保留点索引置 keep[]。 +void dpRecurse(const std::vector& pts, int lo, int hi, double tol, + std::vector& keep) { + if (hi <= lo + 1) return; + double maxD = -1.0; + int idx = lo; + for (int i = lo + 1; i < hi; ++i) { + const double d = perpDistance(pts[i], pts[lo], pts[hi]); + if (d > maxD) { maxD = d; idx = i; } + } + if (maxD > tol) { + keep[idx] = true; + dpRecurse(pts, lo, idx, tol, keep); + dpRecurse(pts, idx, hi, tol, keep); + } +} +} // namespace + +std::vector douglasPeucker(const std::vector& pts, + double tol) { + if (tol <= 0.0 || pts.size() <= 2) return pts; + std::vector keep(pts.size(), false); + keep.front() = true; + keep.back() = true; + dpRecurse(pts, 0, static_cast(pts.size()) - 1, tol, keep); + std::vector out; + out.reserve(pts.size()); + for (std::size_t i = 0; i < pts.size(); ++i) + if (keep[i]) out.push_back(pts[i]); + return out; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourSimplify.hpp b/src/app/panels/chart/ContourSimplify.hpp new file mode 100644 index 0000000..61bb9e0 --- /dev/null +++ b/src/app/panels/chart/ContourSimplify.hpp @@ -0,0 +1,14 @@ +#pragma once +#include + +#include "model/Anomaly.hpp" // core::Vec2 + +namespace geopro::app { + +// 折线 Douglas-Peucker 抽稀(纯几何,无 Qt/VTK 依赖,便于单测)。 +// tol<=0 或点数<=2 时原样返回(拷贝)。容差单位与点坐标一致(数据坐标)。 +// I8「简化容差」滑块据此对等值线做点抽稀(容差越大点越少、线越粗略)。 +std::vector douglasPeucker(const std::vector& pts, + double tol); + +} // namespace geopro::app diff --git a/src/app/panels/chart/DataTableView.cpp b/src/app/panels/chart/DataTableView.cpp index 410edf0..8f9a837 100644 --- a/src/app/panels/chart/DataTableView.cpp +++ b/src/app/panels/chart/DataTableView.cpp @@ -1,10 +1,17 @@ #include "panels/chart/DataTableView.hpp" +#include + +#include +#include #include #include +#include #include +#include #include +#include "panels/chart/InversionFormDialog.hpp" #include "panels/chart/TablePager.hpp" namespace geopro::app { @@ -111,6 +118,14 @@ DataTableView::DataTableView(QWidget* parent) : QWidget(parent) { lay->setContentsMargins(0, 0, 0, 0); lay->setSpacing(0); + // 顶部功能按钮行(默认隐藏;仅 dd_grid 载荷带 functionButtons 时显示)。右对齐贴近原版布局。 + toolbar_ = new QWidget(this); + toolbarLay_ = new QHBoxLayout(toolbar_); + toolbarLay_->setContentsMargins(0, 0, 0, 8); + toolbarLay_->addStretch(1); + toolbar_->hide(); + lay->addWidget(toolbar_); + model_ = new TablePayloadModel(this); table_ = new QTableView(this); table_->setModel(model_); @@ -162,6 +177,54 @@ void DataTableView::setPayload(const QVariant& payload) { } else { pager_->hide(); } + + // 功能按钮行:按载荷 functionButtons 重建(空 → 隐藏;非 dd_grid 始终空)。 + rebuildToolbar(t.functionButtons); +} + +void DataTableView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter) { + cmdRepo_ = repo; + dsIdGetter_ = std::move(dsIdGetter); + projectIdGetter_ = std::move(projectIdGetter); +} + +void DataTableView::rebuildToolbar(const std::vector& buttons) { + // 清空旧按钮(保留末尾 addStretch;逐项删 QPushButton)。 + for (int i = toolbarLay_->count() - 1; i >= 0; --i) { + if (auto* w = toolbarLay_->itemAt(i)->widget()) { + toolbarLay_->removeWidget(w); + w->deleteLater(); + } + } + + // 仅渲染 enable 的按钮(原版 v-show="enable");全空/全禁用 → 隐藏整行。 + int shown = 0; + for (const auto& b : buttons) { + if (!b.enable) continue; + auto* btn = new QPushButton(b.nameChn, toolbar_); + const QString code = b.code; + connect(btn, &QPushButton::clicked, this, [this, code] { onFunctionButton(code); }); + toolbarLay_->addWidget(btn); + ++shown; + } + toolbar_->setVisible(shown > 0); +} + +void DataTableView::onFunctionButton(const QString& code) { + // 路由:原版 handleFunctionBtn 仅处理 inversion(其余 code 无操作)。 + if (code != QLatin1String("inversion")) return; + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { + QToolTip::showText(QCursor::pos(), QStringLiteral("暂未实现"), this); + return; + } + // 反演运算:复用共享对话框 Mode::Inversion(模型列表+动态表单+submitInversionTask)。 + // 与原版 DdGrid 完全一致(同 InversionForm.vue + outerInversion 端点)。 + InversionFormDialog dlg(InversionFormDialog::Mode::Inversion, cmdRepo_, dsId, projectId, this); + dlg.exec(); // 提交反馈由对话框内部处理;列表无需刷新(原版亦仅 Message.success 提示)。 } } // namespace geopro::app diff --git a/src/app/panels/chart/DataTableView.hpp b/src/app/panels/chart/DataTableView.hpp index d0eaaa5..b36e47a 100644 --- a/src/app/panels/chart/DataTableView.hpp +++ b/src/app/panels/chart/DataTableView.hpp @@ -1,11 +1,20 @@ #pragma once +#include + #include #include +#include #include #include "model/detail/DetailPayloads.hpp" #include "panels/chart/IDetailView.hpp" class QTableView; +class QWidget; +class QHBoxLayout; + +namespace geopro::data { +class IDatasetCommandRepository; +} namespace geopro::app { @@ -48,6 +57,9 @@ class TablePager; // 通用数据列表视图:IDetailView + QTableView(+ 分页型载荷时底部 TablePager 分页器)。 // measurement/grid/trajectory 列表共用。载荷 pageSize>0(dd_grid)时显示分页器并转发翻页请求; // 否则隐藏分页器(全量列表)。 +// 顶部功能按钮行:仅当载荷 functionButtons 非空(dd_grid)时显示,按钮文案/可见来自服务端 +// functionList;点 code=="inversion" → 弹反演动态表单对话框(复用 InversionFormDialog/Mode::Inversion)。 +// 其余 Table 复用场景(measurement 列表/trajectory/gr)functionButtons 为空 → 工具条隐藏,无任何变化。 class DataTableView : public QWidget, public IDetailView { Q_OBJECT public: @@ -56,14 +68,29 @@ public: QWidget* widget() override { return this; } void setPayload(const QVariant& payload) override; // 坏/空 variant → 保持空态不崩 + // 注入反演命令仓储 + dsId/projectId 取值回调(dd_grid「反演」功能按钮用)。 + // 未注入时功能按钮仍渲染但点击退化为占位提示(与其它未接入视图一致)。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter); + signals: // 分页器请求加载某页(翻页/跳页/改每页条数)。壳据此触发控制器 loadTabPaged。 void pageRequested(int pageNo, int pageSize); private: + void rebuildToolbar(const std::vector& buttons); + void onFunctionButton(const QString& code); // 功能按钮路由(仅 inversion 起效) + + QWidget* toolbar_; // 顶部功能按钮行容器(functionButtons 空时隐藏) + QHBoxLayout* toolbarLay_; // 功能按钮布局(重建时清空重填) QTableView* table_; TablePayloadModel* model_; TablePager* pager_; // 分页器(pageSize>0 时显示,否则隐藏) + + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; + std::function dsIdGetter_; + std::function projectIdGetter_; }; } // namespace geopro::app diff --git a/src/app/panels/chart/DetailViewFactory.cpp b/src/app/panels/chart/DetailViewFactory.cpp index 8ac1ca5..f22e065 100644 --- a/src/app/panels/chart/DetailViewFactory.cpp +++ b/src/app/panels/chart/DetailViewFactory.cpp @@ -14,18 +14,32 @@ namespace geopro::app { std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* parent, geopro::data::IColorTemplateRepository* colorTplRepo, - std::function projectIdGetter) { + std::function projectIdGetter, + geopro::data::IDatasetCommandRepository* cmdRepo, + std::function dsIdGetter) { switch (kind) { - case controller::ViewKind::Scatter: - return std::unique_ptr(new RawDataChartView(parent)); + case controller::ViewKind::Scatter: { + auto* raw = new RawDataChartView(parent); + // 注入反演命令仓储 + dsId/projectId 取值回调(measurement 反演运算/生成视电阻率)。 + raw->setCommandRepo(cmdRepo, dsIdGetter, projectIdGetter); + return std::unique_ptr(raw); + } case controller::ViewKind::FilledContour: { auto* grid = new GridDataChartView(parent); // 注入色阶模板仓储 + projectId 取值回调(网格剖面「色阶配置」编辑器用)。 - grid->setColorTemplateRepo(colorTplRepo, std::move(projectIdGetter)); + grid->setColorTemplateRepo(colorTplRepo, projectIdGetter); + // 注入反演命令仓储 + dsId/projectId 取值回调(网格化/白化/滤波/另存为按钮)。 + grid->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter)); return std::unique_ptr(grid); } - case controller::ViewKind::Table: - return std::unique_ptr(new DataTableView(parent)); + case controller::ViewKind::Table: { + auto* table = new DataTableView(parent); + // 注入反演命令仓储 + dsId/projectId 取值回调(dd_grid「反演」功能按钮用)。 + // 其余 Table 复用场景(measurement 列表/trajectory/gr)载荷无 functionButtons, + // 工具条隐藏,注入的仓储不会被触达 → 无副作用。 + table->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter)); + return std::unique_ptr(table); + } case controller::ViewKind::Bar: return std::unique_ptr(new BarChartView(parent)); case controller::ViewKind::LineProfile: diff --git a/src/app/panels/chart/DetailViewFactory.hpp b/src/app/panels/chart/DetailViewFactory.hpp index 2cba058..aa11617 100644 --- a/src/app/panels/chart/DetailViewFactory.hpp +++ b/src/app/panels/chart/DetailViewFactory.hpp @@ -10,6 +10,7 @@ class QWidget; namespace geopro::data { class IColorTemplateRepository; +class IDatasetCommandRepository; } namespace geopro::app { @@ -20,9 +21,12 @@ class IDetailView; // Bar/LineProfile/PolylineMap/Table 留待后续阶段(measurement/trajectory/grid)补, // 现阶段命中会抛 std::runtime_error(明确失败,而非静默空指针)。 // colorTplRepo/projectIdGetter:FilledContour 视图的色阶模板仓储注入(可空 → 编辑器后端按钮禁用)。 +// cmdRepo/dsIdGetter:Scatter 视图(measurement)反演运算/生成视电阻率命令仓储注入(可空 → 按钮占位提示)。 std::unique_ptr makeDetailView( controller::ViewKind kind, QWidget* parent, geopro::data::IColorTemplateRepository* colorTplRepo = nullptr, - std::function projectIdGetter = {}); + std::function projectIdGetter = {}, + geopro::data::IDatasetCommandRepository* cmdRepo = nullptr, + std::function dsIdGetter = {}); } // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionDetailDialog.cpp b/src/app/panels/chart/ExceptionDetailDialog.cpp new file mode 100644 index 0000000..ee485d7 --- /dev/null +++ b/src/app/panels/chart/ExceptionDetailDialog.cpp @@ -0,0 +1,140 @@ +#include "panels/chart/ExceptionDetailDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandRepository* repo, + const geopro::core::Anomaly& anomaly, QWidget* parent) + : QDialog(parent), repo_(repo), anomaly_(anomaly) { + setWindowTitle(QStringLiteral("异常详情")); + setModal(true); + resize(420, 460); + + lineColor_ = QString::fromStdString(anomaly_.lineColor); + if (lineColor_.isEmpty()) lineColor_ = QStringLiteral("#000000"); + + auto* root = formkit::dialogRoot(this); + + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + auto* form = formkit::makeEditForm(); + + nameEdit_ = new QLineEdit(QString::fromStdString(anomaly_.name), this); + formkit::capField(nameEdit_); + form->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_); + + auto* typeLabel = new QLabel(QString::fromStdString(anomaly_.typeName), this); + form->addRow(formkit::editLabel(QStringLiteral("异常类型")), typeLabel); + + // 图例:线色 / 线宽 / 线型(对照原版 legend.polyline*)。 + colorBtn_ = new QPushButton(lineColor_, this); + colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(lineColor_)); + connect(colorBtn_, &QPushButton::clicked, this, [this]() { + const QColor c = QColorDialog::getColor(QColor(lineColor_), this, QStringLiteral("线色")); + if (c.isValid()) { + lineColor_ = c.name(); + colorBtn_->setText(lineColor_); + colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(lineColor_)); + } + }); + formkit::capField(colorBtn_); + form->addRow(formkit::editLabel(QStringLiteral("线色")), colorBtn_); + + widthSpin_ = new QDoubleSpinBox(this); + widthSpin_->setRange(0.1, 20.0); + widthSpin_->setSingleStep(0.5); + widthSpin_->setValue(anomaly_.lineWidth > 0 ? anomaly_.lineWidth : 1.0); + formkit::capField(widthSpin_); + form->addRow(formkit::editLabel(QStringLiteral("线宽")), widthSpin_); + + shapeCombo_ = new QComboBox(this); + shapeCombo_->addItem(QStringLiteral("实线"), QStringLiteral("solid")); + shapeCombo_->addItem(QStringLiteral("虚线"), QStringLiteral("dash")); + shapeCombo_->setCurrentIndex(anomaly_.dashed ? 1 : 0); + formkit::capField(shapeCombo_); + form->addRow(formkit::editLabel(QStringLiteral("线型")), shapeCombo_); + + remarkEdit_ = new QPlainTextEdit(QString::fromStdString(anomaly_.remark), this); + remarkEdit_->setFixedHeight(geopro::app::scaledPx(60)); + formkit::capField(remarkEdit_); + form->addRow(formkit::editLabel(QStringLiteral("备注")), remarkEdit_); + cardLay->addLayout(form); + root->addWidget(card); + + // 坐标(只读展示,对照原版坐标信息 tab)。 + root->addWidget(new QLabel(QStringLiteral("坐标:"), this)); + auto* coordTable = new QTableWidget(static_cast(anomaly_.localPts.size()), 2, this); + coordTable->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")}); + coordTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + coordTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + for (int r = 0; r < static_cast(anomaly_.localPts.size()); ++r) { + coordTable->setItem(r, 0, + new QTableWidgetItem(QString::number(anomaly_.localPts[r].x, 'f', 7))); + coordTable->setItem(r, 1, + new QTableWidgetItem(QString::number(anomaly_.localPts[r].y, 'f', 7))); + } + root->addWidget(coordTable, 1); + + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + okBtn_ = new QPushButton(QStringLiteral("确定"), this); + okBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn_, &QPushButton::clicked, this, &ExceptionDetailDialog::onConfirm); +} + +void ExceptionDetailDialog::onConfirm() { + if (!repo_ || anomaly_.id.empty()) { reject(); return; } + const QString name = nameEdit_->text().trimmed(); + if (name.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称")); + return; + } + // 原版详情抽屉「改名称/备注」走 PUT /business/exception 的局部更新, + // 仅发 {id, exceptionName, remark}(线样式是另一条独立 PUT,且抽屉里样式控件 disabled)。 + // 对齐原版 contourPage.vue onOk:不合并/不重发 legend。 + QJsonObject body{ + {QStringLiteral("id"), QString::fromStdString(anomaly_.id)}, + {QStringLiteral("exceptionName"), name}, + {QStringLiteral("remark"), remarkEdit_->toPlainText()}, + }; + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->updateException(body, [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("更新失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionDetailDialog.hpp b/src/app/panels/chart/ExceptionDetailDialog.hpp new file mode 100644 index 0000000..bcb6508 --- /dev/null +++ b/src/app/panels/chart/ExceptionDetailDialog.hpp @@ -0,0 +1,44 @@ +#pragma once +#include +#include + +#include "model/Anomaly.hpp" + +class QLineEdit; +class QPlainTextEdit; +class QDoubleSpinBox; +class QComboBox; +class QPushButton; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 异常详情/编辑对话框(I11,复刻原版 drawerExceptionInfo 的可编辑部分): +// 名称(可编辑) / 异常类型(只读) / 图例样式(线色/线宽/线型) / 备注(可编辑) / 坐标(只读展示)。 +// 确认 → updateException(PUT body {id, exceptionName, remark, legend:{polylineColor, +// polylineWidth, polylineShape}}),成功 accept(),调用方随后 reloadGrid。 +class ExceptionDetailDialog : public QDialog { + Q_OBJECT +public: + ExceptionDetailDialog(geopro::data::IDatasetCommandRepository* repo, + const geopro::core::Anomaly& anomaly, QWidget* parent = nullptr); + +private: + void onConfirm(); + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + geopro::core::Anomaly anomaly_; // 拷贝(不可变范式:编辑后组装新 body,不改原对象) + + QLineEdit* nameEdit_ = nullptr; + QPlainTextEdit* remarkEdit_ = nullptr; + QPushButton* colorBtn_ = nullptr; // 线色选择(弹 QColorDialog) + QString lineColor_; // 当前线色 hex + QDoubleSpinBox* widthSpin_ = nullptr; + QComboBox* shapeCombo_ = nullptr; // solid / dash + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionDialog.cpp b/src/app/panels/chart/ExceptionDialog.cpp new file mode 100644 index 0000000..007fc1d --- /dev/null +++ b/src/app/panels/chart/ExceptionDialog.cpp @@ -0,0 +1,214 @@ +#include "panels/chart/ExceptionDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +// 标注类型 → 最少坐标点数(点/文字=1,线≥2,面≥3)。 +int minPoints(const QString& markType) { + if (markType == QStringLiteral("2")) return 2; + if (markType == QStringLiteral("3")) return 3; + return 1; // 点("1")/文字("4") +} +} // namespace + +ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId, + QString remarkSourceId, QWidget* parent) + : QDialog(parent), + repo_(repo), + projectId_(std::move(projectId)), + remarkSourceId_(std::move(remarkSourceId)) { + setWindowTitle(QStringLiteral("新建异常")); + setModal(true); + resize(440, 480); + + auto* root = formkit::dialogRoot(this); + + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + auto* form = formkit::makeEditForm(); + + // 标注类型(remarkSourceType "1".."4",与原版 annotationType 一致)。 + markTypeCombo_ = new QComboBox(this); + markTypeCombo_->addItem(QStringLiteral("点"), QStringLiteral("1")); + markTypeCombo_->addItem(QStringLiteral("线"), QStringLiteral("2")); + markTypeCombo_->addItem(QStringLiteral("面"), QStringLiteral("3")); + markTypeCombo_->addItem(QStringLiteral("文字"), QStringLiteral("4")); + formkit::capField(markTypeCombo_); + form->addRow(formkit::editLabel(QStringLiteral("标注类型")), markTypeCombo_); + + exceptionTypeCombo_ = new QComboBox(this); + formkit::capField(exceptionTypeCombo_); + form->addRow(formkit::editLabel(QStringLiteral("异常类型")), exceptionTypeCombo_); + + nameEdit_ = new QLineEdit(this); + nameEdit_->setPlaceholderText(QStringLiteral("数据名称+异常类型代号+序号")); + formkit::capField(nameEdit_); + form->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_); + + remarkEdit_ = new QPlainTextEdit(this); + remarkEdit_->setFixedHeight(geopro::app::scaledPx(60)); + formkit::capField(remarkEdit_); + form->addRow(formkit::editLabel(QStringLiteral("备注")), remarkEdit_); + cardLay->addLayout(form); + root->addWidget(card); + + // 坐标表(x/y 多行),下方加/减行按钮。 + root->addWidget(new QLabel(QStringLiteral("坐标(x,y):"), this)); + coordTable_ = new QTableWidget(0, 2, this); + coordTable_->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")}); + coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + root->addWidget(coordTable_, 1); + + auto* rowBtns = new QHBoxLayout(); + auto* addRow = new QPushButton(QStringLiteral("加一行"), this); + auto* delRow = new QPushButton(QStringLiteral("删一行"), this); + rowBtns->addWidget(addRow); + rowBtns->addWidget(delRow); + rowBtns->addStretch(); + root->addLayout(rowBtns); + connect(addRow, &QPushButton::clicked, this, + [this]() { coordTable_->insertRow(coordTable_->rowCount()); }); + connect(delRow, &QPushButton::clicked, this, [this]() { + if (coordTable_->rowCount() > 0) coordTable_->removeRow(coordTable_->rowCount() - 1); + }); + + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + okBtn_ = new QPushButton(QStringLiteral("确定"), this); + okBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn_, &QPushButton::clicked, this, &ExceptionDialog::onConfirm); + connect(markTypeCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { onTypeChanged(); }); + connect(exceptionTypeCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { suggestName(); }); + + onTypeChanged(); // 初始:拉首个类型的异常类型列表 + 铺最少坐标行 +} + +QString ExceptionDialog::markTypeValue() const { + return markTypeCombo_->currentData().toString(); +} + +void ExceptionDialog::onTypeChanged() { + // 调整坐标表行数到该形态最少点数(不足则补行;已多则保留)。 + const int need = minPoints(markTypeValue()); + while (coordTable_->rowCount() < need) coordTable_->insertRow(coordTable_->rowCount()); + loadExceptionTypes(); +} + +void ExceptionDialog::loadExceptionTypes() { + if (!repo_) return; + QPointer self(this); + repo_->listExceptionTypes( + projectId_, markTypeValue(), [self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->exceptionTypeCombo_->clear(); + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + // 兼容 {label,value} 与 {exceptionTypeName,id} 两种返回形态。 + QString label = o.value(QStringLiteral("label")).toString(); + if (label.isEmpty()) label = o.value(QStringLiteral("exceptionTypeName")).toString(); + QString id = o.value(QStringLiteral("value")).toString(); + if (id.isEmpty()) id = o.value(QStringLiteral("id")).toString(); + if (!id.isEmpty()) self->exceptionTypeCombo_->addItem(label, id); + } + if (self->exceptionTypeCombo_->count() > 0) self->suggestName(); + }); +} + +void ExceptionDialog::suggestName() { + if (!repo_) return; + const QString typeId = exceptionTypeCombo_->currentData().toString(); + if (typeId.isEmpty()) return; + QPointer self(this); + repo_->getExceptionName(typeId, remarkSourceId_, [self](bool ok, QJsonObject data, QString) { + if (!self || !ok) return; + // 仅当用户未手填时回填建议名(避免覆盖)。 + if (!self->nameEdit_->text().trimmed().isEmpty()) return; + QString name = data.value(QStringLiteral("exceptionName")).toString(); + if (name.isEmpty()) name = data.value(QStringLiteral("name")).toString(); + self->nameEdit_->setText(name); + }); +} + +void ExceptionDialog::onConfirm() { + if (!repo_) { reject(); return; } + const QString name = nameEdit_->text().trimmed(); + const QString typeId = exceptionTypeCombo_->currentData().toString(); + if (name.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称")); + return; + } + if (typeId.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型")); + return; + } + // 收集坐标(跳过空行)。 + QJsonArray coords; + for (int r = 0; r < coordTable_->rowCount(); ++r) { + auto* ix = coordTable_->item(r, 0); + auto* iy = coordTable_->item(r, 1); + if (!ix || !iy || ix->text().trimmed().isEmpty() || iy->text().trimmed().isEmpty()) continue; + bool okx = false, oky = false; + const double x = ix->text().toDouble(&okx); + const double y = iy->text().toDouble(&oky); + if (!okx || !oky) continue; + coords.append(QJsonObject{{QStringLiteral("x"), x}, {QStringLiteral("y"), y}}); + } + if (coords.size() < minPoints(markTypeValue())) { + QMessageBox::warning(this, windowTitle(), + QStringLiteral("坐标点数不足(点/文字≥1,线≥2,面≥3)")); + return; + } + + QJsonObject body{ + {QStringLiteral("exceptionName"), name}, + {QStringLiteral("exceptionTypeId"), typeId}, + {QStringLiteral("remark"), remarkEdit_->toPlainText()}, + {QStringLiteral("remarkSourceType"), markTypeValue()}, // 几何形态字符串 + {QStringLiteral("remarkSourceId"), remarkSourceId_}, // = dsObjectId + {QStringLiteral("projectId"), projectId_}, + {QStringLiteral("location"), QJsonObject{{QStringLiteral("coordinate"), coords}}}, + }; + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->newException(body, [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("创建失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionDialog.hpp b/src/app/panels/chart/ExceptionDialog.hpp new file mode 100644 index 0000000..97aee8e --- /dev/null +++ b/src/app/panels/chart/ExceptionDialog.hpp @@ -0,0 +1,51 @@ +#pragma once +#include + +#include +#include +#include + +class QComboBox; +class QLineEdit; +class QPlainTextEdit; +class QTableWidget; +class QPushButton; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 异常创建对话框(I9,复刻原版 exceptionDialog + contourPage 保存链路): +// 标注类型(点/线/面/文字 → remarkSourceType "1"/"2"/"3"/"4") + 异常类型(listExceptionTypes) +// + 名称(getExceptionName 建议) + 备注 + 坐标表。 +// 确认 → newException(body),成功 accept(),调用方随后 reloadGrid。 +// 说明:原版「图上交互式绘制几何」在 Qwt 成本高,本实现以坐标表(x/y 多行)采集 location, +// 覆盖点(1 行)/线(≥2)/面(≥3)/文字(1) 全形态,打通完整创建链路;on-chart 拖拽绘制为后置项。 +class ExceptionDialog : public QDialog { + Q_OBJECT +public: + ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId, + QString remarkSourceId, QWidget* parent = nullptr); + +private: + void onTypeChanged(); // 标注类型变 → 重拉异常类型列表 + 调整坐标表最少行 + void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType) + void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称建议 + void onConfirm(); // 校验 → newException + QString markTypeValue() const; // 当前标注类型字符串("1".."4") + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString projectId_; + QString remarkSourceId_; + + QComboBox* markTypeCombo_ = nullptr; // userData = "1".."4" + QComboBox* exceptionTypeCombo_ = nullptr; // userData = 异常类型 id + QLineEdit* nameEdit_ = nullptr; + QPlainTextEdit* remarkEdit_ = nullptr; + QTableWidget* coordTable_ = nullptr; + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/FilterDialog.cpp b/src/app/panels/chart/FilterDialog.cpp new file mode 100644 index 0000000..d4b6cea --- /dev/null +++ b/src/app/panels/chart/FilterDialog.cpp @@ -0,0 +1,342 @@ +#include "panels/chart/FilterDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "panels/chart/InversionProcessOps.hpp" // buildFilterApplyBody / buildNewFilterBody +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr double kFillRange = 1e9; +constexpr int kMatrixMin = 1, kMatrixMax = 21; // 矩阵行列范围(对照原版 1~21) +constexpr int kDefaultDim = 3; +const char kDefaultCustomKey[] = "default-custom-filter"; // 默认自定义滤波器(不可删) +const char kCustomGroupName[] = "自定义滤波器"; + +// 单元格读数(空/非法 → 0)。 +double cellValue(const QTableWidgetItem* it) { + if (!it) return 0.0; + bool ok = false; + const double v = it->text().toDouble(&ok); + return ok ? v : 0.0; +} +} // namespace + +FilterDialog::FilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, + QString projectId, QWidget* parent) + : QDialog(parent), repo_(repo), dsId_(std::move(dsId)), projectId_(std::move(projectId)) { + setWindowTitle(QStringLiteral("滤波处理")); + setModal(true); + resize(820, 520); + + auto* root = formkit::dialogRoot(this); + auto* body = new QHBoxLayout(); + root->addLayout(body, 1); + + // ── 左:滤波器树 + 增删按钮 ───────────────────────────────────────── + auto* leftLay = new QVBoxLayout(); + tree_ = new QTreeWidget(this); + tree_->setHeaderHidden(true); + leftLay->addWidget(tree_, 1); + auto* treeBtnLay = new QHBoxLayout(); + auto* addBtn = new QPushButton(QStringLiteral("另存为"), this); + auto* delBtn = new QPushButton(QStringLiteral("删除"), this); + treeBtnLay->addWidget(addBtn); + treeBtnLay->addWidget(delBtn); + leftLay->addLayout(treeBtnLay); + body->addLayout(leftLay, 1); + + // ── 右:配置面板 ──────────────────────────────────────────────────── + auto* rightLay = new QVBoxLayout(); + auto* form = formkit::makeEditForm(); + dataEdge_ = new QComboBox(this); + dataEdge_->addItem(QStringLiteral("设为无效点"), QStringLiteral("whitening")); + dataEdge_->addItem(QStringLiteral("忽略"), QStringLiteral("skip")); + dataEdge_->addItem(QStringLiteral("复制边缘点"), QStringLiteral("edgePoint")); + dataEdge_->addItem(QStringLiteral("填充"), QStringLiteral("filling")); + dataEdgeValue_ = new QLineEdit(this); + dataEdgeValue_->setEnabled(false); + noDataPoints_ = new QComboBox(this); + noDataPoints_->addItem(QStringLiteral("扩展"), QStringLiteral("expansion")); + noDataPoints_->addItem(QStringLiteral("保留"), QStringLiteral("retain")); + noDataPoints_->addItem(QStringLiteral("跳过"), QStringLiteral("skip")); + noDataPoints_->addItem(QStringLiteral("填充"), QStringLiteral("filling")); + noDataPoints_->setCurrentIndex(3); // 默认填充(对照原版) + noDataValue_ = new QLineEdit(this); + filterTimes_ = new QSpinBox(this); + filterTimes_->setRange(1, 10); + rows_ = new QSpinBox(this); + rows_->setRange(kMatrixMin, kMatrixMax); + rows_->setValue(kDefaultDim); + cols_ = new QSpinBox(this); + cols_->setRange(kMatrixMin, kMatrixMax); + cols_->setValue(kDefaultDim); + formkit::capField(dataEdge_); + formkit::capField(dataEdgeValue_); + formkit::capField(noDataPoints_); + formkit::capField(noDataValue_); + formkit::capField(filterTimes_); + formkit::capField(rows_); + formkit::capField(cols_); + form->addRow(formkit::editLabel(QStringLiteral("数据边缘:")), dataEdge_); + form->addRow(formkit::editLabel(QStringLiteral("数据边缘值:")), dataEdgeValue_); + form->addRow(formkit::editLabel(QStringLiteral("无数据点:")), noDataPoints_); + form->addRow(formkit::editLabel(QStringLiteral("无数据点值:")), noDataValue_); + form->addRow(formkit::editLabel(QStringLiteral("滤波次数:")), filterTimes_); + form->addRow(formkit::editLabel(QStringLiteral("行:")), rows_); + form->addRow(formkit::editLabel(QStringLiteral("列:")), cols_); + rightLay->addLayout(form); + matrix_ = new QTableWidget(kDefaultDim, kDefaultDim, this); + matrix_->horizontalHeader()->setVisible(false); + matrix_->verticalHeader()->setVisible(false); + rightLay->addWidget(matrix_, 1); + body->addLayout(rightLay, 2); + + // 底部按钮。 + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + okBtn_ = new QPushButton(QStringLiteral("应用"), this); + okBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + + resizeMatrix(); // 默认 3x3 中心 1 + if (auto* c = matrix_->item(1, 1)) c->setText(QStringLiteral("1")); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn_, &QPushButton::clicked, this, &FilterDialog::onConfirm); + connect(addBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter); + connect(delBtn, &QPushButton::clicked, this, &FilterDialog::deleteSelectedFilter); + connect(tree_, &QTreeWidget::itemSelectionChanged, this, + &FilterDialog::onTreeSelectionChanged); + connect(rows_, QOverload::of(&QSpinBox::valueChanged), this, + [this](int) { resizeMatrix(); }); + connect(cols_, QOverload::of(&QSpinBox::valueChanged), this, + [this](int) { resizeMatrix(); }); + // 数据边缘/无数据点:仅「填充」启用对应值输入框(对照原版 v-if=filling)。 + connect(dataEdge_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { + dataEdgeValue_->setEnabled(dataEdge_->currentData().toString() == + QStringLiteral("filling")); + }); + connect(noDataPoints_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { + noDataValue_->setEnabled(noDataPoints_->currentData().toString() == + QStringLiteral("filling")); + }); + noDataValue_->setEnabled(true); // 默认填充 + + loadFilters(); +} + +void FilterDialog::loadFilters() { + if (!repo_) return; + QPointer self(this); + repo_->listFilters([self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->flatItems_.clear(); + for (const QJsonValue& v : list) self->flatItems_.push_back(v.toObject()); + self->buildTree(); + }); +} + +void FilterDialog::buildTree() { + tree_->clear(); + // 按 id 索引节点,先建全部节点再挂父子(扁平 → 树,对照原版 buildTreeData)。 + QHash byId; + QHash parentOf; + for (const auto& o : flatItems_) { + const QString id = o.value(QStringLiteral("id")).toString(); + const QString name = o.value(QStringLiteral("name")).toString(); + auto* item = new QTreeWidgetItem(QStringList{name}); + item->setData(0, Qt::UserRole, o.value(QStringLiteral("id")).toString()); + item->setData(0, Qt::UserRole + 1, QString::fromUtf8(QJsonDocument(o).toJson())); + // 叶节点(含矩阵 rowColumValue)才可选中应用。 + const bool selectable = !o.value(QStringLiteral("rowColumValue")).isNull() && + o.contains(QStringLiteral("rowColumValue")); + if (!selectable) item->setFlags(item->flags() & ~Qt::ItemIsSelectable); + byId.insert(id, item); + parentOf.insert(id, o.value(QStringLiteral("parentId")).toString()); + } + for (auto it = byId.begin(); it != byId.end(); ++it) { + const QString pid = parentOf.value(it.key()); + if (!pid.isEmpty() && byId.contains(pid)) + byId.value(pid)->addChild(it.value()); + else + tree_->addTopLevelItem(it.value()); + } + tree_->expandAll(); +} + +void FilterDialog::onTreeSelectionChanged() { + auto* item = tree_->currentItem(); + if (!item) return; + const QByteArray raw = item->data(0, Qt::UserRole + 1).toString().toUtf8(); + const QJsonObject o = QJsonDocument::fromJson(raw).object(); + const QJsonObject rc = o.value(QStringLiteral("rowColumValue")).toObject(); + const QJsonArray form = rc.value(QStringLiteral("form")).toArray(); + if (form.isEmpty()) return; + // 回填矩阵 + 行列。 + const int r = form.size(); + const int c = form.at(0).toArray().size(); + rows_->setValue(r); + cols_->setValue(c); // 触发 resizeMatrix + for (int i = 0; i < r; ++i) { + const QJsonArray row = form.at(i).toArray(); + for (int j = 0; j < c && j < row.size(); ++j) + if (auto* cell = matrix_->item(i, j)) + cell->setText(QString::number(row.at(j).toDouble())); + } + // 回填配置(存在则用,缺省保持当前)。code 1..N 直接映射到下拉项 0..N-1。 + auto setCombo = [](QComboBox* combo, int code) { + if (code >= 1 && code <= combo->count()) combo->setCurrentIndex(code - 1); + }; + if (o.contains(QStringLiteral("boundary"))) + setCombo(dataEdge_, o.value(QStringLiteral("boundary")).toInt()); + if (o.contains(QStringLiteral("noDataPoints"))) + setCombo(noDataPoints_, o.value(QStringLiteral("noDataPoints")).toInt()); + if (o.contains(QStringLiteral("number"))) + filterTimes_->setValue(o.value(QStringLiteral("number")).toInt()); +} + +void FilterDialog::resizeMatrix() { + const int r = rows_->value(); + const int c = cols_->value(); + // 保留重叠区旧值(对照原版 updateFilterMatrix 保留 + 补 0)。 + std::vector> old = readMatrix(); + matrix_->setRowCount(r); + matrix_->setColumnCount(c); + for (int i = 0; i < r; ++i) + for (int j = 0; j < c; ++j) { + auto* it = matrix_->item(i, j); + if (!it) { + it = new QTableWidgetItem(); + matrix_->setItem(i, j, it); + } + const double v = (i < static_cast(old.size()) && + j < static_cast(old[i].size())) + ? old[i][j] + : 0.0; + it->setText(QString::number(v)); + } +} + +std::vector> FilterDialog::readMatrix() const { + std::vector> out; + for (int i = 0; i < matrix_->rowCount(); ++i) { + std::vector row; + for (int j = 0; j < matrix_->columnCount(); ++j) row.push_back(cellValue(matrix_->item(i, j))); + out.push_back(std::move(row)); + } + return out; +} + +QString FilterDialog::selectedFilterName() const { + auto* item = tree_->currentItem(); + return item ? item->text(0) : QString(); +} + +QString FilterDialog::customGroupParentId() const { + // 找名为「自定义滤波器」的分组节点 id(newFilter parentId)。 + for (const auto& o : flatItems_) + if (o.value(QStringLiteral("name")).toString() == QLatin1String(kCustomGroupName)) + return o.value(QStringLiteral("id")).toString(); + return QString(); +} + +void FilterDialog::saveCustomFilter() { + if (!repo_) return; + const QString parentId = customGroupParentId(); + if (parentId.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("未找到自定义滤波器分组")); + return; + } + bool ok = false; + const QString name = QInputDialog::getText(this, QStringLiteral("另存为新自定义滤波器"), + QStringLiteral("名称:"), QLineEdit::Normal, + QStringLiteral("自定义滤波器1"), &ok); + if (!ok || name.trimmed().isEmpty()) return; + QPointer self(this); + repo_->newFilter(buildNewFilterBody(name.trimmed(), projectId_, parentId, readMatrix()), + [self](bool ok2, QString msg) { + if (!self) return; + if (ok2) + self->loadFilters(); // 刷新树 + else + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("保存失败") : msg); + }); +} + +void FilterDialog::deleteSelectedFilter() { + if (!repo_) return; + auto* item = tree_->currentItem(); + if (!item) return; + const QString id = item->data(0, Qt::UserRole).toString(); + if (id.isEmpty() || id == QLatin1String(kDefaultCustomKey)) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("该滤波器不可删除")); + return; + } + if (QMessageBox::question(this, windowTitle(), QStringLiteral("确认删除该滤波器?")) != + QMessageBox::Yes) + return; + QPointer self(this); + repo_->deleteFilter(id, [self](bool ok, QString msg) { + if (!self) return; + if (ok) + self->loadFilters(); + else + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("删除失败") : msg); + }); +} + +void FilterDialog::onConfirm() { + if (!repo_) { reject(); return; } + FilterApplyParams p; + p.dsObjectId = dsId_; + p.dataEdge = dataEdge_->currentData().toString(); + p.dataEdgeValue = dataEdgeValue_->text().toDouble(); + p.noDataPoints = noDataPoints_->currentData().toString(); + p.noDataValue = noDataValue_->text().toDouble(); + p.number = filterTimes_->value(); + p.row = rows_->value(); + p.column = cols_->value(); + p.matrix = readMatrix(); + p.filteringMethod = selectedFilterName(); + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->applyFilter(buildFilterApplyBody(p), [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("滤波处理失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/FilterDialog.hpp b/src/app/panels/chart/FilterDialog.hpp new file mode 100644 index 0000000..9eb3e2c --- /dev/null +++ b/src/app/panels/chart/FilterDialog.hpp @@ -0,0 +1,64 @@ +#pragma once +#include + +#include +#include +#include + +class QComboBox; +class QDoubleSpinBox; +class QSpinBox; +class QPushButton; +class QTreeWidget; +class QTreeWidgetItem; +class QTableWidget; +class QLineEdit; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 滤波处理对话框(I4,1:1 复刻原版 FilterDialog): +// 左:滤波器树(listFilters,按 parentId 建树,叶节点可选)+ 自定义滤波器增删。 +// 右:数据边缘 / 无数据点 / 滤波次数 / 矩阵行列 + 矩阵编辑表。 +// 确认 → applyFilter,成功 accept(),调用方随后重载网格重绘。 +class FilterDialog : public QDialog { + Q_OBJECT +public: + FilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, QString projectId, + QWidget* parent = nullptr); + +private: + void loadFilters(); // 拉滤波器树 + void buildTree(); // 由 flatItems_ 建树 + void onTreeSelectionChanged(); // 选中叶节点 → 右侧回填 + void resizeMatrix(); // 行/列变更 → 重建矩阵表(保留重叠值) + std::vector> readMatrix() const; // 读当前矩阵表 + void saveCustomFilter(); // 另存为新自定义滤波器(newFilter) + void deleteSelectedFilter(); // 删除选中自定义滤波器(deleteFilter) + void onConfirm(); // 应用 → applyFilter + QString selectedFilterName() const; // 选中节点名(filteringMethod 字段) + QString customGroupParentId() const; // 「自定义滤波器」分组节点 id(newFilter parentId) + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + QString projectId_; + + QTreeWidget* tree_ = nullptr; + std::vector flatItems_; // listFilters 原始扁平项(建树用) + + QComboBox* dataEdge_ = nullptr; + QLineEdit* dataEdgeValue_ = nullptr; + QComboBox* noDataPoints_ = nullptr; + QLineEdit* noDataValue_ = nullptr; + QSpinBox* filterTimes_ = nullptr; + QSpinBox* rows_ = nullptr; + QSpinBox* cols_ = nullptr; + QTableWidget* matrix_ = nullptr; + + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index 0a56446..bce2b05 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -3,11 +3,18 @@ #include #include +#include #include +#include +#include +#include +#include #include #include #include +#include #include +#include #include #include @@ -23,10 +30,19 @@ #include "panels/AnomalyTablePanel.hpp" #include "panels/DescriptionPanel.hpp" #include "panels/chart/ChartTheme.hpp" +#include "panels/chart/AutoAnnotationDialog.hpp" #include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorMapService.hpp" +#include "panels/chart/ContourHoverTip.hpp" #include "panels/chart/ContourPlotItem.hpp" +#include "panels/chart/ExceptionDetailDialog.hpp" +#include "panels/chart/ExceptionDialog.hpp" +#include "panels/chart/FilterDialog.hpp" +#include "panels/chart/GridWizardDialog.hpp" #include "panels/chart/LivePanner.hpp" +#include "panels/chart/SaveAsDialog.hpp" +#include "panels/chart/WhiteningDialog.hpp" +#include "repo/IDatasetCommandRepository.hpp" namespace geopro::app { @@ -65,16 +81,23 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { auto* lblSimplify = new QLabel(QStringLiteral("简化容差:"), toolbar); + // 简化容差:对照原版 a-slider min0 max2 step0.1,默认 0.5。客户端用整数滑块 0~20 映射 /10。 simplifySlider_ = new QSlider(Qt::Horizontal, toolbar); - simplifySlider_->setRange(0, 100); - simplifySlider_->setValue(50); + simplifySlider_->setRange(0, 20); + simplifySlider_->setValue(5); // 0.5 simplifySlider_->setFixedWidth(80); simplifyValueLabel_ = new QLabel(QStringLiteral("0.5"), toolbar); simplifyValueLabel_->setFixedWidth(28); + // I8 防抖(~300ms,对照原版 300ms 节流):滑动只改标签,停下后真重算等值线简化。 + simplifyDebounce_ = new QTimer(this); + simplifyDebounce_->setSingleShot(true); + simplifyDebounce_->setInterval(300); + connect(simplifyDebounce_, &QTimer::timeout, this, [this]() { applySimplify(); }); connect(simplifySlider_, &QSlider::valueChanged, this, [this](int v) { - simplifyValueLabel_->setText(QString::number(v / 100.0, 'f', 1)); + simplifyValueLabel_->setText(QString::number(v / 10.0, 'f', 1)); + simplifyDebounce_->start(); }); auto* btnAnomalyLabel = new QToolButton(toolbar); @@ -174,6 +197,33 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { connect(btnColorScale, &QToolButton::clicked, this, [this]() { openColorScaleEditor(); }); + // 处理类按钮(网格化/白化/滤波/另存为):成功后重载网格重绘。 + connect(btnGrid, &QToolButton::clicked, this, [this]() { openGridWizard(); }); + connect(btnWhiten, &QToolButton::clicked, this, [this]() { openWhitening(); }); + connect(btnFilter, &QToolButton::clicked, this, [this]() { openFilter(); }); + connect(btnSaveAs, &QToolButton::clicked, this, [this]() { openSaveAs(); }); + + // 异常标注 / 自动标注(I9 / I13)。 + connect(btnAnomalyLabel, &QToolButton::clicked, this, [this]() { openExceptionDialog(); }); + connect(btnAutoLabel, &QToolButton::clicked, this, [this]() { openAutoAnnotation(); }); + + // 异常表操作列 → 删除/详情/定位(I10 / I11 / I12)。 + connect(anomalyTable_, &AnomalyTablePanel::deleteRequested, this, + [this](int i) { deleteAnomaly(i); }); + connect(anomalyTable_, &AnomalyTablePanel::detailRequested, this, + [this](int i) { showAnomalyDetail(i); }); + connect(anomalyTable_, &AnomalyTablePanel::locateRequested, this, + [this](int i) { locateAnomaly(i); }); + + // 描述保存(I14)。 + connect(descriptionPanel_, &DescriptionPanel::saveRequested, this, + [this](const QString& t) { saveDescription(t); }); + + // I7 显示等值线提示信息:hover tooltip 显隐(本地,挂画布事件过滤器)。 + contourTip_ = new ContourHoverTip(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); + connect(chkContourTip, &QCheckBox::toggled, this, + [this](bool on) { if (contourTip_) contourTip_->setEnabled(on); }); + // 主题配色:当前主题套一次 + 监听切换热更新。 applyChartPlotTheme(plot_); QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, @@ -230,6 +280,9 @@ void GridDataChartView::rebuildContour() { contourItem_->setLineColor(lineCfg_.lineColor); // 线形⚙ 配置 contourItem_->setLineDashed(lineCfg_.dashed); contourItem_->setLabelColor(lineCfg_.labelColor); + // I8 应用当前简化容差;I7 把等值线提示绑定到新建项。 + if (simplifySlider_) contourItem_->setSimplifyTolerance(simplifySlider_->value() / 10.0); + if (contourTip_) contourTip_->setItem(contourItem_); contourItem_->attach(plot_); // 轴范围 = 数据范围(x=距离、y=深度/高程)。 @@ -274,4 +327,186 @@ void GridDataChartView::openColorScaleEditor() { colorBar_->setColorScale(gridScale_); } +void GridDataChartView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter) { + cmdRepo_ = repo; + dsIdGetter_ = std::move(dsIdGetter); + // projectId 取值回调与色阶编辑器共用(色阶注入可能先到,二者一致)。 + if (projectIdGetter) projectIdGetter_ = std::move(projectIdGetter); + + // I14:有仓储 + dsId 时启用描述保存并回填一次。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (descriptionPanel_) descriptionPanel_->setSaveEnabled(cmdRepo_ && !dsId.isEmpty()); + loadDescription(); +} + +void GridDataChartView::showNotImplemented(QWidget* anchor) { + const QPoint pos = anchor ? anchor->mapToGlobal(QPoint(0, anchor->height())) : QCursor::pos(); + QToolTip::showText(pos, QStringLiteral("暂未实现"), anchor); +} + +void GridDataChartView::reloadGrid() { + // 处理类操作成功后:只读重载网格 → setGridData 重绘(与 measurement V 值重载同范式)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) return; + QPointer self(this); + cmdRepo_->loadInversionGrid( + dsId, [self](bool ok, geopro::core::ContourPayload p, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("提示"), + msg.isEmpty() ? QStringLiteral("重载失败") : msg); + return; + } + self->setGridData(p.grid, p.scale, p.anomalies); + }); +} + +void GridDataChartView::openGridWizard() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + GridWizardDialog dlg(cmdRepo_, dsId, this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::openWhitening() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + // tmObjectId(白化模板列表用)客户端视图未透传 structParentId,按原版兜底空串。 + WhiteningDialog dlg(cmdRepo_, dsId, projectId, QString(), this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::openFilter() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + FilterDialog dlg(cmdRepo_, dsId, projectId, this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::openSaveAs() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + SaveAsDialog dlg(SaveAsDialog::Mode::Inversion, cmdRepo_, dsId, this); + dlg.exec(); // 另存为新数据,不改当前视图(与原版一致)。 +} + +void GridDataChartView::applySimplify() { + if (!contourItem_ || !simplifySlider_) return; + contourItem_->setSimplifyTolerance(simplifySlider_->value() / 10.0); + plot_->replot(); +} + +// ============================ 异常标注 / 描述(I9~I14)============================ + +void GridDataChartView::openExceptionDialog() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + // remarkSourceId = dsObjectId(异常挂当前等值面数据集)。 + ExceptionDialog dlg(cmdRepo_, projectId, dsId, this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::openAutoAnnotation() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + AutoAnnotationDialog dlg(cmdRepo_, dsId, projectId, this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::deleteAnomaly(int index) { + if (!cmdRepo_ || index < 0 || index >= static_cast(anoms_.size())) return; + const QString id = QString::fromStdString(anoms_[index].id); + if (id.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("提示"), QStringLiteral("该异常无 id,无法删除")); + return; + } + QPointer self(this); + cmdRepo_->deleteException(id, [self](bool ok, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("提示"), + msg.isEmpty() ? QStringLiteral("删除失败") : msg); + return; + } + self->reloadGrid(); // 成功后重载(列表 + 图层同步) + }); +} + +void GridDataChartView::showAnomalyDetail(int index) { + if (!cmdRepo_ || index < 0 || index >= static_cast(anoms_.size())) return; + ExceptionDetailDialog dlg(cmdRepo_, anoms_[index], this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::locateAnomaly(int index) { + if (!contourItem_ || index < 0 || index >= static_cast(anoms_.size())) return; + contourItem_->setHighlightedAnomaly(index); + // 缩放到该异常包围盒(含 10% padding,对照原版 padding 0.1)。 + QRectF box = contourItem_->anomalyBoundingRect(index); + if (!box.isNull()) { + const double padX = box.width() * 0.1 + (box.width() <= 0 ? 1.0 : 0.0); + const double padY = box.height() * 0.1 + (box.height() <= 0 ? 1.0 : 0.0); + plot_->setAxisScale(QwtPlot::xBottom, box.left() - padX, box.right() + padX); + plot_->setAxisScale(QwtPlot::yLeft, box.top() - padY, box.bottom() + padY); + if (rescaler_) rescaler_->rescale(); + } + plot_->replot(); + // 1s 后清除高亮(对照原版 highlight duration 1000ms)。 + QPointer self(this); + QTimer::singleShot(1000, this, [self]() { + if (!self || !self->contourItem_) return; + self->contourItem_->setHighlightedAnomaly(-1); + self->plot_->replot(); + }); +} + +void GridDataChartView::loadDescription() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty() || !descriptionPanel_) return; + QPointer self(this); + cmdRepo_->getDsObjectDetail(dsId, [self](bool ok, QJsonObject data, QString) { + if (!self || !ok) return; + // 原版从 attachedParameters.deltaContent 取 Quill Delta;Qt 退化为纯文本: + // 优先 description 字段,否则拼接 delta ops 的 insert 文本。 + QString text = data.value(QStringLiteral("description")).toString(); + if (text.isEmpty()) { + const QJsonArray ops = data.value(QStringLiteral("attachedParameters")) + .toObject() + .value(QStringLiteral("deltaContent")) + .toArray(); + for (const QJsonValue& op : ops) + text += op.toObject().value(QStringLiteral("insert")).toString(); + } + self->descriptionPanel_->setText(text); + }); +} + +void GridDataChartView::saveDescription(const QString& text) { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + // attachedParameters.deltaContent:以最简单 op 包纯文本(reload 时可还原为纯文本)。 + QJsonArray ops; + if (!text.isEmpty()) ops.append(QJsonObject{{QStringLiteral("insert"), text}}); + QJsonObject body{ + {QStringLiteral("dsObjectId"), dsId}, + {QStringLiteral("description"), text}, + {QStringLiteral("attachedParameters"), + QJsonObject{{QStringLiteral("deltaContent"), ops}}}, + }; + QPointer self(this); + cmdRepo_->updateDsObject(body, [self](bool ok, QString msg) { + if (!self) return; + QMessageBox::information( + self, QStringLiteral("提示"), + ok ? QStringLiteral("描述已保存") + : (msg.isEmpty() ? QStringLiteral("保存失败") : msg)); + }); +} + } // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp index 177a917..77f1e9c 100644 --- a/src/app/panels/chart/GridDataChartView.hpp +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -15,11 +15,13 @@ class QSlider; class QLabel; class QCheckBox; +class QTimer; class QwtPlot; class QwtPlotRescaler; namespace geopro::data { class IColorTemplateRepository; +class IDatasetCommandRepository; } namespace geopro::app { @@ -29,6 +31,7 @@ class DescriptionPanel; class ColorBarWidget; class ColorMapService; class ContourPlotItem; +class ContourHoverTip; // 网格数据图表视图:工具条 + QwtPlot(白底 + 真实比尺 + 实时平移/滚轮缩放,x 轴在底部) // + 独立色阶条 + 底部双页签(异常列表/描述)。 @@ -52,9 +55,30 @@ public: void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, std::function projectIdGetter); + // 注入反演命令仓储 + dsId/projectId 取值回调(网格化/白化/滤波/另存为按钮)。 + // 可传空仓储 → 这些按钮退化为「暂未实现」占位提示。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter); + private: void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙) + void openGridWizard(); // I1「网格」→ 网格化向导 + void openWhitening(); // I3「白化」→ 白化弹窗 + void openFilter(); // I4「滤波处理」→ 滤波弹窗 + void openSaveAs(); // I15「另存为」→ 复用 SaveAsDialog(Inversion) + void reloadGrid(); // 处理类成功后:loadInversionGrid → setGridData 重绘 + void applySimplify(); // I8:把当前滑块容差透传给 ContourPlotItem 并重绘 + void showNotImplemented(QWidget* anchor); // 占位提示(无仓储/无 dsId) + + void openExceptionDialog(); // I9 异常创建 + void openAutoAnnotation(); // I13 自动标注 + void deleteAnomaly(int index); // I10 异常删除 + void showAnomalyDetail(int index); // I11 异常详情/编辑 + void locateAnomaly(int index); // I12 异常定位(高亮 + 缩放) + void loadDescription(); // I14 进入时回填描述 + void saveDescription(const QString& text); // I14 保存描述 QwtPlot* plot_ = nullptr; QwtPlotRescaler* rescaler_ = nullptr; @@ -64,6 +88,8 @@ private: QSlider* simplifySlider_ = nullptr; QLabel* simplifyValueLabel_ = nullptr; QCheckBox* chkShowLabels_ = nullptr; // 工具条「显示等值线标注」(线形⚙ 改标注显隐后同步) + QTimer* simplifyDebounce_ = nullptr; // I8 简化容差防抖(~300ms) + ContourHoverTip* contourTip_ = nullptr; // I7 等值线提示(hover) // 渲染状态 ColorMapService* colorSvc_ = nullptr; // heap,setGridData 重建 @@ -81,6 +107,10 @@ private: // 色阶模板仓储 + projectId 取值回调(注入;空则编辑器后端按钮禁用)。 geopro::data::IColorTemplateRepository* tplRepo_ = nullptr; std::function projectIdGetter_; + + // 反演命令仓储 + dsId 取值回调(注入;空则处理类按钮占位提示)。 + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; + std::function dsIdGetter_; }; } // namespace geopro::app diff --git a/src/app/panels/chart/GridWizardDialog.cpp b/src/app/panels/chart/GridWizardDialog.cpp new file mode 100644 index 0000000..2ba01fc --- /dev/null +++ b/src/app/panels/chart/GridWizardDialog.cpp @@ -0,0 +1,217 @@ +#include "panels/chart/GridWizardDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "panels/chart/InversionProcessOps.hpp" // buildGridToBody +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr double kCoordRange = 1e9; // 坐标范围(足够宽) +constexpr int kSizeMin = 1, kSizeMax = 300; // 点数范围(对照原版 1~300) +constexpr int kDefaultXSize = 100; // 默认点数(原版 xPoints 默认 100) + +// 配一个坐标 spinbox(6 位小数,宽范围)。 +QDoubleSpinBox* makeCoordSpin(QWidget* parent) { + auto* sp = new QDoubleSpinBox(parent); + sp->setRange(-kCoordRange, kCoordRange); + sp->setDecimals(6); + return sp; +} +} // namespace + +GridWizardDialog::GridWizardDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, + QWidget* parent) + : QDialog(parent), repo_(repo), dsId_(std::move(dsId)) { + setWindowTitle(QStringLiteral("网格化")); + setModal(true); + resize(560, 420); + + auto* root = formkit::dialogRoot(this); + stack_ = new QStackedWidget(this); + root->addWidget(stack_, 1); + + // ── 步骤 1:算法选择(单选列表)────────────────────────────────────── + auto* page1 = new QWidget(this); + auto* p1Lay = new QVBoxLayout(page1); + p1Lay->addWidget(new QLabel(QStringLiteral("请选择网格方法:"), page1)); + algoList_ = new QListWidget(page1); + p1Lay->addWidget(algoList_, 1); + stack_->addWidget(page1); + + // ── 步骤 2:网格参数 + 数据值设置 ──────────────────────────────────── + auto* page2 = new QWidget(this); + auto* p2Lay = new QVBoxLayout(page2); + xMin_ = makeCoordSpin(page2); xMax_ = makeCoordSpin(page2); + yMin_ = makeCoordSpin(page2); yMax_ = makeCoordSpin(page2); + vMin_ = makeCoordSpin(page2); vMax_ = makeCoordSpin(page2); + xSize_ = new QSpinBox(page2); xSize_->setRange(kSizeMin, kSizeMax); xSize_->setValue(kDefaultXSize); + ySize_ = new QSpinBox(page2); ySize_->setRange(kSizeMin, kSizeMax); ySize_->setValue(kDefaultXSize); + xSpacing_ = makeCoordSpin(page2); xSpacing_->setRange(0.0, kCoordRange); + ySpacing_ = makeCoordSpin(page2); ySpacing_->setRange(0.0, kCoordRange); + saveFormat_ = new QComboBox(page2); + saveFormat_->addItem(QStringLiteral("线性"), QStringLiteral("linear")); + saveFormat_->addItem(QStringLiteral("对数"), QStringLiteral("log")); + + formkit::capField(xMin_); + formkit::capField(xMax_); + formkit::capField(xSpacing_); + formkit::capField(yMin_); + formkit::capField(yMax_); + formkit::capField(ySpacing_); + formkit::capField(vMin_); + formkit::capField(vMax_); + formkit::capField(xSize_); + formkit::capField(ySize_); + formkit::capField(saveFormat_); + + auto* form = formkit::makeEditForm(); + form->addRow(formkit::editLabel(QStringLiteral("Xmin:")), xMin_); + form->addRow(formkit::editLabel(QStringLiteral("Xmax:")), xMax_); + form->addRow(formkit::editLabel(QStringLiteral("X点数:")), xSize_); + form->addRow(formkit::editLabel(QStringLiteral("X间距:")), xSpacing_); + form->addRow(formkit::editLabel(QStringLiteral("Ymin:")), yMin_); + form->addRow(formkit::editLabel(QStringLiteral("Ymax:")), yMax_); + form->addRow(formkit::editLabel(QStringLiteral("Y点数:")), ySize_); + form->addRow(formkit::editLabel(QStringLiteral("Y间距:")), ySpacing_); + form->addRow(formkit::editLabel(QStringLiteral("数据值min:")), vMin_); + form->addRow(formkit::editLabel(QStringLiteral("数据值max:")), vMax_); + form->addRow(formkit::editLabel(QStringLiteral("保存格式:")), saveFormat_); + p2Lay->addLayout(form); + stack_->addWidget(page2); + + // ── 底部按钮(上一步 / 下一步 / 确认 / 取消)──────────────────────── + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + prevBtn_ = new QPushButton(QStringLiteral("上一步"), this); + nextBtn_ = new QPushButton(QStringLiteral("下一步"), this); + okBtn_ = new QPushButton(QStringLiteral("确认"), this); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + btnLay->addWidget(prevBtn_); + btnLay->addWidget(nextBtn_); + btnLay->addWidget(okBtn_); + btnLay->addWidget(cancelBtn); + root->addLayout(btnLay); + prevBtn_->setVisible(false); + okBtn_->setVisible(false); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(nextBtn_, &QPushButton::clicked, this, &GridWizardDialog::goToStep2); + connect(prevBtn_, &QPushButton::clicked, this, [this]() { + stack_->setCurrentIndex(0); + prevBtn_->setVisible(false); okBtn_->setVisible(false); nextBtn_->setVisible(true); + }); + connect(okBtn_, &QPushButton::clicked, this, &GridWizardDialog::onConfirm); + // 点数变化 → 重算间距(原版 calculateXInterval/calculateYInterval)。 + connect(xSize_, QOverload::of(&QSpinBox::valueChanged), this, + [this](int) { recalcXSpacing(); }); + connect(ySize_, QOverload::of(&QSpinBox::valueChanged), this, + [this](int) { recalcYSpacing(); }); + + loadAlgorithms(); +} + +void GridWizardDialog::loadAlgorithms() { + if (!repo_) return; + QPointer self(this); + repo_->listGridAlgorithm(dsId_, [self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->algoList_->clear(); + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + const QString name = o.value(QStringLiteral("scriptName")).toString(); + const QString code = o.value(QStringLiteral("scriptCode")).toString(); + auto* item = new QListWidgetItem(name, self->algoList_); + item->setData(Qt::UserRole, code); + } + if (self->algoList_->count() > 0) self->algoList_->setCurrentRow(0); // 默认首项 + }); +} + +void GridWizardDialog::goToStep2() { + if (!algoList_->currentItem()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择网格方法")); + return; + } + stack_->setCurrentIndex(1); + nextBtn_->setVisible(false); + prevBtn_->setVisible(true); + okBtn_->setVisible(true); + loadParams(); +} + +void GridWizardDialog::loadParams() { + if (!repo_) return; + QPointer self(this); + repo_->getGridRawDataParams(dsId_, [self](bool ok, QJsonObject d, QString) { + if (!self || !ok) return; + self->xMin_->setValue(d.value(QStringLiteral("xmin")).toDouble()); + self->xMax_->setValue(d.value(QStringLiteral("xmax")).toDouble()); + self->yMin_->setValue(d.value(QStringLiteral("ymin")).toDouble()); + self->yMax_->setValue(d.value(QStringLiteral("ymax")).toDouble()); + self->vMin_->setValue(d.value(QStringLiteral("vmin")).toDouble()); + self->vMax_->setValue(d.value(QStringLiteral("vmax")).toDouble()); + // 初始间距 = (xmax-xmin)/xSize,y 间距同 x(原版 onMounted 逻辑)。 + self->recalcXSpacing(); + const double dx = self->xSpacing_->value(); + if (dx > 0) self->ySpacing_->setValue(dx); + }); +} + +void GridWizardDialog::recalcXSpacing() { + const int n = xSize_->value(); + if (n > 0) xSpacing_->setValue((xMax_->value() - xMin_->value()) / n); +} + +void GridWizardDialog::recalcYSpacing() { + const int n = ySize_->value(); + if (n > 0) ySpacing_->setValue((yMax_->value() - yMin_->value()) / n); +} + +void GridWizardDialog::onConfirm() { + if (!repo_ || !algoList_->currentItem()) { reject(); return; } + if (xMax_->value() < xMin_->value() || yMax_->value() < yMin_->value() || + vMax_->value() < vMin_->value()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("最大值不能小于最小值")); + return; + } + GridToParams p; + p.dsObjectId = dsId_; + p.actionCode = algoList_->currentItem()->data(Qt::UserRole).toString(); + p.xMin = xMin_->value(); p.xMax = xMax_->value(); + p.yMin = yMin_->value(); p.yMax = yMax_->value(); + p.vMin = vMin_->value(); p.vMax = vMax_->value(); + p.xSize = xSize_->value(); p.ySize = ySize_->value(); + p.xSpacing = xSpacing_->value(); p.ySpacing = ySpacing_->value(); + p.logFormat = (saveFormat_->currentData().toString() == QStringLiteral("log")); + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->toGrid(buildGridToBody(p), [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("网格化失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/GridWizardDialog.hpp b/src/app/panels/chart/GridWizardDialog.hpp new file mode 100644 index 0000000..6d27991 --- /dev/null +++ b/src/app/panels/chart/GridWizardDialog.hpp @@ -0,0 +1,60 @@ +#pragma once +#include +#include + +class QComboBox; +class QDoubleSpinBox; +class QSpinBox; +class QPushButton; +class QStackedWidget; +class QListWidget; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 网格化向导(2 步,1:1 复刻原版 GridDialog): +// 步骤 1:listGridAlgorithm 选算法(单选列表,scriptName 显示 / scriptCode 提交)。 +// 步骤 2:getGridRawDataParams 取 x/y/v min/max 默认 + 点数/间距/保存格式(线性|对数), +// 确认 → toGrid。成功 accept(),调用方(视图)随后重载网格重绘。 +// I1(网格视图工具条「网格」)与 O1(原数据工具条「网格」)共用本向导。 +class GridWizardDialog : public QDialog { + Q_OBJECT +public: + GridWizardDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, + QWidget* parent = nullptr); + +private: + void loadAlgorithms(); // 步骤 1:拉算法列表 + void loadParams(); // 步骤 2:拉 x/y/v 默认参数 + void goToStep2(); // 下一步(校验算法已选) + void recalcXSpacing(); // (xMax-xMin)/xSize → xSpacing(间距已有值时) + void recalcYSpacing(); // (yMax-yMin)/ySize → ySpacing + void onConfirm(); // 确认 → toGrid + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + + QStackedWidget* stack_ = nullptr; + QListWidget* algoList_ = nullptr; // 算法单选列表(userData=scriptCode) + + QDoubleSpinBox* xMin_ = nullptr; + QDoubleSpinBox* xMax_ = nullptr; + QDoubleSpinBox* yMin_ = nullptr; + QDoubleSpinBox* yMax_ = nullptr; + QDoubleSpinBox* vMin_ = nullptr; + QDoubleSpinBox* vMax_ = nullptr; + QSpinBox* xSize_ = nullptr; + QSpinBox* ySize_ = nullptr; + QDoubleSpinBox* xSpacing_ = nullptr; + QDoubleSpinBox* ySpacing_ = nullptr; + QComboBox* saveFormat_ = nullptr; // linear / log + + QPushButton* nextBtn_ = nullptr; + QPushButton* prevBtn_ = nullptr; + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionFormDialog.cpp b/src/app/panels/chart/InversionFormDialog.cpp new file mode 100644 index 0000000..15d3de0 --- /dev/null +++ b/src/app/panels/chart/InversionFormDialog.cpp @@ -0,0 +1,200 @@ +#include "panels/chart/InversionFormDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +// 纯解析/组装函数定义在 InversionFormParse.cpp(Qt-Core-only,便于单测)。 + +namespace { +constexpr char kVisualResistivityCode[] = "script_visual_resistivity_data"; +} // namespace + +InversionFormDialog::InversionFormDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, + QString dsId, QString projectId, QWidget* parent) + : QDialog(parent), + mode_(mode), + repo_(repo), + dsId_(std::move(dsId)), + projectId_(std::move(projectId)) { + setWindowTitle(mode_ == Mode::Inversion ? QStringLiteral("反演运算") + : QStringLiteral("反演参数设置")); + setModal(true); + resize(480, 360); + + auto* root = formkit::dialogRoot(this); + + // 模型选择行(label + 下拉)。生成视电阻率下拉禁用(复刻原版 disabled)。 + auto* modelLay = new QVBoxLayout(); + modelLay->addWidget(formkit::editLabel(QStringLiteral("反演模型"), this)); + modelCombo_ = new QComboBox(this); + if (mode_ == Mode::ApparentResistivity) modelCombo_->setEnabled(false); + modelLay->addWidget(modelCombo_); + root->addLayout(modelLay); + + // 动态字段容器(按 groups_ 重建)。 + formHost_ = new QWidget(this); + formHostLay_ = new QVBoxLayout(formHost_); + formHostLay_->setContentsMargins(0, 0, 0, 0); + root->addWidget(formHost_, 1); + + // 底部按钮(取消 / 确定)。 + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + okBtn_ = new QPushButton(QStringLiteral("确定"), this); + okBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn_, &QPushButton::clicked, this, &InversionFormDialog::onConfirm); + // 反演运算下拉可切换模型(allow-clear 在 Qt 以可空首项体现);生成视电阻率禁用故不触发。 + connect(modelCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { onModelChanged(); }); + + loadScripts(); +} + +void InversionFormDialog::loadScripts() { + if (!repo_) return; + QPointer self(this); + const Mode mode = mode_; + repo_->listInversionScripts(dsId_, [self, mode](bool ok, QJsonArray list, QString) { + if (!self) return; + if (!ok) return; + QSignalBlocker block(self->modelCombo_); // 填充期不触发 onModelChanged + self->modelCombo_->clear(); + // 反演运算:首项为空(对应原版 allow-clear,无默认选中)。 + if (mode == Mode::Inversion) self->modelCombo_->addItem(QString(), QString()); + int defaultIdx = -1; + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + const QString label = o.value(QStringLiteral("label")).toString(); + const QString value = o.value(QStringLiteral("value")).toString(); + const QString code = o.value(QStringLiteral("code")).toString(); + self->modelCombo_->addItem(label, value); + if (mode == Mode::ApparentResistivity && + code == QLatin1String(kVisualResistivityCode)) { + defaultIdx = self->modelCombo_->count() - 1; // 默认选中视电阻率(复刻原版) + } + } + if (mode == Mode::ApparentResistivity) { + // 默认选中视电阻率项(未命中则退首项);setCurrentIndex 触发 onModelChanged 拉表单。 + self->modelCombo_->setCurrentIndex(defaultIdx >= 0 ? defaultIdx : 0); + self->onModelChanged(); + } + }); +} + +void InversionFormDialog::onModelChanged() { + const QString typeId = modelCombo_->currentData().toString(); + groups_.clear(); + if (typeId.isEmpty()) { + rebuildFormArea(); // 清空表单(复刻 changeModel: 清空 dynamicForms) + return; + } + loadDynamicForm(typeId); +} + +void InversionFormDialog::loadDynamicForm(const QString& typeId) { + if (!repo_) return; + QPointer self(this); + repo_->getDynamicForm(projectId_, typeId, [self](bool ok, QJsonObject data, QString) { + if (!self) return; + if (!ok) return; + self->groups_ = parseDynamicForm(data); + self->rebuildFormArea(); + }); +} + +void InversionFormDialog::rebuildFormArea() { + // 清空旧字段控件(含布局子项),重置取值索引。 + fieldCombos_.clear(); + fieldCodes_.clear(); + QLayoutItem* item = nullptr; + while ((item = formHostLay_->takeAt(0)) != nullptr) { + if (item->widget()) item->widget()->deleteLater(); + delete item; + } + + const bool fillDefaults = (mode_ == Mode::ApparentResistivity); + for (const auto& g : groups_) { + // 分组卡片:标题 + 字段两列网格(复刻原版 a-card 分组 + 半宽字段)。 + auto* card = new QFrame(formHost_); + card->setObjectName(QStringLiteral("inversionGroupCard")); + auto* cardLay = new QVBoxLayout(card); + if (!g.groupName.isEmpty()) + formkit::addSection(cardLay, g.groupName, card, /*topGap=*/false); + auto* grid = new QGridLayout(); + cardLay->addLayout(grid); + int col = 0, gridRow = 0; + for (const auto& f : g.fields) { + auto* fieldBox = new QVBoxLayout(); + fieldBox->addWidget(formkit::editLabel(f.fieldName, card)); + auto* combo = new QComboBox(card); + for (const auto& o : f.options) combo->addItem(o.label, o.value); + // 生成视电阻率:默认选首项(复刻 initDynamicFieldsDefaultValues)。 + if (fillDefaults && combo->count() > 0) combo->setCurrentIndex(0); + fieldBox->addWidget(combo); + grid->addLayout(fieldBox, gridRow, col); + fieldCombos_.push_back(combo); + fieldCodes_.push_back(f.fieldCode); + if (++col >= 2) { col = 0; ++gridRow; } + } + formHostLay_->addWidget(card); + } + formHostLay_->addStretch(); +} + +void InversionFormDialog::onConfirm() { + if (!repo_) { reject(); return; } + const QString scriptId = modelCombo_->currentData().toString(); + if (scriptId.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择反演模型")); + return; + } + + // 由当前各字段下拉选值装配 {fieldCode: value}。 + QJsonObject selected; + for (size_t i = 0; i < fieldCombos_.size(); ++i) { + selected.insert(fieldCodes_[static_cast(i)], + fieldCombos_[i]->currentData().toString()); + } + const bool fillDefaults = (mode_ == Mode::ApparentResistivity); + const QJsonObject fields = assembleFieldMap(groups_, selected, fillDefaults); + + okBtn_->setEnabled(false); + QPointer self(this); + auto onDone = [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("反演任务失败") : msg); + } + }; + if (mode_ == Mode::Inversion) { + repo_->submitInversionTask(dsId_, scriptId, fields, onDone); + } else { + repo_->createVisualResistivityData(dsId_, scriptId, fields, onDone); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionFormDialog.hpp b/src/app/panels/chart/InversionFormDialog.hpp new file mode 100644 index 0000000..fb01b92 --- /dev/null +++ b/src/app/panels/chart/InversionFormDialog.hpp @@ -0,0 +1,63 @@ +#pragma once +#include + +#include +#include +#include + +#include "panels/chart/InversionFormParse.hpp" // InversionGroup + 纯解析/组装函数 + +class QComboBox; +class QVBoxLayout; +class QPushButton; +class QWidget; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 反演动态表单对话框(1:1 复刻原版 web)。一套对话框服务两个入口,用 Mode 区分: +// - Inversion → measurement「反演运算」(原版 InversionForm.vue + postInversionTask) +// - ApparentResistivity → measurement「生成视电阻率」(原版 InversionDialog.vue + createVisualResistivityData) +// 共同流程:① 拉模型列表 → ② 选模型 → ③ 按 typeId 拉动态表单 → ④ 分组卡片渲染字段 → ⑤ 提交。 +// 差异(严格对照原版): +// Inversion:模型下拉可选(allow-clear)、无默认选中、无字段默认值;提交体 {dsId,scriptId,properties}。 +// ApparentResistivity:模型下拉禁用、默认选中 code=='script_visual_resistivity_data'、 +// 字段默认取首个选项;提交体 {dsObjectId,scriptId,scriptParamListJsonStr}。 +// 回调用 QPointer 守卫(对话框 modal exec,但异步回调仍可能在关闭后到达)。 +class InversionFormDialog : public QDialog { + Q_OBJECT +public: + enum class Mode { + Inversion, // 反演运算 + ApparentResistivity, // 生成视电阻率 + }; + + InversionFormDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, + QString dsId, QString projectId, QWidget* parent = nullptr); + +private: + void loadScripts(); // 拉模型列表填下拉 + void onModelChanged(); // 模型变更 → 拉动态表单 + void loadDynamicForm(const QString& typeId); + void rebuildFormArea(); // 按 groups_ 重建分组卡片 + void onConfirm(); // 提交(按 mode 走不同端点) + + Mode mode_; + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + QString projectId_; + + QComboBox* modelCombo_ = nullptr; + QWidget* formHost_ = nullptr; // 动态字段容器(重建时清空重填) + QVBoxLayout* formHostLay_ = nullptr; + QPushButton* okBtn_ = nullptr; + + std::vector groups_; // 当前模型的动态表单(已解析) + std::vector fieldCombos_; // 与 groups_ 展平后的字段同序(取值用) + std::vector fieldCodes_; // 与 fieldCombos_ 同序的 fieldCode +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionFormParse.cpp b/src/app/panels/chart/InversionFormParse.cpp new file mode 100644 index 0000000..1e7410f --- /dev/null +++ b/src/app/panels/chart/InversionFormParse.cpp @@ -0,0 +1,52 @@ +#include "panels/chart/InversionFormParse.hpp" + +#include + +#include +#include + +namespace geopro::app { + +std::vector parseDynamicForm(const QJsonObject& data) { + std::vector groups; + const QJsonArray formList = data.value(QStringLiteral("formList")).toArray(); + for (const QJsonValue& gv : formList) { + const QJsonObject g = gv.toObject(); + InversionGroup group; + group.groupName = g.value(QStringLiteral("groupName")).toString(); + const QJsonArray values = g.value(QStringLiteral("values")).toArray(); + for (const QJsonValue& fv : values) { + const QJsonObject f = fv.toObject(); + InversionField field; + field.fieldCode = f.value(QStringLiteral("fieldCode")).toString(); + field.fieldName = f.value(QStringLiteral("fieldName")).toString(); + const QJsonArray opts = f.value(QStringLiteral("optionsObject")).toArray(); + for (const QJsonValue& ov : opts) { + const QJsonObject o = ov.toObject(); + field.options.push_back({o.value(QStringLiteral("label")).toString(), + o.value(QStringLiteral("value")).toString()}); + } + group.fields.push_back(std::move(field)); + } + groups.push_back(std::move(group)); + } + return groups; +} + +QJsonObject assembleFieldMap(const std::vector& groups, const QJsonObject& selected, + bool fillDefaults) { + QJsonObject out; + for (const auto& g : groups) { + for (const auto& f : g.fields) { + QString value; + if (selected.contains(f.fieldCode)) value = selected.value(f.fieldCode).toString(); + if (value.isEmpty() && fillDefaults && !f.options.empty()) { + value = f.options.front().value; // 复刻 initDynamicFieldsDefaultValues + } + if (!value.isEmpty()) out.insert(f.fieldCode, value); // 复刻 handleConfirm:空值不进体 + } + } + return out; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionFormParse.hpp b/src/app/panels/chart/InversionFormParse.hpp new file mode 100644 index 0000000..c98c749 --- /dev/null +++ b/src/app/panels/chart/InversionFormParse.hpp @@ -0,0 +1,35 @@ +#pragma once +#include + +#include +#include + +namespace geopro::app { + +// 反演动态表单的数据模型 + 纯解析/组装函数(仅依赖 QtCore JSON,无 Widgets/MOC)。 +// 拆出独立 TU 以便单测(tests 链 geopro_data/Qt6::Core 即可,不必拖入对话框)。 + +// 一个动态表单字段(仅取渲染/提交所需,复刻原版 optionsObject + fieldCode/fieldName)。 +struct InversionFieldOption { + QString label; + QString value; +}; +struct InversionField { + QString fieldCode; + QString fieldName; + std::vector options; // optionsObject(Select 选项) +}; +struct InversionGroup { + QString groupName; + std::vector fields; +}; + +// 解析动态表单响应 data.formList → 分组/字段模型。 +std::vector parseDynamicForm(const QJsonObject& data); + +// 组装提交字段表 {fieldCode: value}。fillDefaults=true 时空选值回退首个选项(生成视电阻率用)。 +// 复刻原版 handleConfirm:仅写入有值的字段(空值不进体)。 +QJsonObject assembleFieldMap(const std::vector& groups, const QJsonObject& selected, + bool fillDefaults); + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionProcessOps.cpp b/src/app/panels/chart/InversionProcessOps.cpp new file mode 100644 index 0000000..523b586 --- /dev/null +++ b/src/app/panels/chart/InversionProcessOps.cpp @@ -0,0 +1,98 @@ +#include "panels/chart/InversionProcessOps.hpp" + +namespace geopro::app { + +QJsonObject buildGridToBody(const GridToParams& p) { + // 字段名/casing 严格对照原版 toGridTheData:xsize/ysize 小写,xSpacing/ySpacing 驼峰。 + return QJsonObject{ + {QStringLiteral("dsObjectId"), p.dsObjectId}, + {QStringLiteral("actionCode"), p.actionCode}, + {QStringLiteral("xminValue"), p.xMin}, + {QStringLiteral("xmaxValue"), p.xMax}, + {QStringLiteral("yminValue"), p.yMin}, + {QStringLiteral("ymaxValue"), p.yMax}, + {QStringLiteral("xsize"), p.xSize}, + {QStringLiteral("xSpacing"), p.xSpacing}, + {QStringLiteral("ysize"), p.ySize}, + {QStringLiteral("ySpacing"), p.ySpacing}, + {QStringLiteral("vminValue"), p.vMin}, + {QStringLiteral("vmaxValue"), p.vMax}, + // saveDataValueType:1=线性 2=对数(原版 saveFormat==='linear'?1:2)。 + {QStringLiteral("saveDataValueType"), p.logFormat ? 2 : 1}, + }; +} + +QJsonObject buildWhitenBody(const WhitenParams& p) { + QJsonObject body{ + {QStringLiteral("dsObjectId"), p.dsObjectId}, + {QStringLiteral("whiteningMethod"), p.whiteningMethod}, + }; + if (p.whiteningMethod == 1) { + body.insert(QStringLiteral("boundaryExtension"), p.boundaryExtension); + // 原版 whitenedWay 为布尔:whiteningType===0 → true(外部白化)。 + body.insert(QStringLiteral("whitenedWay"), p.whiteningType == 0); + } else if (p.whiteningMethod == 2) { + body.insert(QStringLiteral("whitenedDataId"), p.whitenedDataId); + } else if (p.whiteningMethod == 3) { + body.insert(QStringLiteral("modelWhiteningSubType"), p.modelWhiteningSubType); + } + return body; +} + +int filterBoundaryCode(const QString& dataEdge) { + if (dataEdge == QStringLiteral("whitening")) return 1; + if (dataEdge == QStringLiteral("skip")) return 2; + if (dataEdge == QStringLiteral("edgePoint")) return 3; + if (dataEdge == QStringLiteral("filling")) return 4; + return 1; // 默认设为无效点 +} + +int filterNoDataCode(const QString& noDataPoints) { + if (noDataPoints == QStringLiteral("expansion")) return 1; + if (noDataPoints == QStringLiteral("retain")) return 2; + if (noDataPoints == QStringLiteral("skip")) return 3; + if (noDataPoints == QStringLiteral("filling")) return 4; + return 4; // 默认填充 +} + +QJsonArray matrixToJson(const std::vector>& matrix) { + QJsonArray form; + for (const auto& r : matrix) { + QJsonArray row; + for (double v : r) row.append(v); + form.append(row); + } + return form; +} + +QJsonObject buildFilterApplyBody(const FilterApplyParams& p) { + // 全字段对照原版 useFilterToProcessData。 + return QJsonObject{ + {QStringLiteral("boundary"), filterBoundaryCode(p.dataEdge)}, + {QStringLiteral("boundaryValue"), p.dataEdgeValue}, + {QStringLiteral("column"), p.column}, + {QStringLiteral("dsObjectId"), p.dsObjectId}, + {QStringLiteral("noDataPoints"), filterNoDataCode(p.noDataPoints)}, + {QStringLiteral("noDataPointsValue"), p.noDataValue}, + {QStringLiteral("number"), p.number}, + {QStringLiteral("row"), p.row}, + {QStringLiteral("rowColumValue"), + QJsonObject{{QStringLiteral("form"), matrixToJson(p.matrix)}}}, + {QStringLiteral("filteringMethod"), p.filteringMethod}, + }; +} + +QJsonObject buildNewFilterBody(const QString& name, const QString& projectId, + const QString& parentId, + const std::vector>& matrix) { + return QJsonObject{ + {QStringLiteral("type"), 1}, + {QStringLiteral("rowColumValue"), + QJsonObject{{QStringLiteral("form"), matrixToJson(matrix)}}}, + {QStringLiteral("name"), name}, + {QStringLiteral("projectId"), projectId}, + {QStringLiteral("parentId"), parentId}, + }; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionProcessOps.hpp b/src/app/panels/chart/InversionProcessOps.hpp new file mode 100644 index 0000000..6114520 --- /dev/null +++ b/src/app/panels/chart/InversionProcessOps.hpp @@ -0,0 +1,77 @@ +#pragma once +#include + +#include +#include +#include + +namespace geopro::app { + +// 反演网格视图「处理类」操作的纯逻辑(仅依赖 QtCore JSON,无 Widgets/MOC)。 +// 拆出独立 TU 以便单测(与 ScatterDataOps / InversionFormParse 同范式)。 +// 字段名/取值一律对照原版 web(GridDialog/WhiteningDialog/FilterDialog)。 + +// ── I1/O1 网格化(toGrid,对照原版 toGridTheData)───────────────────────────── +// 网格化向导第二步参数集合。spacing/size 取整由调用方按 UI 输入填好。 +struct GridToParams { + QString dsObjectId; + QString actionCode; // = 算法项 scriptCode + double xMin = 0.0, xMax = 0.0, yMin = 0.0, yMax = 0.0; + double vMin = 0.0, vMax = 0.0; + int xSize = 0, ySize = 0; // 点数 + double xSpacing = 0.0, ySpacing = 0.0; // 间距 + bool logFormat = false; // 保存格式:false=线性(1) true=对数(2) +}; + +// 组装 toGrid 请求体(字段名/casing 对照原版:xsize/ysize 小写,xSpacing/ySpacing 驼峰)。 +QJsonObject buildGridToBody(const GridToParams& p); + +// ── I3 白化(whitenData,对照原版 whitenTheData)──────────────────────────── +// 三种白化方式(数值对照原版 whiteningMethod 1/2/3)。 +struct WhitenParams { + QString dsObjectId; + int whiteningMethod = 1; // 1 数据边界自动 / 2 白化模板 / 3 模型白化 + // method 1:边界扩展 + 内/外白化(whiteningType:0 外部 / 1 内部)。 + double boundaryExtension = 0.0; + int whiteningType = 0; + // method 2:选中的白化文件 id。 + QString whitenedDataId; + // method 3:模型白化子类型(2 梯形 / 1 矩形)。 + int modelWhiteningSubType = 2; +}; + +// 组装 whitenData 请求体(按 method 分支组装差异字段,对照原版)。 +// method 1 的 whitenedWay 是布尔(whiteningType==0 → true 外部)。 +QJsonObject buildWhitenBody(const WhitenParams& p); + +// ── I4 滤波(applyFilter,对照原版 useFilterToProcessData)──────────────────── +// 数据边缘处理方式 code(whitening→1 / skip→2 / edgePoint→3 / filling→4)。 +int filterBoundaryCode(const QString& dataEdge); +// 无数据点处理方式 code(expansion→1 / retain→2 / skip→3 / filling→4)。 +int filterNoDataCode(const QString& noDataPoints); + +struct FilterApplyParams { + QString dsObjectId; + QString dataEdge = QStringLiteral("whitening"); // 数据边缘下拉值 + double dataEdgeValue = 0.0; // 仅 filling 生效 + QString noDataPoints = QStringLiteral("filling"); // 无数据点下拉值 + double noDataValue = 0.0; // 仅 filling 生效 + int number = 1; // 滤波次数 + int row = 3, column = 3; // 矩阵行/列 + std::vector> matrix; // 滤波矩阵 + QString filteringMethod; // 选中滤波器节点名(字符串) +}; + +// 把二维矩阵转为 rowColumValue.form(QJsonArray of QJsonArray)。 +QJsonArray matrixToJson(const std::vector>& matrix); + +// 组装 applyFilter 请求体(全字段对照原版 useFilterToProcessData)。 +QJsonObject buildFilterApplyBody(const FilterApplyParams& p); + +// 组装 newFilter 请求体(自定义滤波器另存,对照原版 newTheFilter): +// {type:1, rowColumValue:{form:matrix}, name, projectId, parentId}。 +QJsonObject buildNewFilterBody(const QString& name, const QString& projectId, + const QString& parentId, + const std::vector>& matrix); + +} // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 7268f10..7b5a6ac 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -1,23 +1,45 @@ #include "panels/chart/RawDataChartView.hpp" +#include "ColorScaleConfigDialog.hpp" #include "panels/chart/ChartTheme.hpp" #include "panels/chart/ColorBarWidget.hpp" +#include "panels/chart/GridWizardDialog.hpp" +#include "panels/chart/InversionFormDialog.hpp" +#include "panels/chart/SaveAsDialog.hpp" +#include "panels/chart/ScatterDataOps.hpp" +#include "panels/chart/ScatterFilterDialog.hpp" #include "panels/chart/ScatterHoverTip.hpp" #include "panels/chart/ScatterPlotItem.hpp" +#include + +#include "repo/IDatasetCommandRepository.hpp" + +#include #include #include +#include +#include #include #include +#include +#include #include +#include +#include #include #include #include #include +#include #include +#include +#include #include #include #include +#include + #include #include #include @@ -67,6 +89,13 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { tbLay->addWidget(btnSaveAs); tbLay->addStretch(); + // 反演原数据默认工具条交互(O1 网格 / O2 色阶配置 / O3 另存为)。 + connect(btnGrid, &QToolButton::clicked, this, [this, btnGrid]() { openGridWizard(btnGrid); }); + connect(btnColorScale, &QToolButton::clicked, this, + [this, btnColorScale]() { openInversionColorScale(btnColorScale); }); + connect(btnSaveAs, &QToolButton::clicked, this, + [this, btnSaveAs]() { openInversionSaveAs(btnSaveAs); }); + lay->addWidget(toolbar); // ---- QwtPlot(stretch 填满剩余空间)---- @@ -257,6 +286,41 @@ void styleToolIconButton(QToolButton* btn, const QIcon& icon) { btn->setCursor(Qt::PointingHandCursor); } +// core::Rgba → colorBar 颜色串(与 ColorScaleConfigDialog::rgbaToCss 同格式:不透明 #RRGGBB, +// 半透明 rgba(r,g,b,a∈0..1)),与后端 colorBar 互通。 +QString rgbaToColorBarCss(const geopro::core::Rgba& c) { + if (c.a >= 255) + return QStringLiteral("#%1%2%3") + .arg(c.r, 2, 16, QLatin1Char('0')) + .arg(c.g, 2, 16, QLatin1Char('0')) + .arg(c.b, 2, 16, QLatin1Char('0')) + .toUpper(); + return QStringLiteral("rgba(%1, %2, %3, %4)") + .arg(c.r) + .arg(c.g) + .arg(c.b) + .arg(QString::number(c.a / 255.0, 'g', 3)); +} + +// 组装色阶 properties(colorBar + lineConfig + labelConfig),与原版散点路径 +// newLvlColorLevel 一致(battery/scatters 仅发这三块,不含 lvlSchemeType 等等值面专属字段)。 +QJsonObject buildColorScaleProperties(const geopro::core::ColorScale& scale, + const ContourLineConfig& lineCfg) { + QJsonArray colorBar; + for (const auto& [value, color] : scale.stops()) + colorBar.append(QJsonArray{QString::number(value, 'f', 2), rgbaToColorBarCss(color)}); + QJsonObject lineConfig{ + {QStringLiteral("showLines"), lineCfg.lineShow}, + {QStringLiteral("color"), rgbaToColorBarCss(lineCfg.lineColor)}, + {QStringLiteral("lineType"), + lineCfg.dashed ? QStringLiteral("dashed") : QStringLiteral("solid")}}; + QJsonObject labelConfig{{QStringLiteral("showLabels"), lineCfg.labelShow}, + {QStringLiteral("color"), rgbaToColorBarCss(lineCfg.labelColor)}}; + return QJsonObject{{QStringLiteral("colorBar"), colorBar}, + {QStringLiteral("lineConfig"), lineConfig}, + {QStringLiteral("labelConfig"), labelConfig}}; +} + } // namespace void RawDataChartView::showNotImplemented(QWidget* anchor) { @@ -266,6 +330,333 @@ void RawDataChartView::showNotImplemented(QWidget* anchor) { QToolTip::showText(pos, QStringLiteral("暂未实现"), anchor); } +void RawDataChartView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter) { + cmdRepo_ = repo; + dsIdGetter_ = std::move(dsIdGetter); + projectIdGetter_ = std::move(projectIdGetter); +} + +void RawDataChartView::openInversionDialog(bool apparentResistivity, QWidget* anchor) { + // 无仓储/无 dsId 取值回调 → 退化占位(与未注入时一致)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { + showNotImplemented(anchor); + return; + } + const auto mode = apparentResistivity ? InversionFormDialog::Mode::ApparentResistivity + : InversionFormDialog::Mode::Inversion; + InversionFormDialog dlg(mode, cmdRepo_, dsId, projectId, this); + dlg.exec(); // 提交成功/失败由对话框内部反馈;本视图无需后续刷新(原版亦仅提示)。 +} + +void RawDataChartView::openGridWizard(QWidget* anchor) { + // O1:网格化向导(与网格视图 I1 共用 GridWizardDialog)。成功后散点视图无法渲染网格, + // 故仅提示成功(用户切到网格页签查看,与原版「生成新网格数据后刷新」语义一致)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } + GridWizardDialog dlg(cmdRepo_, dsId, this); + if (dlg.exec() == QDialog::Accepted) + QMessageBox::information(this, QStringLiteral("网格化"), QStringLiteral("网格化成功!")); +} + +void RawDataChartView::openInversionColorScale(QWidget* anchor) { + // O2:反演原数据散点色阶(type1,businessCode 空串,对照原版 originPage)。 + if (data_.scale.empty()) { showNotImplemented(anchor); return; } + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::lowest(); + for (double v : data_.scatter.v) { + if (!std::isfinite(v)) continue; + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + if (vMin > vMax) { vMin = 0.0; vMax = 1.0; } + std::vector samples = data_.scatter.v; + ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, nullptr, {}, this); + if (dlg.exec() != QDialog::Accepted) return; + + // 本地重建上色重绘。 + data_.scale = dlg.colorScale(); + delete colorSvc_; + colorSvc_ = new ColorMapService(data_.scale); + redrawScatter(); + colorBar_->setColorScale(data_.scale); + + // 持久化(businessCode 空,对照原版 originPage newLvlColorLevel businessCode:'')。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) return; + QJsonObject body{ + {QStringLiteral("dsObjectId"), dsId}, + {QStringLiteral("businessCode"), QString()}, + {QStringLiteral("projectId"), projectId}, + {QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, + }; + QPointer self(this); + cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) { + if (!self || ok) return; + QMessageBox::warning(self, QStringLiteral("色阶配置"), + msg.isEmpty() ? QStringLiteral("色阶保存失败") : msg); + }); +} + +void RawDataChartView::openInversionSaveAs(QWidget* anchor) { + // O3:另存为(复用 SaveAsDialog::Inversion → saveInversionAsData)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } + SaveAsDialog dlg(SaveAsDialog::Mode::Inversion, cmdRepo_, dsId, this); + dlg.exec(); +} + +QString RawDataChartView::currentVFieldCode() const { + if (vCombo_ && vCombo_->currentIndex() >= 0) { + const QString code = vCombo_->currentData().toString(); + if (!code.isEmpty()) return code; + } + return QStringLiteral("R0"); // 默认视电阻率(与初始加载一致) +} + +void RawDataChartView::redrawScatter() { + // 用当前 data_.scatter + colorSvc_ 重绘(M7/M8 本地变换/色阶变更后复用)。 + if (!scatterItem_ || !colorSvc_) return; + // 数据范围跟随当前 v 有限值 min/max(cauto,与初始上色一致)。 + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::lowest(); + for (double v : data_.scatter.v) { + if (!std::isfinite(v)) continue; + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + if (vMin <= vMax) colorSvc_->setDataRange(vMin, vMax); + scatterItem_->setData(data_.scatter, colorSvc_); + if (hoverTip_) hoverTip_->setField(&data_.scatter); + plot_->replot(); +} + +void RawDataChartView::onShowHide(bool hide) { + // popconfirm 确认(原版 a-popconfirm):复刻确认文案。 + const QString text = hide ? QStringLiteral("该操作将会把已选择的散点进行隐藏?") + : QStringLiteral("该操作将会显示所有已经隐藏的散点?"); + const auto ans = QMessageBox::question(this, QStringLiteral("提示"), text, + QMessageBox::Ok | QMessageBox::Cancel); + if (ans != QMessageBox::Ok) return; + + // 本地切换:显示/隐藏全部数据方块(电极保留)。 + auto localToggle = [this, hide]() { + if (!scatterItem_) return; + scatterItem_->setScatterVisible(!hide); + plot_->replot(); + }; + + // 无仓储/无 dsId → 仅本地切换(退化,不持久化)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { localToggle(); return; } + + // 收集要持久化的点 id(隐藏取可见点 / 显示取隐藏点),status:0=显示 1=隐藏。 + const QJsonArray ids = collectScatterIds(data_.scatter, hide); + const int status = hide ? 1 : 0; + QPointer self(this); + cmdRepo_->saveDisplayStatus(dsId, ids, status, [self, hide, localToggle](bool ok, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("提示"), + msg.isEmpty() ? QStringLiteral("操作失败") : msg); + return; + } + // 持久化成功后同步本地 displayStatus 与方块可见性。 + const int newStatus = hide ? 1 : 0; + for (int& s : self->data_.scatter.displayStatus) s = newStatus; + localToggle(); + }); +} + +void RawDataChartView::openFilterDialog(QWidget* anchor) { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } + ScatterFilterDialog dlg(cmdRepo_, dsId, currentVFieldCode(), this); + dlg.exec(); // 成功/失败由对话框内部反馈(生成过滤数据集为后端动作)。 +} + +void RawDataChartView::reloadForVValue() { + // V 值切换:重新请求散点 + 色阶(原版 vValueType change)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(vCombo_); return; } + QPointer self(this); + const QString vCode = currentVFieldCode(); + cmdRepo_->loadMeasurementScatter( + dsId, vCode, [self](bool ok, geopro::core::ScatterPayload payload, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("提示"), + msg.isEmpty() ? QStringLiteral("加载失败") : msg); + return; + } + // 保留已建 measurement 工具条(不重建):只换数据 + 色阶。setData 会重置 baseV_。 + self->setData(payload); + // 切 V 后值类型回到线性(与原版重新请求后默认线性一致)。 + if (self->valueTypeCombo_) { + const QSignalBlocker block(self->valueTypeCombo_); + self->valueTypeCombo_->setCurrentIndex(0); + } + }); +} + +void RawDataChartView::applyValueType() { + // 本地值类型变换(线性/倒数/对数):从 baseV_ 算,重新上色重绘(无后端)。 + if (!valueTypeCombo_ || baseV_.empty()) return; + const ScatterValueType type = scatterValueTypeFromCode(valueTypeCombo_->currentData().toString()); + data_.scatter.v = applyScatterValueType(baseV_, type); + redrawScatter(); +} + +void RawDataChartView::openScatterColorScale(QWidget* anchor) { + if (data_.scale.empty()) { showNotImplemented(anchor); return; } + // 数据范围取当前 v 有限值 min/max(散点上色 cauto 区间)。 + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::lowest(); + for (double v : data_.scatter.v) { + if (!std::isfinite(v)) continue; + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + if (vMin > vMax) { vMin = 0.0; vMax = 1.0; } + std::vector samples = data_.scatter.v; // 直方图/等积分层用原始标量 + + // 散点无独立 lvl 模板仓储(视图只持命令仓储)→ tplRepo 传空(另存为/打开 禁用)。 + ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, nullptr, {}, this); + if (dlg.exec() != QDialog::Accepted) return; + + // 本地重建 colorSvc_ 重绘散点(M8 即时生效)。 + data_.scale = dlg.colorScale(); + delete colorSvc_; + colorSvc_ = new ColorMapService(data_.scale); + redrawScatter(); + // 同步右侧竖条/底部横条色阶图例。 + if (data_.verticalLegend) colorBarV_->setColorScale(data_.scale); + else colorBar_->setColorScale(data_.scale); + + // 持久化到后端(saveColorGradation,businessCode=当前 V 值,type=3 散点路径)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) return; // 无仓储 → 仅本地生效(不阻塞) + QJsonObject body{ + {QStringLiteral("dsObjectId"), dsId}, + {QStringLiteral("businessCode"), currentVFieldCode()}, + {QStringLiteral("projectId"), projectId}, + {QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, + }; + QPointer self(this); + cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) { + if (!self || ok) return; + QMessageBox::warning(self, QStringLiteral("色阶配置"), + msg.isEmpty() ? QStringLiteral("色阶保存失败") : msg); + }); +} + +void RawDataChartView::openSaveAs(QWidget* anchor) { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } + SaveAsDialog dlg(SaveAsDialog::Mode::RawData, cmdRepo_, dsId, this); + dlg.exec(); // 成功/失败由对话框内部反馈。 +} + +void RawDataChartView::exportDat() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + // 参数与原版 exportScatterData2Dat 一致:electrodePosition=2, ipDataMark=0, typeMeasurement=0。 + QPointer self(this); + cmdRepo_->exportMeasurementDat( + dsId, /*electrodePosition*/ 2, /*ipDataMark*/ 0, /*typeMeasurement*/ 0, + [self](bool ok, QString fileName, QString fileData, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("导出"), + msg.isEmpty() ? QStringLiteral("导出失败!") : msg); + return; + } + const QString suggested = fileName.isEmpty() ? QStringLiteral("export.dat") : fileName; + const QString path = QFileDialog::getSaveFileName( + self, QStringLiteral("导出 DAT"), suggested, QStringLiteral("DAT 文件 (*.dat)")); + if (path.isEmpty()) return; + const QByteArray bytes = QByteArray::fromBase64(fileData.toUtf8()); + QSaveFile f(path); + if (!f.open(QIODevice::WriteOnly) || f.write(bytes) != bytes.size() || !f.commit()) { + QMessageBox::warning(self, QStringLiteral("导出"), QStringLiteral("写入文件失败。")); + } + }); +} + +void RawDataChartView::toggleInfoMode(bool on) { + infoMode_ = on; + if (on && !infoPanel_) { + // 首次开启:建覆盖在图区右上角的属性面板(复刻原版 .scatterInfos 浮层)。 + infoPanel_ = new QWidget(plot_->canvas()); + infoPanel_->setObjectName(QStringLiteral("scatterInfoPanel")); + auto* il = new QVBoxLayout(infoPanel_); + il->setContentsMargins(8, 6, 8, 6); + infoLabel_ = new QLabel(QStringLiteral("点选散点查看属性"), infoPanel_); + il->addWidget(infoLabel_); + applyTokenizedStyleSheet( + infoPanel_, + QStringLiteral("QWidget#scatterInfoPanel { background: {{bg/panel}};" + " border: 1px solid {{border/default}}; border-radius: 6px; }" + "QLabel { color: {{text/primary}}; }")); + // 画布事件过滤器:信息模式下点击找最近点显示属性。 + plot_->canvas()->installEventFilter(this); + } + if (infoPanel_) { + infoPanel_->setVisible(on); + if (on) { + infoPanel_->adjustSize(); + infoPanel_->move(plot_->canvas()->width() - infoPanel_->width() - 10, 10); + infoPanel_->raise(); + } + } +} + +void RawDataChartView::showPointInfoAt(const QPoint& canvasPos) { + if (!infoLabel_ || data_.scatter.x.empty()) return; + const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xTop); + const QwtScaleMap yMap = plot_->canvasMap(QwtPlot::yLeft); + const auto& s = data_.scatter; + const std::size_t n = std::min(s.x.size(), s.y.size()); + double bestD2 = std::numeric_limits::max(); + std::size_t bestI = 0; + for (std::size_t i = 0; i < n; ++i) { + const double dx = xMap.transform(s.x[i]) - canvasPos.x(); + const double dy = yMap.transform(s.y[i]) - canvasPos.y(); + const double d2 = dx * dx + dy * dy; + if (d2 < bestD2) { bestD2 = d2; bestI = i; } + } + constexpr double kHit = 8.0; // 命中半径(像素) + if (bestD2 > kHit * kHit) return; // 未命中任何点 → 保持当前显示 + auto at = [](const std::vector& v, std::size_t i) { + return i < v.size() ? v[i] : 0.0; + }; + // 复刻原版 scatterInfos:A / B / M / N / DataRow / Pseu_Resis。 + infoLabel_->setText(QStringLiteral("A= %1\nB= %2\nM= %3\nN= %4\nDataRow= %5\nPseu_Resis= %6") + .arg(QString::number(at(s.a, bestI), 'g', 6), + QString::number(at(s.b, bestI), 'g', 6), + QString::number(at(s.m, bestI), 'g', 6), + QString::number(at(s.n, bestI), 'g', 6), + QString::number(at(s.row, bestI), 'g', 6), + QString::number(at(s.pseu, bestI), 'g', 6))); + infoPanel_->adjustSize(); + infoPanel_->move(plot_->canvas()->width() - infoPanel_->width() - 10, 10); + infoPanel_->raise(); +} + +bool RawDataChartView::eventFilter(QObject* obj, QEvent* ev) { + // 仅信息模式 + 画布左键点击:找最近散点显示属性,不消费事件(保留平移链路)。 + if (infoMode_ && plot_ && obj == plot_->canvas() && ev->type() == QEvent::MouseButtonPress) { + auto* me = static_cast(ev); + if (me->button() == Qt::LeftButton) showPointInfoAt(me->position().toPoint()); + } + return QWidget::eventFilter(obj, ev); +} + void RawDataChartView::replotForAxis() { // 本地换 x/y(无网络):按下拉 fieldCode 从备选列取数据,重设 scatter.x/.y 并重绘。 if (!xCombo_ || !yCombo_ || !scatterItem_) return; @@ -305,73 +696,91 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba auto* btnInfo = new QToolButton(toolbar); btnInfo->setToolTip(QStringLiteral("信息")); styleToolIconButton(btnInfo, makeInfoIcon()); - connect(btnInfo, &QToolButton::clicked, this, [this, btnInfo]() { showNotImplemented(btnInfo); }); auto* btnMarquee = new QToolButton(toolbar); btnMarquee->setToolTip(QStringLiteral("框选")); styleToolIconButton(btnMarquee, makeMarqueeIcon()); - connect(btnMarquee, &QToolButton::clicked, this, [this, btnMarquee]() { showNotImplemented(btnMarquee); }); // 主题热切:重绘图标(info 锚定品牌蓝,marquee 描边随次要文本色)。 connect(&ThemeManager::instance(), &ThemeManager::changed, btnInfo, [btnInfo]() { btnInfo->setIcon(makeInfoIcon()); }); connect(&ThemeManager::instance(), &ThemeManager::changed, btnMarquee, [btnMarquee]() { btnMarquee->setIcon(makeMarqueeIcon()); }); - // 显示 / 隐藏:功能性——切换全部数据方块可见性(电极保留)。 + // [i] 信息:切换信息模式(点选散点看 A/B/M/N/DataRow/Pseu_Resis)。 + btnInfo->setCheckable(true); + connect(btnInfo, &QToolButton::toggled, this, [this](bool on) { toggleInfoMode(on); }); + // [▣] 框选:本轮后置(Qwt 橡皮筋框选 + 选区联动隐藏成本较高),保持占位提示。 + connect(btnMarquee, &QToolButton::clicked, this, + [this, btnMarquee]() { showNotImplemented(btnMarquee); }); + + // 显示 / 隐藏:popconfirm 确认 → saveDisplayStatus 持久化 → 本地切换(M1)。 auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar); auto* btnHide = new QPushButton(QStringLiteral("隐藏"), toolbar); - connect(btnShow, &QPushButton::clicked, this, [this]() { - if (scatterItem_) { scatterItem_->setScatterVisible(true); plot_->replot(); } - }); - connect(btnHide, &QPushButton::clicked, this, [this]() { - if (scatterItem_) { scatterItem_->setScatterVisible(false); plot_->replot(); } - }); + connect(btnShow, &QPushButton::clicked, this, [this]() { onShowHide(/*hide*/ false); }); + connect(btnHide, &QPushButton::clicked, this, [this]() { onShowHide(/*hide*/ true); }); - // 数据过滤:占位(暂未实现)。 + // 数据过滤:范围过滤弹窗(M3)。 auto* btnFilter = new QPushButton(QStringLiteral("数据过滤"), toolbar); - connect(btnFilter, &QPushButton::clicked, this, [this, btnFilter]() { showNotImplemented(btnFilter); }); + connect(btnFilter, &QPushButton::clicked, this, [this, btnFilter]() { openFilterDialog(btnFilter); }); - // x / y 下拉:功能性(本地换列重绘);v / method 下拉:视觉占位(选不同 v/method 提示暂未实现)。 + // 导出 DAT:原版在页头「导出」,客户端页头为占位且跨 dd 共用,故 measurement 专属导出 + // 收纳进本工具条(M12)。点击 → exportMeasurementDat → 选路径写盘。 + auto* btnExport = new QPushButton(QStringLiteral("导出"), toolbar); + connect(btnExport, &QPushButton::clicked, this, [this]() { exportDat(); }); + + // x / y 下拉:本地换列重绘;v 下拉:重新请求散点+色阶(M6);值类型下拉:本地变换(M7)。 xCombo_ = new QComboBox(toolbar); fillCombo(xCombo_, conf.x, conf.defaultX, QString()); yCombo_ = new QComboBox(toolbar); fillCombo(yCombo_, conf.y, conf.defaultY, QString()); - auto* vCombo = new QComboBox(toolbar); - fillCombo(vCombo, conf.v, conf.defaultV, QString()); - auto* methodCombo = new QComboBox(toolbar); - fillCombo(methodCombo, conf.method, QString(), conf.defaultMethod); + vCombo_ = new QComboBox(toolbar); + fillCombo(vCombo_, conf.v, conf.defaultV, QString()); + valueTypeCombo_ = new QComboBox(toolbar); + // 值类型固定三项(原版 linearity/inverse/logarithm),本地变换无后端。 + valueTypeCombo_->addItem(QStringLiteral("线性"), QStringLiteral("linearity")); + valueTypeCombo_->addItem(QStringLiteral("倒数"), QStringLiteral("inverse")); + valueTypeCombo_->addItem(QStringLiteral("对数"), QStringLiteral("logarithm")); connect(xCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { replotForAxis(); }); connect(yCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { replotForAxis(); }); - // v / method:换选项 → 暂未实现(需重新请求散点/色阶,属重交互,本轮不做)。 - connect(vCombo, QOverload::of(&QComboBox::activated), this, - [this, vCombo](int) { showNotImplemented(vCombo); }); - connect(methodCombo, QOverload::of(&QComboBox::activated), this, - [this, methodCombo](int) { showNotImplemented(methodCombo); }); + // V 值切换:重新请求散点+色阶(用 activated 仅用户操作触发,填充期不误触)。 + connect(vCombo_, QOverload::of(&QComboBox::activated), this, + [this](int) { reloadForVValue(); }); + connect(valueTypeCombo_, QOverload::of(&QComboBox::activated), this, + [this](int) { applyValueType(); }); - // 色阶配置:占位(暂未实现)。 + // 色阶配置:复用 ColorScaleConfigDialog(散点上色路径),保存 + 本地重绘(M8)。 auto* btnColorScale = new QPushButton(QStringLiteral("色阶配置"), toolbar); - connect(btnColorScale, &QPushButton::clicked, this, [this, btnColorScale]() { showNotImplemented(btnColorScale); }); + connect(btnColorScale, &QPushButton::clicked, this, + [this, btnColorScale]() { openScatterColorScale(btnColorScale); }); - // 右侧主操作(蓝色):生成视电阻率数据 / 反演运算 / 另存为 —— 占位(暂未实现)。 + // 右侧主操作(蓝色):生成视电阻率数据 / 反演运算 / 另存为。 auto* btnGen = new QPushButton(QStringLiteral("生成视电阻率数据"), toolbar); auto* btnInvert = new QPushButton(QStringLiteral("反演运算"), toolbar); auto* btnSaveAs = new QPushButton(QStringLiteral("另存为"), toolbar); for (auto* b : {btnGen, btnInvert, btnSaveAs}) { b->setObjectName(QStringLiteral("primaryBtn")); // 蓝色主按钮(下方 QSS) - connect(b, &QPushButton::clicked, this, [this, b]() { showNotImplemented(b); }); } + // 反演运算 → submitInversionTask;生成视电阻率 → createVisualResistivityData(共享反演对话框)。 + connect(btnInvert, &QPushButton::clicked, this, + [this, btnInvert]() { openInversionDialog(/*apparentResistivity*/ false, btnInvert); }); + connect(btnGen, &QPushButton::clicked, this, + [this, btnGen]() { openInversionDialog(/*apparentResistivity*/ true, btnGen); }); + // 另存为:新增/覆盖弹窗 → saveRawData(M11)。 + connect(btnSaveAs, &QPushButton::clicked, this, + [this, btnSaveAs]() { openSaveAs(btnSaveAs); }); tbLay->addWidget(btnInfo); tbLay->addWidget(btnMarquee); tbLay->addWidget(btnShow); tbLay->addWidget(btnHide); tbLay->addWidget(btnFilter); + tbLay->addWidget(btnExport); tbLay->addWidget(xCombo_); tbLay->addWidget(yCombo_); - tbLay->addWidget(vCombo); - tbLay->addWidget(methodCombo); + tbLay->addWidget(vCombo_); + tbLay->addWidget(valueTypeCombo_); tbLay->addWidget(btnColorScale); tbLay->addStretch(); // 把主操作推到右侧 tbLay->addWidget(btnGen); @@ -405,6 +814,7 @@ void RawDataChartView::setPayload(const QVariant& payload) { void RawDataChartView::setData(const geopro::core::ScatterPayload& p) { data_ = p; + baseV_ = data_.scatter.v; // 缓存原始 v(线性),M7 值类型变换从原值算,不累积误差 if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖) // measurement 载荷(toolbar 非空):首次到来时建并替换工具条(视觉 1:1)。反演留空 → 不动。 diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index 38b2c51..7b3c523 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -1,4 +1,7 @@ #pragma once +#include + +#include #include #include "model/detail/DetailPayloads.hpp" #include "panels/chart/ColorMapService.hpp" @@ -6,9 +9,14 @@ class QComboBox; class QVBoxLayout; +class QLabel; class QwtPlot; class QwtPlotRescaler; +namespace geopro::data { +class IDatasetCommandRepository; +} + namespace geopro::app { class ColorBarWidget; @@ -32,6 +40,16 @@ public: // 供外部访问(已不再是占位,保留兼容接口返回 plot_) QWidget* plotArea() const; + // 注入反演命令仓储 + dsId/projectId 取值回调(measurement 反演运算/生成视电阻率按钮)。 + // 可传空仓储 → 两按钮退化为「暂未实现」占位提示。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter); + +protected: + // 信息模式(M13)下捕获画布点击:找最近散点显示属性。其余事件不消费。 + bool eventFilter(QObject* obj, QEvent* ev) override; + private: // 工具条按载荷类型二选一:反演原数据 = ctor 默认建的 inversion 工具条;measurement = // 首个非空 ScatterToolbarConf 到来时建一次并替换(视觉 1:1)。建好后缓存,后续 setData 复用。 @@ -40,6 +58,27 @@ private: void replotForAxis(); // “暂未实现”轻提示(占位按钮/下拉点击)。 void showNotImplemented(QWidget* anchor); + // 打开反演动态表单对话框(反演运算/生成视电阻率共享)。无仓储/无 dsId → 占位提示。 + void openInversionDialog(bool apparentResistivity, QWidget* anchor); + + // 反演原数据默认工具条交互(O1/O2/O3): + void openGridWizard(QWidget* anchor); // O1 网格化向导(复用 GridWizardDialog) + void openInversionColorScale(QWidget* anchor); // O2 原数据散点色阶(type1,businessCode='') + void openInversionSaveAs(QWidget* anchor); // O3 另存为(复用 SaveAsDialog::Inversion) + + // measurement 交互: + void onShowHide(bool hide); // M1 显示/隐藏(popconfirm→持久化→本地切) + void openFilterDialog(QWidget* anchor); // M3 数据过滤 + void reloadForVValue(); // M6 V 值切换重新请求散点+色阶 + void applyValueType(); // M7 值类型本地变换重新上色 + void openScatterColorScale(QWidget* anchor); // M8 色阶配置(编辑+保存+重绘) + void openSaveAs(QWidget* anchor); // M11 另存为 + void exportDat(); // M12 导出 DAT + void toggleInfoMode(bool on); // M13 [i] 信息模式开关 + void showPointInfoAt(const QPoint& canvasPos); // M13 点选显示属性 + // 用 colorSvc_ 重绘当前散点(M7/M8 本地变换/色阶变更后复用)。 + void redrawScatter(); + QString currentVFieldCode() const; // 当前 V 值下拉 fieldCode geopro::core::ScatterPayload data_; QwtPlot* plot_; @@ -53,11 +92,25 @@ private: bool measurementToolbar_ = false; // 已建 measurement 工具条 QComboBox* xCombo_ = nullptr; // measurement x 下拉 QComboBox* yCombo_ = nullptr; // measurement y 下拉 + QComboBox* vCombo_ = nullptr; // measurement V 值下拉(M6 重载) + QComboBox* valueTypeCombo_ = nullptr; // measurement 值类型下拉(M7 本地变换) + + // M7 值类型:保留原始 v(线性)以便倒数/对数从原值变换,不累积误差。 + std::vector baseV_; + // M13 [i]信息:信息模式开关 + 覆盖在图区右上的属性面板。 + bool infoMode_ = false; + QWidget* infoPanel_ = nullptr; // 属性覆盖面板(A/B/M/N/DataRow/Pseu_Resis) + QLabel* infoLabel_ = nullptr; // 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针 ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建 ScatterPlotItem* scatterItem_ = nullptr; ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示(QObject,this 持有) + + // 反演命令仓储 + dsId/projectId 取值回调(注入;空则反演按钮占位)。 + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; + std::function dsIdGetter_; + std::function projectIdGetter_; }; } // namespace geopro::app diff --git a/src/app/panels/chart/SaveAsDialog.cpp b/src/app/panels/chart/SaveAsDialog.cpp new file mode 100644 index 0000000..af9b0bd --- /dev/null +++ b/src/app/panels/chart/SaveAsDialog.cpp @@ -0,0 +1,110 @@ +#include "panels/chart/SaveAsDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "panels/chart/ScatterDataOps.hpp" // buildSaveRawDataBody(纯组装,便于单测) +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, QString dsId, + QWidget* parent) + : QDialog(parent), mode_(mode), repo_(repo), dsId_(std::move(dsId)) { + setWindowTitle(QStringLiteral("数据另存为")); + setModal(true); + + auto* root = formkit::dialogRoot(this); + + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + + if (mode_ == Mode::RawData) { + // 新增/覆盖单选(复刻原版 a-radio-group:1=新增 0=覆盖)。 + auto* opLay = new QHBoxLayout(); + auto* rbNew = new QRadioButton(QStringLiteral("新增"), this); + auto* rbOverwrite = new QRadioButton(QStringLiteral("覆盖"), this); + opGroup_ = new QButtonGroup(this); + opGroup_->addButton(rbNew, 1); + opGroup_->addButton(rbOverwrite, 0); + rbNew->setChecked(true); // 默认新增(与原版 dataStored 初值一致) + opLay->addWidget(rbNew); + opLay->addWidget(rbOverwrite); + opLay->addStretch(); + cardLay->addLayout(opLay); + } + + // 名称行:RawData 仅新增可见;Inversion 始终可见。 + auto* nameForm = formkit::makeEditForm(); + nameLabel_ = formkit::editLabel(QStringLiteral("数据名称"), this); + nameEdit_ = new QLineEdit(this); + formkit::capField(nameEdit_); + nameForm->addRow(nameLabel_, nameEdit_); + cardLay->addLayout(nameForm); + root->addWidget(card); + + if (mode_ == Mode::RawData && opGroup_) { + // 切到覆盖隐藏名称框,切回新增显示(复刻原版 v-show=dataStored===1)。 + connect(opGroup_, QOverload::of(&QButtonGroup::idClicked), this, [this](int id) { + const bool isNew = (id == 1); + nameLabel_->setVisible(isNew); + nameEdit_->setVisible(isNew); + }); + } + + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + okBtn_ = new QPushButton(QStringLiteral("确定"), this); + okBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn_, &QPushButton::clicked, this, &SaveAsDialog::onConfirm); +} + +void SaveAsDialog::onConfirm() { + if (!repo_) { reject(); return; } + + // RawData 新增 / Inversion 均需名称(覆盖不需)。 + const int operationType = opGroup_ ? opGroup_->checkedId() : 1; + const bool needName = (mode_ == Mode::Inversion) || (operationType == 1); + const QString name = nameEdit_->text().trimmed(); + if (needName && name.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入数据名称")); + return; + } + + okBtn_->setEnabled(false); + QPointer self(this); + auto onDone = [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("保存失败") : msg); + } + }; + if (mode_ == Mode::Inversion) { + repo_->saveInversionAsData(dsId_, name, onDone); + } else { + repo_->saveRawData(buildSaveRawDataBody(dsId_, operationType, name), onDone); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/SaveAsDialog.hpp b/src/app/panels/chart/SaveAsDialog.hpp new file mode 100644 index 0000000..450fce1 --- /dev/null +++ b/src/app/panels/chart/SaveAsDialog.hpp @@ -0,0 +1,50 @@ +#pragma once +#include + +#include +#include +#include + +class QLineEdit; +class QButtonGroup; +class QLabel; +class QPushButton; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 「另存为」对话框(1:1 复刻原版 web,两形态可复用): +// - RawData(measurement「另存为」,原版 saveRawDataValue):新增/覆盖单选 +(新增时)名称框。 +// 提交体 {dsId, operationType(1新增/0覆盖), name?}(覆盖不带 name)。 +// - Inversion(inversion「另存为」,原版 saveVisualResistivityData):仅名称框。 +// 提交体 {dsObjectId, name}。 +// 设计成可复用:Mode 区分两形态;提交统一经 IDatasetCommandRepository::saveRawData / +// saveInversionAsData。回调用 QPointer 守卫(虽 modal exec,仍异步回调)。 +class SaveAsDialog : public QDialog { + Q_OBJECT +public: + enum class Mode { + RawData, // measurement 另存为(新增/覆盖 + 名称) + Inversion, // inversion 另存为(仅名称) + }; + + SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, QString dsId, + QWidget* parent = nullptr); + +private: + void onConfirm(); + + Mode mode_; + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + + QButtonGroup* opGroup_ = nullptr; // RawData:新增(1)/覆盖(0) + QLabel* nameLabel_ = nullptr; // RawData:仅新增可见 + QLineEdit* nameEdit_ = nullptr; + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterDataOps.cpp b/src/app/panels/chart/ScatterDataOps.cpp new file mode 100644 index 0000000..56b0949 --- /dev/null +++ b/src/app/panels/chart/ScatterDataOps.cpp @@ -0,0 +1,68 @@ +#include "panels/chart/ScatterDataOps.hpp" + +#include + +namespace geopro::app { + +ScatterValueType scatterValueTypeFromCode(const QString& code) { + if (code == QStringLiteral("inverse")) return ScatterValueType::Inverse; + if (code == QStringLiteral("logarithm")) return ScatterValueType::Logarithm; + return ScatterValueType::Linearity; // 'linearity' 及未知 +} + +std::vector applyScatterValueType(const std::vector& v, ScatterValueType type) { + std::vector out; + out.reserve(v.size()); + for (double x : v) { + switch (type) { + case ScatterValueType::Inverse: + out.push_back(x == 0.0 ? 0.0 : 1.0 / x); + break; + case ScatterValueType::Logarithm: + // v<=0 无定义:保持原值(避免 NaN/-inf 污染数据范围→全图取 NaN 色)。 + out.push_back(x > 0.0 ? std::log10(x) : x); + break; + case ScatterValueType::Linearity: + default: + out.push_back(x); + break; + } + } + return out; +} + +QJsonArray collectScatterIds(const geopro::core::ScatterField& field, bool hide) { + QJsonArray ids; + const std::size_t n = field.id.size(); + for (std::size_t i = 0; i < n; ++i) { + const std::string& id = field.id[i]; + if (id.empty()) continue; + // displayStatus 缺省视为可见(0)。 + const int status = i < field.displayStatus.size() ? field.displayStatus[i] : 0; + const bool visible = (status == 0); + // 隐藏:取可见点;显示:取隐藏点。 + if (hide == visible) ids.append(QString::fromStdString(id)); + } + return ids; +} + +QJsonObject buildScatterFilterBody(const QString& dsObjectId, const QString& vFieldCode, double min, + double max) { + return QJsonObject{ + {QStringLiteral("sourceDsObjectId"), dsObjectId}, + {QStringLiteral("sourceVFieldCode"), vFieldCode}, + {QStringLiteral("min"), min}, + {QStringLiteral("max"), max}, + }; +} + +QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const QString& name) { + QJsonObject body{ + {QStringLiteral("dsId"), dsId}, + {QStringLiteral("operationType"), operationType}, + }; + if (operationType == 1) body.insert(QStringLiteral("name"), name); // 新增才带名称 + return body; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterDataOps.hpp b/src/app/panels/chart/ScatterDataOps.hpp new file mode 100644 index 0000000..70eeef7 --- /dev/null +++ b/src/app/panels/chart/ScatterDataOps.hpp @@ -0,0 +1,43 @@ +#pragma once +#include + +#include +#include +#include + +#include "model/Field.hpp" + +namespace geopro::app { + +// measurement 散点的纯逻辑(仅依赖 QtCore JSON + core model,无 Widgets/MOC)。 +// 拆出独立 TU 以便单测(与 InversionFormParse 同范式)。 + +// 值类型变换(M7 值类型下拉:线性 / 倒数 / 对数)。原版本地变换显示,无后端。 +enum class ScatterValueType { + Linearity, // 线性:原值 v + Inverse, // 倒数:1/v(v==0 → 保持 0,避免 inf) + Logarithm, // 对数:log10(v)(v<=0 → 保持原值,避免 NaN/-inf 污染色阶范围) +}; + +// fieldCode('linearity'/'inverse'/'logarithm')→ 枚举;未知回退线性。 +ScatterValueType scatterValueTypeFromCode(const QString& code); + +// 对一组 v 值应用变换,返回新数组(不可变:不改入参)。 +std::vector applyScatterValueType(const std::vector& v, ScatterValueType type); + +// 收集「显示/隐藏」要持久化的点 id(M1)。 +// hide=true → 收集当前可见(displayStatus==0)的点 id(原版隐藏全部已选/可见点)。 +// hide=false → 收集当前隐藏(displayStatus!=0)的点 id(原版显示全部已隐藏点)。 +// id 为空串的点跳过(无效)。 +QJsonArray collectScatterIds(const geopro::core::ScatterField& field, bool hide); + +// 组装散点过滤请求体(M3,applyScatterFilter): +// {sourceDsObjectId, sourceVFieldCode, min, max}(字段名对照原版 applyScatterFilterInfo)。 +QJsonObject buildScatterFilterBody(const QString& dsObjectId, const QString& vFieldCode, + double min, double max); + +// 组装 measurement「另存为」请求体(M11,saveRawData,对照原版 saveRawDataValue): +// {dsId, operationType(1新增/0覆盖)},仅新增(operationType==1)才带 name。 +QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const QString& name); + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterFilterDialog.cpp b/src/app/panels/chart/ScatterFilterDialog.cpp new file mode 100644 index 0000000..64a35f4 --- /dev/null +++ b/src/app/panels/chart/ScatterFilterDialog.cpp @@ -0,0 +1,113 @@ +#include "panels/chart/ScatterFilterDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "panels/chart/ScatterDataOps.hpp" // buildScatterFilterBody +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr double kSpinRange = 1e12; // 数值范围足够宽,覆盖电阻率/电位等量纲 +} // namespace + +ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, + QString dsObjectId, QString vFieldCode, QWidget* parent) + : QDialog(parent), + repo_(repo), + dsObjectId_(std::move(dsObjectId)), + vFieldCode_(std::move(vFieldCode)) { + setWindowTitle(QStringLiteral("数据过滤")); + setModal(true); + resize(360, 200); + + auto* root = formkit::dialogRoot(this); + + rangeLabel_ = new QLabel(QStringLiteral("数值范围:—"), this); + root->addWidget(rangeLabel_); + + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + + auto* form = formkit::makeEditForm(); + minSpin_ = new QDoubleSpinBox(this); + minSpin_->setRange(-kSpinRange, kSpinRange); + minSpin_->setDecimals(2); + formkit::capField(minSpin_); + maxSpin_ = new QDoubleSpinBox(this); + maxSpin_->setRange(-kSpinRange, kSpinRange); + maxSpin_->setDecimals(2); + formkit::capField(maxSpin_); + form->addRow(formkit::editLabel(QStringLiteral("最小值")), minSpin_); + form->addRow(formkit::editLabel(QStringLiteral("最大值")), maxSpin_); + cardLay->addLayout(form); + root->addWidget(card); + + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + applyBtn_ = new QPushButton(QStringLiteral("应用过滤"), this); + applyBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(applyBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(applyBtn_, &QPushButton::clicked, this, &ScatterFilterDialog::onApply); + + loadConfig(); +} + +void ScatterFilterDialog::loadConfig() { + if (!repo_) return; + QPointer self(this); + repo_->getScatterFilterConfig( + dsObjectId_, vFieldCode_, [self](bool ok, QJsonObject cfg, QString) { + if (!self || !ok) return; + const double mn = cfg.value(QStringLiteral("min")).toDouble(); + const double mx = cfg.value(QStringLiteral("max")).toDouble(); + self->minSpin_->setValue(mn); + self->maxSpin_->setValue(mx); + self->rangeLabel_->setText(QStringLiteral("数值范围:%1 — %2") + .arg(QString::number(mn, 'g', 6), + QString::number(mx, 'g', 6))); + }); +} + +void ScatterFilterDialog::onApply() { + if (!repo_) { reject(); return; } + const double mn = minSpin_->value(); + const double mx = maxSpin_->value(); + if (mn > mx) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("最小值不能大于最大值")); + return; + } + + applyBtn_->setEnabled(false); + QPointer self(this); + repo_->applyScatterFilter( + buildScatterFilterBody(dsObjectId_, vFieldCode_, mn, mx), [self](bool ok, QString msg) { + if (!self) return; + self->applyBtn_->setEnabled(true); + if (ok) { + QMessageBox::information(self, self->windowTitle(), + QStringLiteral("应用过滤成功!")); + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("应用过滤失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterFilterDialog.hpp b/src/app/panels/chart/ScatterFilterDialog.hpp new file mode 100644 index 0000000..81afe44 --- /dev/null +++ b/src/app/panels/chart/ScatterFilterDialog.hpp @@ -0,0 +1,40 @@ +#pragma once +#include +#include + +class QDoubleSpinBox; +class QLabel; +class QPushButton; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 「数据过滤」对话框(复刻原版 web dataFilter.vue 的范围过滤部分): +// 打开时经 getScatterFilterConfig 取 min/max 初值填入「最小值/最大值」输入框; +// 「应用过滤」经 applyScatterFilter 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max})。 +// 直方图(原版左侧 D3 分布图)本轮后置:范围过滤为核心,直方图仅可视化辅助。 +// 回调用 QPointer 守卫(虽 modal exec,仍异步回调)。 +class ScatterFilterDialog : public QDialog { + Q_OBJECT +public: + ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, + QString vFieldCode, QWidget* parent = nullptr); + +private: + void loadConfig(); // 取 min/max 初值 + void onApply(); // 应用过滤 + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsObjectId_; + QString vFieldCode_; + + QLabel* rangeLabel_ = nullptr; // 「数值范围:min — max」 + QDoubleSpinBox* minSpin_ = nullptr; + QDoubleSpinBox* maxSpin_ = nullptr; + QPushButton* applyBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/WhiteningDialog.cpp b/src/app/panels/chart/WhiteningDialog.cpp new file mode 100644 index 0000000..e18b8d9 --- /dev/null +++ b/src/app/panels/chart/WhiteningDialog.cpp @@ -0,0 +1,173 @@ +#include "panels/chart/WhiteningDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "panels/chart/InversionProcessOps.hpp" // buildWhitenBody +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr double kExtRange = 1e6; // 边界扩展范围 +} // namespace + +WhiteningDialog::WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, + QString projectId, QString tmObjectId, QWidget* parent) + : QDialog(parent), + repo_(repo), + dsId_(std::move(dsId)), + projectId_(std::move(projectId)), + tmObjectId_(std::move(tmObjectId)) { + setWindowTitle(QStringLiteral("白化")); + setModal(true); + resize(460, 280); + + auto* root = formkit::dialogRoot(this); + + // 白化方式下拉(数值对照原版 whiteningMethod 1/2/3)。 + auto* methodLay = formkit::makeEditForm(); + methodCombo_ = new QComboBox(this); + methodCombo_->addItem(QStringLiteral("数据边界自动白化"), 1); + methodCombo_->addItem(QStringLiteral("白化模板"), 2); + methodCombo_->addItem(QStringLiteral("模型白化"), 3); + formkit::capField(methodCombo_); + methodLay->addRow(formkit::editLabel(QStringLiteral("白化方式")), methodCombo_); + root->addLayout(methodLay); + + stack_ = new QStackedWidget(this); + root->addWidget(stack_, 1); + + // ── 方式 1:数据边界自动白化 ──────────────────────────────────────── + auto* page1 = new QWidget(this); + auto* p1 = formkit::makeEditForm(); + page1->setLayout(p1); + extension_ = new QDoubleSpinBox(page1); + extension_->setRange(-kExtRange, kExtRange); + extension_->setDecimals(3); + formkit::capField(extension_); + p1->addRow(formkit::editLabel(QStringLiteral("白化边界扩展")), extension_); + auto* typeRow = new QHBoxLayout(); + auto* rbOuter = new QRadioButton(QStringLiteral("外部白化"), page1); + auto* rbInner = new QRadioButton(QStringLiteral("内部白化"), page1); + rbOuter->setChecked(true); + whiteningType_ = new QButtonGroup(this); + whiteningType_->addButton(rbOuter, 0); + whiteningType_->addButton(rbInner, 1); + typeRow->addWidget(rbOuter); + typeRow->addWidget(rbInner); + typeRow->addStretch(); + p1->addRow(formkit::editLabel(QStringLiteral("白化")), typeRow); + stack_->addWidget(page1); + + // ── 方式 2:白化模板(选文件)────────────────────────────────────── + auto* page2 = new QWidget(this); + auto* p2 = formkit::makeEditForm(); + page2->setLayout(p2); + fileCombo_ = new QComboBox(page2); + formkit::capField(fileCombo_); + p2->addRow(formkit::editLabel(QStringLiteral("选择白化文件")), fileCombo_); + stack_->addWidget(page2); + + // ── 方式 3:模型白化(梯形/矩形)─────────────────────────────────── + auto* page3 = new QWidget(this); + auto* p3 = formkit::makeEditForm(); + page3->setLayout(p3); + auto* subRow = new QHBoxLayout(); + auto* rbTrap = new QRadioButton(QStringLiteral("梯形白化"), page3); + auto* rbRect = new QRadioButton(QStringLiteral("矩形白化"), page3); + rbTrap->setChecked(true); + modelSubType_ = new QButtonGroup(this); + modelSubType_->addButton(rbTrap, 2); // 2 梯形 + modelSubType_->addButton(rbRect, 1); // 1 矩形 + subRow->addWidget(rbTrap); + subRow->addWidget(rbRect); + subRow->addStretch(); + p3->addRow(formkit::editLabel(QStringLiteral("白化")), subRow); + stack_->addWidget(page3); + + // 底部按钮。 + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + okBtn_ = new QPushButton(QStringLiteral("确定"), this); + okBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn_, &QPushButton::clicked, this, &WhiteningDialog::onConfirm); + connect(methodCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { onMethodChanged(methodCombo_->currentData().toInt()); }); + + loadWhitenedFiles(); // 预拉文件列表(方式 2 用) +} + +void WhiteningDialog::onMethodChanged(int method) { + stack_->setCurrentIndex(method - 1); // 1/2/3 → 页 0/1/2 +} + +void WhiteningDialog::loadWhitenedFiles() { + if (!repo_) return; + QPointer self(this); + repo_->listWhitenedData(projectId_, tmObjectId_, [self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->fileCombo_->clear(); + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + QString label = o.value(QStringLiteral("dsName")).toString(); + if (label.isEmpty()) label = o.value(QStringLiteral("name")).toString(); + const QString id = o.value(QStringLiteral("id")).toString(); + if (label.isEmpty()) label = QStringLiteral("未命名文件_%1").arg(id); + self->fileCombo_->addItem(label, id); + } + }); +} + +void WhiteningDialog::onConfirm() { + if (!repo_) { reject(); return; } + WhitenParams p; + p.dsObjectId = dsId_; + p.whiteningMethod = methodCombo_->currentData().toInt(); + if (p.whiteningMethod == 1) { + p.boundaryExtension = extension_->value(); + p.whiteningType = whiteningType_->checkedId(); + } else if (p.whiteningMethod == 2) { + if (fileCombo_->currentIndex() < 0) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择白化文件")); + return; + } + p.whitenedDataId = fileCombo_->currentData().toString(); + } else { + p.modelWhiteningSubType = modelSubType_->checkedId(); + } + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->whitenData(buildWhitenBody(p), [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("白化失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/WhiteningDialog.hpp b/src/app/panels/chart/WhiteningDialog.hpp new file mode 100644 index 0000000..624b9a0 --- /dev/null +++ b/src/app/panels/chart/WhiteningDialog.hpp @@ -0,0 +1,51 @@ +#pragma once +#include +#include + +class QComboBox; +class QDoubleSpinBox; +class QButtonGroup; +class QStackedWidget; +class QPushButton; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 白化对话框(I3,1:1 复刻原版 WhiteningDialog)。三种白化方式: +// 1 数据边界自动:边界扩展 + 内/外白化单选。 +// 2 白化模板:listWhitenedData(projectId, tmObjectId) 选白化文件。 +// 3 模型白化:梯形/矩形单选。 +// 确认 → whitenData,成功 accept(),调用方随后重载网格重绘。 +class WhiteningDialog : public QDialog { + Q_OBJECT +public: + WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, QString projectId, + QString tmObjectId, QWidget* parent = nullptr); + +private: + void onMethodChanged(int method); // 切换方式 → 切到对应配置页 + void loadWhitenedFiles(); // 方式 2:拉白化文件列表 + void onConfirm(); // 确认 → whitenData + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + QString projectId_; + QString tmObjectId_; + + QComboBox* methodCombo_ = nullptr; + QStackedWidget* stack_ = nullptr; + // 方式 1: + QDoubleSpinBox* extension_ = nullptr; + QButtonGroup* whiteningType_ = nullptr; // 0 外部 / 1 内部 + // 方式 2: + QComboBox* fileCombo_ = nullptr; + // 方式 3: + QButtonGroup* modelSubType_ = nullptr; // 2 梯形 / 1 矩形 + + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/core/model/Anomaly.hpp b/src/core/model/Anomaly.hpp index 3a938b2..0c6ad32 100644 --- a/src/core/model/Anomaly.hpp +++ b/src/core/model/Anomaly.hpp @@ -16,6 +16,7 @@ struct Anomaly { std::string typeName; // exceptionTypeName std::string exceptionTypeId; // 异常类型 id(保存请求 exceptionTypeId) std::string remark; // 备注 + std::string createTime; // 创建时间(异常列表展示用,只读) AnomalyMarkType markType = AnomalyMarkType::Polyline; std::vector localPts; // 2D 局部坐标(剖面详情:x=距离, y=深度) // VTK 三维:异常多边形/折线/点的世界 3D 坐标(落在所在切片平面上)+ 平面(法向/一点), diff --git a/src/core/model/Field.hpp b/src/core/model/Field.hpp index 95d7ca0..a67b012 100644 --- a/src/core/model/Field.hpp +++ b/src/core/model/Field.hpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace geopro::core { // 规则三维标量场(IInterpolator 输出;render 层转 vtkImageData)。 @@ -55,6 +56,14 @@ struct ScatterField { std::vector a, b, m, n; std::vector electrodeX; std::vector electrodeNo; + // measurement 散点 [i]信息 / 显隐 / 持久化用(反演留空)。与 x/y/v 同序、一一对应: + // id = 点 id(saveDisplayStatus ids[],原版 rows[i][8];core 保持 Qt-free 用 std::string) + // displayStatus = 可见性(0=显示 1=隐藏,原版 rows[i][7]) + // row = DataRow([i]信息面板,原版 rows[i][15]) + // pseu = Pseu_Resis 视电阻率([i]信息面板,原版 rows[i][16]) + std::vector id; + std::vector displayStatus; + std::vector row, pseu; }; } // namespace geopro::core diff --git a/src/core/model/detail/DetailPayloads.hpp b/src/core/model/detail/DetailPayloads.hpp index 762aba3..48df109 100644 --- a/src/core/model/detail/DetailPayloads.hpp +++ b/src/core/model/detail/DetailPayloads.hpp @@ -60,15 +60,26 @@ struct TableColumn { TableColumnKind kind = TableColumnKind::Text; }; +// 表格功能按钮(dd_grid:服务端 functionList = DDGridFunctionVO[],驱动列表上方功能按钮行)。 +// 仅 dd_grid 列表携带(其余 Table 复用场景该列表为空 → 不渲染工具条)。enable=false 的按钮不显示 +// (原版 v-show="enable")。点击按 code 路由(原版仅处理 code=="inversion")。 +struct TableFunctionButton { + QString code; // functionCode(如 inversion) + QString nameChn; // functionNameChn(按钮中文文案) + bool enable = true; +}; + // 通用表格载荷:列定义 + 预格式化的行(每格 QString)+ 总数(分页用)。 // 分页(dd_grid 列表,服务端分页 vxe-pager):pageSize>0 时视图渲染分页器,pageNo 为当前页(1 基); // pageSize=0(默认)= 不分页(measurement/trajectory 全量列表,一次性返回所有行)。 +// functionButtons:仅 dd_grid 列表非空(来自服务端 functionList),驱动列表上方功能按钮行。 struct TablePayload { std::vector columns; std::vector> rows; int total = 0; int pageNo = 1; // 当前页(1 基);分页用 int pageSize = 0; // 每页条数;>0 才渲染分页器(vxe-pager),0=不分页 + std::vector functionButtons; // dd_grid 功能按钮(其余场景空) }; // 柱状图系列:名称(图例/legend)+ 各类目的 y 值 + 填充色(hex,如 #5470c6;数据色,两主题一致)。 diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 8a168c3..73d39c4 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(geopro_data STATIC api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp api/ApiColorTemplateRepository.cpp + api/ApiDatasetCommandRepository.cpp api/Api3dRepository.cpp api/DatasetLoadHandles.cpp api/NavRequest.cpp) diff --git a/src/data/api/ApiDatasetCommandRepository.cpp b/src/data/api/ApiDatasetCommandRepository.cpp new file mode 100644 index 0000000..d0cbde7 --- /dev/null +++ b/src/data/api/ApiDatasetCommandRepository.cpp @@ -0,0 +1,425 @@ +#include "api/ApiDatasetCommandRepository.hpp" + +#include + +#include + +#include "ApiBatch.hpp" +#include "ApiClient.hpp" +#include "IApiCall.hpp" +#include "dto/MeasurementDto.hpp" // parseMeasurementScatter(V 值重载复用初始加载解析) +#include "dto/DatasetChartDto.hpp" // parseInversionGrid/parseColorBar/parseDatasetAnomalies(网格重载) + +namespace geopro::data { + +namespace { +// 失败判定与色阶/详情仓储一致:业务码 != 200 或传输错误(rawError 非空)。 +bool isOk(const net::ApiResponse& r) { return r.code == 200 && r.rawError.isEmpty(); } + +// URL 路径段编码(动态 id),与现有 enc 用法一致。 +QString enc(const QString& s) { return QString::fromUtf8(QUrl::toPercentEncoding(s)); } + +// 三类回调骨架:统一「句柄判空 → connect → finished 取值」,消除样板重复。 + +// 仅状态:(bool ok, QString msg)。 +void wireStatus(net::IApiCall* call, std::function cb) { + if (call == nullptr) { + if (cb) cb(false, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (cb) cb(isOk(resp), resp.msg); + }); +} + +// 返回数组:(bool ok, QJsonArray list, QString msg)。顶层数组被 buildResponse 包成 data.value。 +void wireArray(net::IApiCall* call, std::function cb) { + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + cb(true, resp.data.value(QStringLiteral("value")).toArray(), resp.msg); + }); +} + +// 返回对象:(bool ok, QJsonObject data, QString msg)。 +void wireObject(net::IApiCall* call, std::function cb) { + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + cb(true, resp.data, resp.msg); + }); +} +} // namespace + +ApiDatasetCommandRepository::ApiDatasetCommandRepository(net::ApiClient& api) : api_(api) {} + +void ApiDatasetCommandRepository::listInversionScripts( + const QString& dsObjectId, std::function cb) { + auto* call = api_.getAsync( + QStringLiteral("/business/outerInversion/query/script?dsObjectId=%1") + .arg(QString::fromUtf8(QUrl::toPercentEncoding(dsObjectId)))); + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + // 模型列表:data 顶层为数组,buildResponse 包成 {"value": [...]}。 + cb(true, resp.data.value(QStringLiteral("value")).toArray(), resp.msg); + }); +} + +void ApiDatasetCommandRepository::getDynamicForm( + const QString& projectId, const QString& typeId, + std::function cb) { + QJsonObject body{{QStringLiteral("projectId"), projectId}, + {QStringLiteral("type"), 6}, + {QStringLiteral("typeId"), typeId}}; + auto* call = api_.postJsonAsync(QStringLiteral("/business/project/getDynamicForm"), body); + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + // 动态表单:data 为对象(含 formList 数组)。 + cb(true, resp.data, resp.msg); + }); +} + +void ApiDatasetCommandRepository::submitInversionTask(const QString& dsId, const QString& scriptId, + const QJsonObject& properties, + std::function cb) { + QJsonObject body{{QStringLiteral("dsId"), dsId}, + {QStringLiteral("scriptId"), scriptId}, + {QStringLiteral("properties"), properties}}; + auto* call = + api_.postJsonAsync(QStringLiteral("/business/outerInversion/submitInversionTask"), body); + if (call == nullptr) { + if (cb) cb(false, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (cb) cb(isOk(resp), resp.msg); + }); +} + +void ApiDatasetCommandRepository::createVisualResistivityData( + const QString& dsObjectId, const QString& scriptId, const QJsonObject& scriptParamListJsonStr, + std::function cb) { + QJsonObject body{{QStringLiteral("dsObjectId"), dsObjectId}, + {QStringLiteral("scriptId"), scriptId}, + {QStringLiteral("scriptParamListJsonStr"), scriptParamListJsonStr}}; + auto* call = api_.postJsonAsync( + QStringLiteral("/business/dd/ert/measurement/createVisualResistivityData"), body); + if (call == nullptr) { + if (cb) cb(false, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (cb) cb(isOk(resp), resp.msg); + }); +} + +// ============================ measurement 散点相关 ============================ + +void ApiDatasetCommandRepository::saveDisplayStatus(const QString& dsObjectId, + const QJsonArray& ids, int status, + std::function cb) { + QJsonObject body{{QStringLiteral("dsObjectId"), dsObjectId}, + {QStringLiteral("ids"), ids}, + {QStringLiteral("status"), status}}; + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/measurement/saveDisplayStatus"), + body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::getScatterFilterConfig( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) { + wireObject( + api_.getAsync(QStringLiteral( + "/business/scatterPlotDataFilter/getDataFilterConfig?dsObjectId=%1&vFieldCode=%2") + .arg(enc(dsObjectId), enc(vFieldCode))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::applyScatterFilter(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/scatterPlotDataFilter"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::saveRawData(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/measurement/saveRawData"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::exportMeasurementDat( + const QString& dsId, int electrodePosition, int ipDataMark, int typeMeasurement, + std::function cb) { + auto* call = api_.getAsync( + QStringLiteral("/business/dd/ert/measurement/rs2d/" + "export?dsId=%1&electrodePosition=%2&ipDataMark=%3&typeMeasurement=%4") + .arg(enc(dsId)) + .arg(electrodePosition) + .arg(ipDataMark) + .arg(typeMeasurement)); + if (call == nullptr) { + if (cb) cb(false, {}, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, {}, resp.msg); + return; + } + // 导出:data 含 base64 fileData 与 fileName,落盘交由 UI 层。 + cb(true, resp.data.value(QStringLiteral("fileName")).toString(), + resp.data.value(QStringLiteral("fileData")).toString(), resp.msg); + }); +} + +void ApiDatasetCommandRepository::loadMeasurementScatter( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) { + // 并发拉 scatter/graph(带 vFieldCode) + 色阶 getDetail(type3, businessCode=vFieldCode)。 + // 与 ApiDatasetRepository::measurementScatterBatch 同端点/同解析(仅 vFieldCode 可变)。 + QList calls{ + api_.getAsync( + QStringLiteral("/business/dd/ert/measurement/scatter/graph?dsObjectId=%1&vFieldCode=%2") + .arg(enc(dsObjectId), enc(vFieldCode))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), + QJsonObject{{QStringLiteral("dsObjectId"), dsObjectId}, + {QStringLiteral("businessCode"), vFieldCode}, + {QStringLiteral("type"), 3}}), + }; + auto* batch = new net::ApiBatch(calls, [](const net::ApiResponse& r) { + return r.code != 200 || !r.rawError.isEmpty(); + }); + QObject::connect(batch, &net::ApiBatch::succeeded, batch, + [cb](const QList& r) { + if (cb) cb(true, dto::parseMeasurementScatter(r[0].data, r[1].data), {}); + }); + QObject::connect(batch, &net::ApiBatch::failed, batch, + [cb](int, const net::ApiResponse& resp) { + if (cb) cb(false, {}, resp.msg); + }); + // ApiBatch 完成/失败后各 call 自行 deleteLater;batch 本身随末次信号后 deleteLater。 + QObject::connect(batch, &net::ApiBatch::succeeded, batch, &QObject::deleteLater); + QObject::connect(batch, &net::ApiBatch::failed, batch, &QObject::deleteLater); +} + +// ============================ 色阶(lvl)相关 ============================ + +void ApiDatasetCommandRepository::saveColorGradation(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation"), body), + std::move(cb)); +} + +// ============================ inversion 相关 ============================ + +void ApiDatasetCommandRepository::saveInversionAsData(const QString& dsObjectId, + const QString& name, + std::function cb) { + QJsonObject body{{QStringLiteral("dsObjectId"), dsObjectId}, {QStringLiteral("name"), name}}; + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/inversion/saveAsData"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::loadInversionGrid( + const QString& dsObjectId, + std::function cb) { + // 与 ApiDatasetRepository::inversionGridBatch 同端点/同解析:rows(慢) + 色阶 type2 + 异常。 + QList calls{ + api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsObjectId))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), + QJsonObject{{QStringLiteral("dsObjectId"), dsObjectId}, + {QStringLiteral("businessCode"), QString()}, + {QStringLiteral("type"), 2}}), + api_.getAsync( + QStringLiteral("/business/exception/queryException/%1").arg(enc(dsObjectId))), + }; + auto* batch = new net::ApiBatch(calls, [](const net::ApiResponse& r) { + return r.code != 200 || !r.rawError.isEmpty(); + }); + QObject::connect(batch, &net::ApiBatch::succeeded, batch, + [cb](const QList& r) { + if (!cb) return; + geopro::core::ContourPayload p{ + dto::parseInversionGrid(r[0].data), + dto::parseColorBar(r[1].data), + dto::parseDatasetAnomalies( + r[2].data.value(QStringLiteral("value")).toArray())}; + cb(true, p, {}); + }); + QObject::connect(batch, &net::ApiBatch::failed, batch, + [cb](int, const net::ApiResponse& resp) { + if (cb) cb(false, {}, resp.msg); + }); + QObject::connect(batch, &net::ApiBatch::succeeded, batch, &QObject::deleteLater); + QObject::connect(batch, &net::ApiBatch::failed, batch, &QObject::deleteLater); +} + +void ApiDatasetCommandRepository::listGridAlgorithm( + const QString& dsObjectId, std::function cb) { + wireArray(api_.getAsync(QStringLiteral("/business/dd/ert/inversion/queryAlgorithmModel/%1") + .arg(enc(dsObjectId))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::getGridRawDataParams( + const QString& dsObjectId, std::function cb) { + wireObject(api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getRawData/%1") + .arg(enc(dsObjectId))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::toGrid(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/inversion/grid"), body), + std::move(cb)); +} + +// ============================ 白化相关 ============================ + +void ApiDatasetCommandRepository::listWhitenedData( + const QString& projectId, const QString& tmObjectId, + std::function cb) { + QJsonObject body{{QStringLiteral("projectId"), projectId}, + {QStringLiteral("tmObjectId"), tmObjectId}}; + wireArray(api_.postJsonAsync(QStringLiteral("/business/dsObject/queryWhitenedDataList"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::whitenData(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/inversion/whitenedData"), body), + std::move(cb)); +} + +// ============================ 滤波相关 ============================ + +void ApiDatasetCommandRepository::listFilters(std::function cb) { + wireArray(api_.getAsync(QStringLiteral("/business/filter/queryFilter")), std::move(cb)); +} + +void ApiDatasetCommandRepository::newFilter(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/filter"), body), std::move(cb)); +} + +void ApiDatasetCommandRepository::deleteFilter(const QString& id, + std::function cb) { + wireStatus(api_.deleteAsync(QStringLiteral("/business/filter/delete/%1").arg(enc(id))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::applyFilter(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/inversion/filterData"), body), + std::move(cb)); +} + +// ============================ 异常 CRUD ============================ + +void ApiDatasetCommandRepository::listExceptionTypes( + const QString& projectId, const QString& remarkSourceType, + std::function cb) { + wireArray(api_.getAsync( + QStringLiteral( + "/business/exceptionType/queryExceptionTypeByProjectIdAndType/%1/%2") + .arg(enc(projectId), enc(remarkSourceType))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::getExceptionName( + const QString& exceptionTypeId, const QString& remarkSourceId, + std::function cb) { + QJsonObject body{{QStringLiteral("exceptionTypeId"), exceptionTypeId}, + {QStringLiteral("remarkSourceId"), remarkSourceId}}; + wireObject(api_.postJsonAsync(QStringLiteral("/business/exception/getExceptionName"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::newException(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/exception"), body), std::move(cb)); +} + +void ApiDatasetCommandRepository::deleteException(const QString& id, + std::function cb) { + wireStatus(api_.deleteAsync(QStringLiteral("/business/exception/%1").arg(enc(id))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::updateException(const QJsonObject& body, + std::function cb) { + wireStatus(api_.putJsonAsync(QStringLiteral("/business/exception"), body), std::move(cb)); +} + +// ============================ 自动标注 ============================ + +void ApiDatasetCommandRepository::executeExceptionMark( + const QJsonObject& body, std::function cb) { + wireObject( + api_.postJsonAsync(QStringLiteral("/business/exception/exception-mark/execute"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::batchCreateException(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/exception/batch/create"), body), + std::move(cb)); +} + +// ============================ 描述 / dsObject ============================ + +void ApiDatasetCommandRepository::getDsObjectDetail( + const QString& dsObjectId, std::function cb) { + wireObject( + api_.getAsync(QStringLiteral("/business/dsObject/getDetail/%1").arg(enc(dsObjectId))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::updateDsObject(const QJsonObject& body, + std::function cb) { + // 原版 URL 末尾带斜杠(updateProfileInversionDescription)。 + wireStatus(api_.putJsonAsync(QStringLiteral("/business/dsObject/updateDsObject/"), body), + std::move(cb)); +} + +} // namespace geopro::data diff --git a/src/data/api/ApiDatasetCommandRepository.hpp b/src/data/api/ApiDatasetCommandRepository.hpp new file mode 100644 index 0000000..94320a1 --- /dev/null +++ b/src/data/api/ApiDatasetCommandRepository.hpp @@ -0,0 +1,112 @@ +#pragma once +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::net { class ApiClient; } + +namespace geopro::data { + +// IDatasetCommandRepository 的真实 API 实现(反演相关写操作)。 +// 持 ApiClient&(共享会话),每个方法组装 body → 发请求 → 连 IApiCall::finished 回调; +// 句柄自管理(完成后 deleteLater),不手动 delete。与 ApiColorTemplateRepository 同模式。 +class ApiDatasetCommandRepository : public IDatasetCommandRepository { +public: + explicit ApiDatasetCommandRepository(net::ApiClient& api); + + void listInversionScripts( + const QString& dsObjectId, + std::function cb) override; + void getDynamicForm( + const QString& projectId, const QString& typeId, + std::function cb) override; + void submitInversionTask(const QString& dsId, const QString& scriptId, + const QJsonObject& properties, + std::function cb) override; + void createVisualResistivityData( + const QString& dsObjectId, const QString& scriptId, + const QJsonObject& scriptParamListJsonStr, + std::function cb) override; + + // measurement 散点 + void saveDisplayStatus(const QString& dsObjectId, const QJsonArray& ids, int status, + std::function cb) override; + void getScatterFilterConfig( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) override; + void applyScatterFilter(const QJsonObject& body, + std::function cb) override; + void saveRawData(const QJsonObject& body, + std::function cb) override; + void exportMeasurementDat( + const QString& dsId, int electrodePosition, int ipDataMark, int typeMeasurement, + std::function cb) override; + void loadMeasurementScatter( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) override; + + // 色阶 + void saveColorGradation(const QJsonObject& body, + std::function cb) override; + + // inversion + void saveInversionAsData(const QString& dsObjectId, const QString& name, + std::function cb) override; + void loadInversionGrid( + const QString& dsObjectId, + std::function cb) override; + void listGridAlgorithm( + const QString& dsObjectId, + std::function cb) override; + void getGridRawDataParams( + const QString& dsObjectId, + std::function cb) override; + void toGrid(const QJsonObject& body, + std::function cb) override; + + // 白化 + void listWhitenedData(const QString& projectId, const QString& tmObjectId, + std::function cb) override; + void whitenData(const QJsonObject& body, + std::function cb) override; + + // 滤波 + void listFilters(std::function cb) override; + void newFilter(const QJsonObject& body, + std::function cb) override; + void deleteFilter(const QString& id, + std::function cb) override; + void applyFilter(const QJsonObject& body, + std::function cb) override; + + // 异常 CRUD + void listExceptionTypes( + const QString& projectId, const QString& remarkSourceType, + std::function cb) override; + void getExceptionName( + const QString& exceptionTypeId, const QString& remarkSourceId, + std::function cb) override; + void newException(const QJsonObject& body, + std::function cb) override; + void deleteException(const QString& id, + std::function cb) override; + void updateException(const QJsonObject& body, + std::function cb) override; + + // 自动标注 + void executeExceptionMark( + const QJsonObject& body, + std::function cb) override; + void batchCreateException(const QJsonObject& body, + std::function cb) override; + + // 描述 / dsObject + void getDsObjectDetail( + const QString& dsObjectId, + std::function cb) override; + void updateDsObject(const QJsonObject& body, + std::function cb) override; + +private: + net::ApiClient& api_; +}; + +} // namespace geopro::data diff --git a/src/data/dto/DatasetChartDto.cpp b/src/data/dto/DatasetChartDto.cpp index a977d1b..7b26d7a 100644 --- a/src/data/dto/DatasetChartDto.cpp +++ b/src/data/dto/DatasetChartDto.cpp @@ -65,8 +65,12 @@ std::vector parseDatasetAnomalies(const QJsonArray& arr) { for (auto e : arr) { const QJsonObject o = e.toObject(); Anomaly a; + a.id = o.value("id").toString().toStdString(); // 删除/更新/定位需要持久化 id a.name = o.value("exceptionName").toString().toStdString(); a.typeName = o.value("exceptionTypeName").toString().toStdString(); + a.exceptionTypeId = o.value("exceptionTypeId").toString().toStdString(); + a.remark = o.value("remark").toString().toStdString(); + a.createTime = o.value("createTime").toString().toStdString(); const int mt = o.value("exceptionMarkType").toInt(2); // 1=点 2=线 3=面 a.markType = (mt >= 1 && mt <= 3) ? static_cast(mt) : AnomalyMarkType::Polyline; // 越界值兜底为线 diff --git a/src/data/dto/GridDto.cpp b/src/data/dto/GridDto.cpp index 1187a12..4ea88bd 100644 --- a/src/data/dto/GridDto.cpp +++ b/src/data/dto/GridDto.cpp @@ -1,5 +1,6 @@ #include "dto/GridDto.hpp" +#include #include #include "dto/TrajectoryDto.hpp" // parseGridHeaderTable(通用 gridHeaderDisplay+rowList 解析器)复用 @@ -28,6 +29,18 @@ TablePayload parseGridTable(const QJsonObject& data, int pageNo, int pageSize) { t.total = data.value(QStringLiteral("total")).toInt(t.total); t.pageNo = pn; t.pageSize = ps; + + // 功能按钮:data.functionList(DDGridFunctionVO[])→ 列表上方功能按钮行(原版 functionButtons)。 + // 仅 dd_grid 携带;缺省/空数组 → 不渲染工具条。enable 透传(视图按 v-show 语义过滤)。 + const QJsonArray fns = data.value(QStringLiteral("functionList")).toArray(); + for (const auto& v : fns) { + const QJsonObject o = v.toObject(); + TableFunctionButton b; + b.code = o.value(QStringLiteral("functionCode")).toString(); + b.nameChn = o.value(QStringLiteral("functionNameChn")).toString(); + b.enable = o.value(QStringLiteral("enable")).toBool(true); + t.functionButtons.push_back(std::move(b)); + } return t; } diff --git a/src/data/dto/GridDto.hpp b/src/data/dto/GridDto.hpp index 0728d6d..1519298 100644 --- a/src/data/dto/GridDto.hpp +++ b/src/data/dto/GridDto.hpp @@ -13,6 +13,7 @@ namespace geopro::data::dto { // 复用通用 parseGridHeaderTable(gridHeaderDisplay→x/y 列 + rowList→行),再前插「序号」列 // (vxe seq 列:按页偏移自增 = (pageNo-1)*pageSize + 行内序号);total 取 data.total(非 __rowTotal); // 回填 pageNo/pageSize 供视图渲染分页器。pageNo/pageSize 为本次请求参数(仓储已解析默认值后传入)。 +// 另解析 data.functionList(DDGridFunctionVO[])→ functionButtons(驱动列表上方功能按钮行,含「反演」)。 geopro::core::TablePayload parseGridTable(const QJsonObject& data, int pageNo, int pageSize); } // namespace geopro::data::dto diff --git a/src/data/dto/MeasurementDto.cpp b/src/data/dto/MeasurementDto.cpp index d206c02..ef00756 100644 --- a/src/data/dto/MeasurementDto.cpp +++ b/src/data/dto/MeasurementDto.cpp @@ -13,12 +13,15 @@ namespace { constexpr int kColHorizontalDistance = 0; // x 备选:平距 constexpr int kColSlopeDistance = 1; // x:斜距(默认) constexpr int kColPseudoDepth = 3; // y:伪深度(负,向下,默认) +constexpr int kColVisible = 7; // 可见性:0=显示 1=隐藏(displayStatus) +constexpr int kColId = 8; // 点 id(saveDisplayStatus ids[]) constexpr int kColElevationPseudoDepth = 9; // y 备选:伪深度+高程 constexpr int kColA = 10; constexpr int kColB = 11; constexpr int kColM = 12; constexpr int kColN = 13; -constexpr int kColValue = 16; // 色值:选中 v(默认视电阻率 R0) +constexpr int kColRow = 15; // DataRow([i]信息面板) +constexpr int kColValue = 16; // 色值/Pseu_Resis:选中 v(默认视电阻率 R0) double numAt(const QJsonArray& arr, int i) { if (i < 0 || i >= arr.size()) return 0.0; @@ -28,6 +31,15 @@ double numAt(const QJsonArray& arr, int i) { return 0.0; } +// 取字符串列(id 为字符串形态,如 "1453611521843200";数字形态退回字符串表示)。 +QString strAt(const QJsonArray& arr, int i) { + if (i < 0 || i >= arr.size()) return QString(); + const QJsonValue v = arr.at(i); + if (v.isString()) return v.toString(); + if (v.isDouble()) return QString::number(v.toDouble(), 'f', 0); + return QString(); +} + // 把 JSON 值预格式化为单元格 QString(数字按原值,null/缺省→空串)。 QString cellText(const QJsonValue& v) { if (v.isDouble()) { @@ -75,6 +87,11 @@ ScatterPayload parseMeasurementScatter(const QJsonObject& scatterData, const QJs s.b.push_back(numAt(row, kColB)); s.m.push_back(numAt(row, kColM)); s.n.push_back(numAt(row, kColN)); + // [i]信息 / 显隐持久化用元数据(与上面 push 同序、一一对应)。 + s.id.push_back(strAt(row, kColId).toStdString()); + s.displayStatus.push_back(static_cast(numAt(row, kColVisible))); + s.row.push_back(numAt(row, kColRow)); + s.pseu.push_back(numAt(row, kColValue)); // x/y 下拉本地重绘备选列(与上面 push 同序、一一对应 → 视图换 x/y 无需再请求)。 p.altXHorizontal.push_back(numAt(row, kColHorizontalDistance)); p.altXSlope.push_back(numAt(row, kColSlopeDistance)); diff --git a/src/data/repo/IDatasetCommandRepository.hpp b/src/data/repo/IDatasetCommandRepository.hpp new file mode 100644 index 0000000..6b40aa1 --- /dev/null +++ b/src/data/repo/IDatasetCommandRepository.hpp @@ -0,0 +1,217 @@ +#pragma once +#include + +#include +#include +#include + +#include "model/detail/DetailPayloads.hpp" // core::ScatterPayload(V 值重载返回完整载荷) + +namespace geopro::data { + +// 数据集详情视图「写操作 / 命令」仓储抽象(与只读 IAsyncDatasetRepository 平行)。 +// 本切片仅纳入「反演」相关的 4 个端点(measurement 反演运算 / 生成视电阻率 + 共享的 +// 模型列表 / 动态表单查询)。回调式异步:仓储只做「组装请求 + 取数组/状态」的传输职责; +// 领域解析(动态表单分组/字段控件)留在对话框(见 InversionFormDialog 的纯函数)。 +// 回调在 Qt 主线程经 IApiCall::finished 触发;调用方须用 QPointer 守卫可能已关闭的对话框/视图。 +class IDatasetCommandRepository { +public: + virtual ~IDatasetCommandRepository() = default; + + // 反演模型列表:GET /business/outerInversion/query/script?dsObjectId={ds}。 + // measurement「反演运算」与「生成视电阻率」共用此端点(原版 getInversionOptions / + // getProcessScriptList 同 URL)。回调 list = 响应 data 数组(每项含 label/value/code)。 + virtual void listInversionScripts( + const QString& dsObjectId, + std::function cb) = 0; + + // 动态表单:POST /business/project/getDynamicForm body {projectId, type:6, typeId}。 + // 回调 data = 响应 data 对象(含 formList 数组:分组 → values 字段)。 + virtual void getDynamicForm( + const QString& projectId, const QString& typeId, + std::function cb) = 0; + + // 提交反演运算:POST /business/outerInversion/submitInversionTask + // body {dsId, scriptId, properties:{fieldCode:value,...}}(对应原版 postInversionTask)。 + virtual void submitInversionTask(const QString& dsId, const QString& scriptId, + const QJsonObject& properties, + std::function cb) = 0; + + // 生成视电阻率数据:POST /business/dd/ert/measurement/createVisualResistivityData + // body {dsObjectId, scriptId, scriptParamListJsonStr:{fieldCode:value,...}} + // (对应原版 createVisualResistivityData,散点图模块版本)。 + virtual void createVisualResistivityData( + const QString& dsObjectId, const QString& scriptId, + const QJsonObject& scriptParamListJsonStr, + std::function cb) = 0; + + // ============================ measurement 散点相关 ============================ + + // 散点可见性持久化:POST /business/dd/ert/measurement/saveDisplayStatus + // body {dsObjectId, ids:[...], status}(对应原版 updateScatterDataVisible)。 + // 注:原版字段名为 dsObjectId(非 dsObjectId 之外的 dsId),status 为 int(0/1)。 + virtual void saveDisplayStatus(const QString& dsObjectId, const QJsonArray& ids, int status, + std::function cb) = 0; + + // 散点过滤配置查询:GET /business/scatterPlotDataFilter/getDataFilterConfig + // ?dsObjectId={ds}&vFieldCode={vf}(对应原版 queryScatterFilterInfo)。 + // 回调 data = 响应 data 对象(含 min/max 等配置)。 + virtual void getScatterFilterConfig( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) = 0; + + // 应用散点过滤:POST /business/scatterPlotDataFilter + // body {sourceDsObjectId, sourceVFieldCode, min, max}(对应原版 applyScatterFilterInfo)。 + // min/max 为字符串/数值由调用方组装,故收已组装 body。 + virtual void applyScatterFilter(const QJsonObject& body, + std::function cb) = 0; + + // 另存原始数据:POST /business/dd/ert/measurement/saveRawData + // body {dsObjectId, ...}(对应原版 saveRawDataValue,projectSpace 模块)。 + // 原版仅约束 dsObjectId 必填;operationType/name 由调用方按场景组装,故收 body。 + virtual void saveRawData(const QJsonObject& body, + std::function cb) = 0; + + // 导出 DAT:GET /business/dd/ert/measurement/rs2d/export + // ?dsId={ds}&electrodePosition={ep}&ipDataMark={ip}&typeMeasurement={tm} + // (对应原版 exportScatterData2Dat)。回调回传 base64 fileData 与 fileName, + // 实际落盘/下载交由 UI 层处理。 + virtual void exportMeasurementDat( + const QString& dsId, int electrodePosition, int ipDataMark, int typeMeasurement, + std::function cb) = 0; + + // V 值切换重载散点(M6):并发拉 scatter/graph(带 vFieldCode) + 色阶 getDetail(type3, + // businessCode=vFieldCode),解析为完整 ScatterPayload(对应原版 V 值 change 重新请求散点+色阶)。 + // 与 ApiDatasetRepository 的初始 measurement 加载同端点/同解析,仅 vFieldCode 可变。 + virtual void loadMeasurementScatter( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) = 0; + + // ============================ 色阶(lvl)相关 ============================ + + // 保存 .lvl 色阶:POST /business/lvl/colorGradation + // body {dsObjectId, templateId, businessCode, projectId, properties} + // (对应原版 newLvlColorLevel)。measurement 散点与 inversion 原数据色阶配置共用。 + virtual void saveColorGradation(const QJsonObject& body, + std::function cb) = 0; + + // ============================ inversion 相关 ============================ + + // inversion 另存为数据:POST /business/dd/ert/inversion/saveAsData + // body {dsObjectId, name}(对应原版 saveVisualResistivityData)。 + virtual void saveInversionAsData(const QString& dsObjectId, const QString& name, + std::function cb) = 0; + + // 网格视图重载(处理类操作=网格化/白化/滤波 成功后重绘):并发拉 rows + 色阶 getDetail(type2) + // + 异常,解析为 ContourPayload(与 ApiDatasetRepository::makeInversionGrid 同端点/同解析)。 + // 与 loadMeasurementScatter 同范式:仓储只读重载方法,UI 在成功回调 setGridData 重绘。 + virtual void loadInversionGrid( + const QString& dsObjectId, + std::function cb) = 0; + + // 网格化:查询算法模型 GET /business/dd/ert/inversion/queryAlgorithmModel/{ds} + // (对应原版 getGridModel)。回调 list = data.value 数组。 + virtual void listGridAlgorithm( + const QString& dsObjectId, + std::function cb) = 0; + + // 网格化:获取原始数据参数 GET /business/dd/ert/inversion/getRawData/{ds} + // (对应原版 getGridParams)。回调 data = 响应 data 对象。 + virtual void getGridRawDataParams( + const QString& dsObjectId, + std::function cb) = 0; + + // 网格化:执行 POST /business/dd/ert/inversion/grid + // body 含 {dsObjectId, actionCode, saveDataValueType, ...}(对应原版 toGridTheData 全字段)。 + virtual void toGrid(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 白化相关 ============================ + + // 白化:查询可白化数据列表 POST /business/dsObject/queryWhitenedDataList + // body {projectId, tmObjectId}(对应原版 getWhitenedDataList)。 + // 回调 list = data.value 数组。 + virtual void listWhitenedData(const QString& projectId, const QString& tmObjectId, + std::function cb) = 0; + + // 白化:执行 POST /business/dd/ert/inversion/whitenedData + // body 含 {dsObjectId, whiteningMethod, ...}(对应原版 whitenTheData,3 种 method 字段差异由调用方组装)。 + virtual void whitenData(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 滤波相关 ============================ + + // 滤波:查询滤波器列表 GET /business/filter/queryFilter(对应原版 getFilters,无 query)。 + // 回调 list = data.value 数组。 + virtual void listFilters(std::function cb) = 0; + + // 滤波:新增滤波器 POST /business/filter + // body 含 {projectId, parentId, rowColumValue, type, ...}(对应原版 newTheFilter)。 + virtual void newFilter(const QJsonObject& body, + std::function cb) = 0; + + // 滤波:删除滤波器 DELETE /business/filter/delete/{id}(对应原版 deleteTheFilter)。 + virtual void deleteFilter(const QString& id, + std::function cb) = 0; + + // 滤波:应用滤波处理数据 POST /business/dd/ert/inversion/filterData + // body 含 {dsObjectId, rowColumValue, ...}(对应原版 useFilterToProcessData 全字段)。 + virtual void applyFilter(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 异常 CRUD ============================ + + // 异常类型列表:GET /business/exceptionType/queryExceptionTypeByProjectIdAndType/{pid}/{type} + // (对应原版 queryExceptionTypeData,type = remarkSourceType)。回调 list = data.value 数组。 + virtual void listExceptionTypes( + const QString& projectId, const QString& remarkSourceType, + std::function cb) = 0; + + // 获取异常名称:POST /business/exception/getExceptionName + // body {exceptionTypeId, remarkSourceId}(对应原版 queryExceptionNameInProfileInversion)。 + // 回调 data = 响应 data 对象(含建议名称)。 + virtual void getExceptionName( + const QString& exceptionTypeId, const QString& remarkSourceId, + std::function cb) = 0; + + // 新增异常:POST /business/exception + // body 含 {exceptionName, exceptionTypeId, location, projectId, remarkSourceId, remarkSourceType, remark} + // (对应原版 newExceptionInProfileInversion 全字段)。 + virtual void newException(const QJsonObject& body, + std::function cb) = 0; + + // 删除异常:DELETE /business/exception/{id}(对应原版 deleteExceptionDataInProfileInversion)。 + virtual void deleteException(const QString& id, + std::function cb) = 0; + + // 更新异常:PUT /business/exception + // body 含 {id, exceptionName, remark, ...}(对应原版 updateExceptionDataInProfileInversion)。 + virtual void updateException(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 自动标注 ============================ + + // 执行自动标注:POST /business/exception/exception-mark/execute(对应原版 executeExceptionMark)。 + virtual void executeExceptionMark(const QJsonObject& body, + std::function cb) = 0; + + // 批量创建异常:POST /business/exception/batch/create(对应原版 batchCreateException)。 + virtual void batchCreateException(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 描述 / dsObject ============================ + + // 获取 dsObject 详情:GET /business/dsObject/getDetail/{ds} + // (对应原版 getProfileInversionDescription)。回调 data = 响应 data 对象(含 description 等)。 + virtual void getDsObjectDetail( + const QString& dsObjectId, + std::function cb) = 0; + + // 更新 dsObject:PUT /business/dsObject/updateDsObject/ + // body 含 {dsObjectId, description, attachedParameters:{deltaContent}, ...} + // (对应原版 updateProfileInversionDescription,注意原版 URL 末尾带斜杠)。 + virtual void updateDsObject(const QJsonObject& body, + std::function cb) = 0; +}; + +} // namespace geopro::data diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2358282..753159f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -139,6 +139,26 @@ target_sources(geopro_tests PRIVATE app/test_dataset_dimension.cpp ${CMAKE_SOURCE_DIR}/src/app/DatasetDimension.cpp ) +# 反演动态表单解析/组装纯函数(parseDynamicForm/assembleFieldMap,仅 Qt6::Core JSON)。 +target_sources(geopro_tests PRIVATE + app/test_inversion_form_parse.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/InversionFormParse.cpp +) +# measurement 散点纯逻辑(值类型变换 / 显隐 id 收集 / 过滤体 / 另存体,Qt6::Core JSON + core model)。 +target_sources(geopro_tests PRIVATE + app/test_scatter_data_ops.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ScatterDataOps.cpp +) +# 反演处理类纯逻辑(网格化/白化/滤波 请求体组装 + code 映射,仅 Qt6::Core JSON)。 +target_sources(geopro_tests PRIVATE + app/test_inversion_process_ops.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/InversionProcessOps.cpp +) +# 等值线 Douglas-Peucker 抽稀(I8 简化容差,纯几何,无 Qt/VTK 依赖)。 +target_sources(geopro_tests PRIVATE + app/test_contour_simplify.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ContourSimplify.cpp +) # controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 datasetOpened/tabReady/loadFailed)。 find_package(Qt6 COMPONENTS Test REQUIRED) diff --git a/tests/app/test_contour_simplify.cpp b/tests/app/test_contour_simplify.cpp new file mode 100644 index 0000000..9dbcded --- /dev/null +++ b/tests/app/test_contour_simplify.cpp @@ -0,0 +1,42 @@ +#include + +#include "panels/chart/ContourSimplify.hpp" + +using geopro::app::douglasPeucker; +using geopro::core::Vec2; + +TEST(ContourSimplify, TolZeroReturnsOriginal) { + std::vector pts{{0, 0}, {1, 0.01}, {2, 0}}; + auto out = douglasPeucker(pts, 0.0); + EXPECT_EQ(out.size(), 3u); +} + +TEST(ContourSimplify, ShortPolylineUnchanged) { + std::vector pts{{0, 0}, {5, 5}}; + auto out = douglasPeucker(pts, 1.0); + EXPECT_EQ(out.size(), 2u); // 点数<=2 原样 +} + +TEST(ContourSimplify, CollinearMiddleDropped) { + // 共线点:中点在容差内 → 被抽掉,只保留首尾。 + std::vector pts{{0, 0}, {1, 0}, {2, 0}, {3, 0}}; + auto out = douglasPeucker(pts, 0.1); + ASSERT_EQ(out.size(), 2u); + EXPECT_DOUBLE_EQ(out.front().x, 0.0); + EXPECT_DOUBLE_EQ(out.back().x, 3.0); +} + +TEST(ContourSimplify, SignificantPointKept) { + // 中间有明显偏移的点(偏 1.0 > tol 0.5)→ 保留。 + std::vector pts{{0, 0}, {1, 1.0}, {2, 0}}; + auto out = douglasPeucker(pts, 0.5); + EXPECT_EQ(out.size(), 3u); +} + +TEST(ContourSimplify, LargerTolDropsMorePoints) { + std::vector pts{{0, 0}, {1, 0.2}, {2, 0}, {3, 0.2}, {4, 0}}; + auto coarse = douglasPeucker(pts, 0.5); // 偏移 0.2 < 0.5 → 全抽成首尾 + auto fine = douglasPeucker(pts, 0.05); // 0.2 > 0.05 → 保留中间峰 + EXPECT_LT(coarse.size(), fine.size()); + EXPECT_EQ(coarse.size(), 2u); +} diff --git a/tests/app/test_inversion_form_parse.cpp b/tests/app/test_inversion_form_parse.cpp new file mode 100644 index 0000000..035e133 --- /dev/null +++ b/tests/app/test_inversion_form_parse.cpp @@ -0,0 +1,87 @@ +#include + +#include +#include + +#include "panels/chart/InversionFormParse.hpp" + +using namespace geopro::app; + +namespace { + +// 取自原版动态表单响应 data 结构(POST /business/project/getDynamicForm): +// formList → [{groupName, values:[{fieldCode, fieldName, optionsObject:[{label,value}]}]}]。 +const char* kFormData = R"({ + "formList": [ + { + "groupName": "基础参数", + "values": [ + { "fieldCode": "elevation", "fieldName": "是否含高程", + "optionsObject": [ {"label": "是", "value": "1"}, {"label": "否", "value": "0"} ] }, + { "fieldCode": "method", "fieldName": "反演方法", + "optionsObject": [ {"label": "最小二乘", "value": "ls"} ] } + ] + }, + { + "groupName": "高级参数", + "values": [ + { "fieldCode": "noOptions", "fieldName": "无选项字段", "optionsObject": [] } + ] + } + ] +})"; + +QJsonObject formData() { return QJsonDocument::fromJson(kFormData).object(); } + +} // namespace + +TEST(InversionFormParse, ParsesGroupsFieldsAndOptions) { + const auto groups = parseDynamicForm(formData()); + + ASSERT_EQ(groups.size(), 2u); + EXPECT_EQ(groups[0].groupName.toStdString(), std::string("基础参数")); + ASSERT_EQ(groups[0].fields.size(), 2u); + + const auto& f0 = groups[0].fields[0]; + EXPECT_EQ(f0.fieldCode.toStdString(), std::string("elevation")); + EXPECT_EQ(f0.fieldName.toStdString(), std::string("是否含高程")); + ASSERT_EQ(f0.options.size(), 2u); + EXPECT_EQ(f0.options[0].label.toStdString(), std::string("是")); + EXPECT_EQ(f0.options[0].value.toStdString(), std::string("1")); + + EXPECT_TRUE(groups[1].fields[0].options.empty()); +} + +TEST(InversionFormParse, EmptyDataYieldsNoGroups) { + EXPECT_TRUE(parseDynamicForm(QJsonObject{}).empty()); +} + +TEST(InversionFormParse, AssembleUsesSelectedValues) { + const auto groups = parseDynamicForm(formData()); + QJsonObject selected{{"elevation", "0"}, {"method", "ls"}}; + + // fillDefaults=false(反演运算):仅含已选且非空的字段;无选值字段不进体。 + const QJsonObject out = assembleFieldMap(groups, selected, /*fillDefaults*/ false); + EXPECT_EQ(out.value("elevation").toString().toStdString(), std::string("0")); + EXPECT_EQ(out.value("method").toString().toStdString(), std::string("ls")); + EXPECT_FALSE(out.contains("noOptions")); // 无选项 + 未选 → 不进体 +} + +TEST(InversionFormParse, AssembleFillsDefaultsWhenRequested) { + const auto groups = parseDynamicForm(formData()); + + // fillDefaults=true(生成视电阻率):空选值回退首个选项。 + const QJsonObject out = assembleFieldMap(groups, QJsonObject{}, /*fillDefaults*/ true); + EXPECT_EQ(out.value("elevation").toString().toStdString(), std::string("1")); // 首项 value + EXPECT_EQ(out.value("method").toString().toStdString(), std::string("ls")); + EXPECT_FALSE(out.contains("noOptions")); // 无选项 → 即便 fillDefaults 也无值可填 +} + +TEST(InversionFormParse, AssembleOmitsEmptySelectedValues) { + const auto groups = parseDynamicForm(formData()); + QJsonObject selected{{"elevation", ""}}; // 显式空字符串 + + // fillDefaults=false:空值不进体(复刻原版 handleConfirm 的 if(selectedValue))。 + const QJsonObject out = assembleFieldMap(groups, selected, /*fillDefaults*/ false); + EXPECT_FALSE(out.contains("elevation")); +} diff --git a/tests/app/test_inversion_process_ops.cpp b/tests/app/test_inversion_process_ops.cpp new file mode 100644 index 0000000..ec5ec93 --- /dev/null +++ b/tests/app/test_inversion_process_ops.cpp @@ -0,0 +1,130 @@ +#include + +#include +#include + +#include "panels/chart/InversionProcessOps.hpp" + +using namespace geopro::app; + +// ── I1/O1 网格化 toGrid 体 ──────────────────────────────────────────────── +TEST(InversionProcessOps, GridToBodyFieldsLinear) { + GridToParams p; + p.dsObjectId = QStringLiteral("ds1"); + p.actionCode = QStringLiteral("alg_kriging"); + p.xMin = -1.0; p.xMax = 99.0; p.yMin = -2.0; p.yMax = 50.0; + p.vMin = 5.0; p.vMax = 500.0; + p.xSize = 100; p.ySize = 60; + p.xSpacing = 1.0; p.ySpacing = 0.86; + p.logFormat = false; + + auto b = buildGridToBody(p); + EXPECT_EQ(b.value("dsObjectId").toString().toStdString(), "ds1"); + EXPECT_EQ(b.value("actionCode").toString().toStdString(), "alg_kriging"); + EXPECT_DOUBLE_EQ(b.value("xminValue").toDouble(), -1.0); + EXPECT_DOUBLE_EQ(b.value("xmaxValue").toDouble(), 99.0); + EXPECT_DOUBLE_EQ(b.value("yminValue").toDouble(), -2.0); + EXPECT_DOUBLE_EQ(b.value("ymaxValue").toDouble(), 50.0); + EXPECT_EQ(b.value("xsize").toInt(), 100); // 小写 + EXPECT_EQ(b.value("ysize").toInt(), 60); // 小写 + EXPECT_DOUBLE_EQ(b.value("xSpacing").toDouble(), 1.0); // 驼峰 + EXPECT_DOUBLE_EQ(b.value("ySpacing").toDouble(), 0.86); // 驼峰 + EXPECT_DOUBLE_EQ(b.value("vminValue").toDouble(), 5.0); + EXPECT_DOUBLE_EQ(b.value("vmaxValue").toDouble(), 500.0); + EXPECT_EQ(b.value("saveDataValueType").toInt(), 1); // 线性=1 +} + +TEST(InversionProcessOps, GridToBodyLogFormat) { + GridToParams p; + p.logFormat = true; + EXPECT_EQ(buildGridToBody(p).value("saveDataValueType").toInt(), 2); // 对数=2 +} + +// ── I3 白化 whitenData 体 ────────────────────────────────────────────────── +TEST(InversionProcessOps, WhitenMethod1ExternalSendsBoolWay) { + WhitenParams p; + p.dsObjectId = QStringLiteral("ds1"); + p.whiteningMethod = 1; + p.boundaryExtension = 3.5; + p.whiteningType = 0; // 外部 → whitenedWay=true + auto b = buildWhitenBody(p); + EXPECT_EQ(b.value("whiteningMethod").toInt(), 1); + EXPECT_DOUBLE_EQ(b.value("boundaryExtension").toDouble(), 3.5); + EXPECT_TRUE(b.value("whitenedWay").toBool()); + EXPECT_FALSE(b.contains("whitenedDataId")); +} + +TEST(InversionProcessOps, WhitenMethod1InternalWayFalse) { + WhitenParams p; + p.whiteningMethod = 1; + p.whiteningType = 1; // 内部 → whitenedWay=false + EXPECT_FALSE(buildWhitenBody(p).value("whitenedWay").toBool()); +} + +TEST(InversionProcessOps, WhitenMethod2SendsFileId) { + WhitenParams p; + p.whiteningMethod = 2; + p.whitenedDataId = QStringLiteral("file-9"); + auto b = buildWhitenBody(p); + EXPECT_EQ(b.value("whitenedDataId").toString().toStdString(), "file-9"); + EXPECT_FALSE(b.contains("boundaryExtension")); +} + +TEST(InversionProcessOps, WhitenMethod3SendsSubType) { + WhitenParams p; + p.whiteningMethod = 3; + p.modelWhiteningSubType = 1; // 矩形 + auto b = buildWhitenBody(p); + EXPECT_EQ(b.value("modelWhiteningSubType").toInt(), 1); +} + +// ── I4 滤波 applyFilter 体 + code 映射 ────────────────────────────────────── +TEST(InversionProcessOps, BoundaryAndNoDataCodes) { + EXPECT_EQ(filterBoundaryCode(QStringLiteral("whitening")), 1); + EXPECT_EQ(filterBoundaryCode(QStringLiteral("skip")), 2); + EXPECT_EQ(filterBoundaryCode(QStringLiteral("edgePoint")), 3); + EXPECT_EQ(filterBoundaryCode(QStringLiteral("filling")), 4); + EXPECT_EQ(filterNoDataCode(QStringLiteral("expansion")), 1); + EXPECT_EQ(filterNoDataCode(QStringLiteral("retain")), 2); + EXPECT_EQ(filterNoDataCode(QStringLiteral("skip")), 3); + EXPECT_EQ(filterNoDataCode(QStringLiteral("filling")), 4); +} + +TEST(InversionProcessOps, FilterApplyBodyFields) { + FilterApplyParams p; + p.dsObjectId = QStringLiteral("ds1"); + p.dataEdge = QStringLiteral("filling"); + p.dataEdgeValue = 12.0; + p.noDataPoints = QStringLiteral("skip"); + p.noDataValue = 0.0; + p.number = 3; + p.row = 3; p.column = 3; + p.matrix = {{0, 1, 0}, {1, 1, 1}, {0, 1, 0}}; + p.filteringMethod = QStringLiteral("中值滤波"); + + auto b = buildFilterApplyBody(p); + EXPECT_EQ(b.value("boundary").toInt(), 4); // filling + EXPECT_DOUBLE_EQ(b.value("boundaryValue").toDouble(), 12.0); + EXPECT_EQ(b.value("noDataPoints").toInt(), 3); // skip + EXPECT_DOUBLE_EQ(b.value("noDataPointsValue").toDouble(), 0.0); + EXPECT_EQ(b.value("number").toInt(), 3); + EXPECT_EQ(b.value("row").toInt(), 3); + EXPECT_EQ(b.value("column").toInt(), 3); + EXPECT_EQ(b.value("filteringMethod").toString().toStdString(), std::string("中值滤波")); + const auto form = b.value("rowColumValue").toObject().value("form").toArray(); + ASSERT_EQ(form.size(), 3); + EXPECT_DOUBLE_EQ(form.at(1).toArray().at(1).toDouble(), 1.0); // 中心 + EXPECT_DOUBLE_EQ(form.at(0).toArray().at(0).toDouble(), 0.0); // 角 +} + +TEST(InversionProcessOps, NewFilterBodyFields) { + auto b = buildNewFilterBody(QStringLiteral("自定义滤波器1"), QStringLiteral("pj1"), + QStringLiteral("grp-7"), {{1, 1}, {1, 1}}); + EXPECT_EQ(b.value("type").toInt(), 1); + EXPECT_EQ(b.value("name").toString().toStdString(), std::string("自定义滤波器1")); + EXPECT_EQ(b.value("projectId").toString().toStdString(), "pj1"); + EXPECT_EQ(b.value("parentId").toString().toStdString(), "grp-7"); + const auto form = b.value("rowColumValue").toObject().value("form").toArray(); + ASSERT_EQ(form.size(), 2); + EXPECT_EQ(form.at(0).toArray().size(), 2); +} diff --git a/tests/app/test_scatter_data_ops.cpp b/tests/app/test_scatter_data_ops.cpp new file mode 100644 index 0000000..2eef3fe --- /dev/null +++ b/tests/app/test_scatter_data_ops.cpp @@ -0,0 +1,87 @@ +#include + +#include + +#include + +#include "panels/chart/ScatterDataOps.hpp" + +using namespace geopro::app; +using geopro::core::ScatterField; + +TEST(ScatterDataOps, ValueTypeFromCode) { + EXPECT_EQ(scatterValueTypeFromCode(QStringLiteral("linearity")), ScatterValueType::Linearity); + EXPECT_EQ(scatterValueTypeFromCode(QStringLiteral("inverse")), ScatterValueType::Inverse); + EXPECT_EQ(scatterValueTypeFromCode(QStringLiteral("logarithm")), ScatterValueType::Logarithm); + // 未知回退线性。 + EXPECT_EQ(scatterValueTypeFromCode(QStringLiteral("xyz")), ScatterValueType::Linearity); +} + +TEST(ScatterDataOps, ApplyValueTypeLinearIsIdentity) { + std::vector v{1.0, 10.0, 100.0}; + auto out = applyScatterValueType(v, ScatterValueType::Linearity); + ASSERT_EQ(out.size(), 3u); + EXPECT_DOUBLE_EQ(out[0], 1.0); + EXPECT_DOUBLE_EQ(out[2], 100.0); +} + +TEST(ScatterDataOps, ApplyValueTypeInverse) { + std::vector v{2.0, 0.0, -4.0}; + auto out = applyScatterValueType(v, ScatterValueType::Inverse); + EXPECT_DOUBLE_EQ(out[0], 0.5); + EXPECT_DOUBLE_EQ(out[1], 0.0); // v==0 → 保持 0(避免 inf) + EXPECT_DOUBLE_EQ(out[2], -0.25); +} + +TEST(ScatterDataOps, ApplyValueTypeLog) { + std::vector v{100.0, 0.0, -5.0}; + auto out = applyScatterValueType(v, ScatterValueType::Logarithm); + EXPECT_DOUBLE_EQ(out[0], 2.0); // log10(100) + EXPECT_DOUBLE_EQ(out[1], 0.0); // v<=0 → 保持原值 + EXPECT_DOUBLE_EQ(out[2], -5.0); // v<=0 → 保持原值 +} + +TEST(ScatterDataOps, CollectIdsForHideTakesVisible) { + ScatterField f; + f.id = {"a", "b", "c", ""}; + f.displayStatus = {0, 1, 0, 0}; // a,c 可见;b 隐藏;空 id 跳过 + // 隐藏:取可见点(a, c)。 + auto hideIds = collectScatterIds(f, /*hide*/ true); + ASSERT_EQ(hideIds.size(), 2); + EXPECT_EQ(hideIds.at(0).toString().toStdString(), "a"); + EXPECT_EQ(hideIds.at(1).toString().toStdString(), "c"); +} + +TEST(ScatterDataOps, CollectIdsForShowTakesHidden) { + ScatterField f; + f.id = {"a", "b", "c"}; + f.displayStatus = {0, 1, 1}; // b,c 隐藏 + auto showIds = collectScatterIds(f, /*hide*/ false); + ASSERT_EQ(showIds.size(), 2); + EXPECT_EQ(showIds.at(0).toString().toStdString(), "b"); + EXPECT_EQ(showIds.at(1).toString().toStdString(), "c"); +} + +TEST(ScatterDataOps, FilterBodyFields) { + auto body = buildScatterFilterBody(QStringLiteral("ds1"), QStringLiteral("R0"), -10.5, 200.0); + EXPECT_EQ(body.value("sourceDsObjectId").toString().toStdString(), "ds1"); + EXPECT_EQ(body.value("sourceVFieldCode").toString().toStdString(), "R0"); + EXPECT_DOUBLE_EQ(body.value("min").toDouble(), -10.5); + EXPECT_DOUBLE_EQ(body.value("max").toDouble(), 200.0); +} + +TEST(ScatterDataOps, SaveRawDataBodyNewHasName) { + auto body = buildSaveRawDataBody(QStringLiteral("ds1"), /*operationType*/ 1, + QStringLiteral("新数据")); + EXPECT_EQ(body.value("dsId").toString().toStdString(), "ds1"); + EXPECT_EQ(body.value("operationType").toInt(), 1); + EXPECT_TRUE(body.contains("name")); + EXPECT_EQ(body.value("name").toString().toStdString(), std::string("新数据")); +} + +TEST(ScatterDataOps, SaveRawDataBodyOverwriteOmitsName) { + auto body = buildSaveRawDataBody(QStringLiteral("ds1"), /*operationType*/ 0, + QStringLiteral("ignored")); + EXPECT_EQ(body.value("operationType").toInt(), 0); + EXPECT_FALSE(body.contains("name")); // 覆盖不带 name +} diff --git a/tests/data/test_dataset_chart_dto.cpp b/tests/data/test_dataset_chart_dto.cpp index 0c36ac1..030b50c 100644 --- a/tests/data/test_dataset_chart_dto.cpp +++ b/tests/data/test_dataset_chart_dto.cpp @@ -56,3 +56,18 @@ TEST(DatasetChartDto, ParsesAnomalyPolyline) { EXPECT_DOUBLE_EQ(v[0].localPts[1].x, 3.0); EXPECT_TRUE(v[0].dashed); } +TEST(DatasetChartDto, ParsesAnomalyIdentityFields) { + // I10/I11/I12 需要 id(删除/更新/定位)、备注、异常类型 id、创建时间。 + auto arr = QJsonDocument::fromJson( + R"([{"id":"exc-1","exceptionName":"A1","exceptionTypeName":"Zone", + "exceptionTypeId":"type-9","exceptionMarkType":3,"remark":"备注X", + "createTime":"2026-06-22 10:00:00", + "location":{"coordinate":[{"x":1,"y":2},{"x":3,"y":4},{"x":5,"y":6}]}}])").array(); + auto v = parseDatasetAnomalies(arr); + ASSERT_EQ(v.size(), 1u); + EXPECT_EQ(v[0].id, "exc-1"); + EXPECT_EQ(v[0].exceptionTypeId, "type-9"); + EXPECT_EQ(v[0].remark, "备注X"); + EXPECT_EQ(v[0].createTime, "2026-06-22 10:00:00"); + EXPECT_EQ(static_cast(v[0].markType), 3); +} diff --git a/tests/data/test_grid_dto.cpp b/tests/data/test_grid_dto.cpp index 68c0e61..0f11c9c 100644 --- a/tests/data/test_grid_dto.cpp +++ b/tests/data/test_grid_dto.cpp @@ -75,4 +75,35 @@ TEST(GridDto, EmptyDataYieldsSeqOnlyColumnNoRows) { EXPECT_EQ(t.columns[0].code.toStdString(), "__seq"); EXPECT_EQ(t.rows.size(), 0u); EXPECT_EQ(t.total, 0); + EXPECT_TRUE(t.functionButtons.empty()); // 无 functionList → 无功能按钮 +} + +TEST(GridDto, EmptyFunctionListYieldsNoButtons) { + // 真实夹具 functionList:[] → functionButtons 空(不渲染工具条)。 + auto t = parseGridTable(gridData(), 1, 50); + EXPECT_TRUE(t.functionButtons.empty()); +} + +TEST(GridDto, ParsesFunctionListIntoButtons) { + // functionList = DDGridFunctionVO[](functionCode/functionNameChn/enable)→ functionButtons。 + const char* kData = R"({ + "gridHeaderDisplay": [ + { "columnCode": "x", "columnNameChn": "x", "columnSort": 1 }, + { "columnCode": "y", "columnNameChn": "y", "columnSort": 2 } + ], + "functionList": [ + { "functionCode": "inversion", "functionNameChn": "反演", "enable": true }, + { "functionCode": "other", "functionNameChn": "其它", "enable": false } + ], + "total": 1, + "rowList": [ { "x": 1.0, "y": 2.0, "id": "1" } ] + })"; + auto t = parseGridTable(QJsonDocument::fromJson(kData).object(), 1, 50); + + ASSERT_EQ(t.functionButtons.size(), 2u); + EXPECT_EQ(t.functionButtons[0].code.toStdString(), "inversion"); + EXPECT_EQ(t.functionButtons[0].nameChn.toStdString(), "反演"); + EXPECT_TRUE(t.functionButtons[0].enable); + EXPECT_EQ(t.functionButtons[1].code.toStdString(), "other"); + EXPECT_FALSE(t.functionButtons[1].enable); // enable=false(视图按 v-show 不渲染) } diff --git a/tests/data/test_measurement_dto.cpp b/tests/data/test_measurement_dto.cpp index 25f3b0d..1ca2c6f 100644 --- a/tests/data/test_measurement_dto.cpp +++ b/tests/data/test_measurement_dto.cpp @@ -103,6 +103,26 @@ TEST(MeasurementDto, ParsesScatterPositionalRows) { EXPECT_DOUBLE_EQ(s.electrodeNo[2], 3.0); } +TEST(MeasurementDto, ParsesScatterPointMetadata) { + // [i]信息 / 显隐持久化用元数据:id(col8) / displayStatus(col7) / row(col15) / pseu(col16)。 + auto p = parseMeasurementScatter(obj(kScatterData), obj(kColorBarData)); + const auto& s = p.scatter; + + ASSERT_EQ(s.id.size(), 3u); + EXPECT_EQ(s.id[0], "1453611521843200"); + EXPECT_EQ(s.id[1], "1453611521843201"); + + ASSERT_EQ(s.displayStatus.size(), 3u); + EXPECT_EQ(s.displayStatus[0], 0); // col7=0 → 可见 + + ASSERT_EQ(s.row.size(), 3u); + EXPECT_DOUBLE_EQ(s.row[0], 1.0); // DataRow = col15 + EXPECT_DOUBLE_EQ(s.row[2], 3.0); + + ASSERT_EQ(s.pseu.size(), 3u); + EXPECT_DOUBLE_EQ(s.pseu[0], 242.952988); // Pseu_Resis = col16 +} + // scatter/graph 含 scatterGraphConf 时:工具条下拉项 + 默认选中 + x/y 本地重绘备选列。 static const char* kScatterDataWithConf = R"({ "electrodeList": [