feat(workbench): 对象/数据集视图交互 — 右键菜单/筛选/删除 + 动态表单编辑保存与新建TM

Batch1(骨架+读联动+删除+筛选):
- ApiClient 补 putJsonAsync/deleteAsync
- 删除 GS/TM/DS(真实 DELETE + 确认框 + 成功刷新)
- 对象树右键菜单(9项)/数据集右键菜单:属性·异常详情·详情联动接现有面板;显示隐藏/定位等 2D/3D 占位
- 快速筛选器(对象按类型批量勾选/反选;数据集按类型+创建日期客户端过滤)+ 数据集单击 tooltip
- 复选框手势修复:点勾选不再触发"选中"重载(viewport 事件过滤 + 复选框命中判定)

Batch2(动态表单引擎+写操作):
- DynamicFormEditor:getDynamicForm schema 驱动(comp1/4/6/7/8 + 必填校验 + properties 预填)
- ObjectFormDialog:拉真实表单→校验→提交
- 编辑保存 PUT /business/{gs|tm}Object(成功刷新,失败回显后端 msg)
- 新建 TM:queryTmType 选型→空表单→POST(带父 GS 上下文)
- 插件子菜单:列出真实 model/list(启动缓存)

数据层:
- EditableForm/TmTypeOption/ModelInfo DTO + parseEditableForm/parseTmTypes/parseModels
- StructNode.typeId;repo loadEditableForm/queryTmTypes/submitObject/listModels;controller currentProjectId()

注:保存/新建请求体为推断(OpenAPI 未文档化提交 body),待真实提交验证后定版。
docs: plans/2026-06-13-object-dataset-interactions.md, specs/2026-06-13-batch2-object-dataset-dialogs.md
This commit is contained in:
gaozheng 2026-06-13 20:03:30 +08:00
parent 1cc5400e73
commit 1f0081ee34
26 changed files with 1400 additions and 20 deletions

View File

@ -0,0 +1,66 @@
# 对象视图 / 数据集视图 交互操作 — 进度 · 问题 · 下步计划
依据:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「客户端」页签(对象列表 / 数据集列表 交互规则)。
约定:二维/三维视图相关交互**先占位**;其余尽量接真实后端。
配套API 实地研究见 `../specs/2026-06-13-batch2-object-dataset-dialogs.md`
---
## 一、已完成(已编译 + 单测通过;写操作待真实提交验证)
### Batch 1 — 交互骨架 + 读联动 + 删除 + 筛选(已并入本分支)
- **ApiClient**:补 `putJsonAsync` / `deleteAsync`
- **删除**(真实 DELETE`deleteObjectAsync`(GS/TM) / `deleteDatasetAsync`;控制器 `deleteObject`/`deleteDataset` + `mutationSucceeded/Failed`,成功后刷新结构 / 数据集列表。
- **对象树右键菜单**9 项):属性、异常详情(`showObjectExceptions`GS→收集其下 TM 复用 setCheckedTms真实接现有面板删除真实显示隐藏/定位=2D/3D 占位。
- **数据集右键菜单**:数据集详情、属性 真实;删除真实。
- **快速筛选器**:对象树(全选测线/取消/反选);数据集(类型多选 + 创建日期,客户端隐藏不匹配行)。
- **数据集单击 tooltip**:名称/类型/创建时间。
- **手势修复 #1**:点对象树复选框只切勾选、不再触发"选中"重载viewport 事件过滤 + 复选框命中判定)。
### Batch 2本轮— 动态表单引擎 + 编辑保存 + 新建 TM + 插件列表
- **动态表单引擎** `DynamicFormEditor`:按 `project/getDynamicForm` 字段元信息渲染
comp1=文本(dtype2/3 数字+范围校验) / comp4=下拉(optionsObject) / comp6=日期 / comp7=日期时间 /
comp8=多行;必填(requiredType==1)标红 *;编辑态用 properties 预填。
- **ObjectFormDialog**:拉真实 schema → 渲染 → 校验 → 提交。
- **编辑保存**:右键编辑 → PUT `/business/{gs|tm}Object` → 成功刷新结构,失败回显后端 msg。
- **新建 TM**:右键 GS → `queryTmType` 选型 → 空表单 → POST带 structParentId/structParentConfType
- **插件子菜单**:数据集右键「插件」列出真实 `model/list`(启动缓存);点击=占位 toast。
- 数据层:`EditableForm`/`TmTypeOption`/`ModelInfo` DTO + parse`StructNode.typeId`
repo `loadEditableFormAsync`/`queryTmTypesAsync`/`submitObjectAsync`/`listModelsAsync`
controller `currentProjectId()`
---
## 二、已知问题 / 风险
1. **保存/新建请求体为推断**(最高优先级风险)
- OpenAPI 未文档化 POST/PUT body原版前端在 Vue 组件动态拼装,压缩代码难 100% 还原。
- 当前发送 `{typeId,id,type,projectId,properties:{fieldCode:值}}`(新建另带父上下文)。
- **需用户真实提交验证**:失败时对话框回显后端 msg用户提供浏览器 Network 真实 body 后定版。
2. **动态表单只读规则未定**:原版禁用了"测量值"字段(名称/电极数/间距/线长,落在 properties
无显式 disabled 标志可依,当前一律可编辑(不发请求时无害);接保存后需确认规则。
3. **菜单文档与实现的不一致**(用户已知,暂不改):
- 项目根("也是一个 GS")当前为不可选中/勾选的纯容器。
- GS 勾选用 Qt 自动三态,会改写子 TM 勾选态;文档 rule4 要求"屏蔽不改状态、可恢复"(随 2D/3D 做)。
- GS 选中是否返回其下全部 ds未实测后端。
4. **2D/3D 相关交互**:显示/隐藏、定位、双击地图获焦 — 按约定占位。
---
## 三、下一步计划
| 优先 | 任务 | 前置 |
|---|---|---|
| P0 | 用户实测 编辑保存 / 新建TM / 删除;据真实 body 定版字段 | 用户测试 |
| P1 | 新建 GSGS 类型来源:研究原版「项目结构\添加」,疑似需 gsTypeId | Playwright |
| P1 | 导入 DS 向导TM.dsList 选类型 → query/script → 文件 → checkImport → import含 multipart 上传) | 抓 import 细节 |
| P2 | 导出对话框queryExportObject 选对象 + 模板 → templateExport/export | 抓 export body |
| P2 | 插件「关联过滤 + 调用」(某 ds 适用哪些模型 + 模型任务流) | 研究原版 |
| P3 | 动态表单只读规则、GS 三态语义(与 2D/3D 批次合并)、项目根可选中 | — |
### 提交体捕获方法(不动生产数据的前提下尽量做)
- 优先:用户在原系统真实操作一次,复制 Network 请求体。
- 备选:继续分析 `index-*.js` 路由分包中数据管理组件的保存处理逻辑。

View File

@ -0,0 +1,93 @@
# Batch 2 实施依据:对象/数据集对话框(新建/编辑/导入/导出/插件)
实地研究原系统 `http://tenant.geomative.cn`(项目「香港威立雅」`projectId=1439735554211840`
projectTypeId `1445121423155200`所得。API 经页面 token replay + 真实 DOM 操作 + fetch 录制捕获。
> 口径:成功码 `code==200`;列表载荷在 `data`(非对象时包成 `data.value`)。
> token 头 `geomativeauthorization`base `http://tenant.geomative.cn/pop-api`
## 一、项目结构(已用于现有功能)
`GET /business/projectStruct/queryProjectStruct/{projectId}`
→ 扁平节点 `[{id,parentId,name,type(1=GS,2=TM),typeName,typeId,confCode,collectTime}]`,根 parentId="0"。
## 二、编辑 / 新建对象 —— 动态表单(核心,最大工作量)
### 表单 schema 来源(统一端点,编辑弹窗打开时调用)
`POST /business/project/getDynamicForm`
body `{"typeId": <类型id>, "id": <对象id>, "type": <1=GS|2=TM>, "projectId": <projectId>}`
→ 返回结构同 getDetail
```
{
typeId, confCode, name(类型名), description,
formList: [ { groupName, values: [ FieldDef... ] } ], // 可多组(对应弹窗内分页签:基本信息/测线布设/数据质量检查...)
properties: { <fieldCode>: <当前值> } // 编辑预填;新建时为空
}
```
getGsObjectDetail / tmObject/getDetail 返回同样的 formList/properties可互为参考。
TM 的 getDetail 还含 dsList / dsClassifyTypeList / gridFieldList。
### FieldDef 字段定义
```
confFieldId, fieldUseType(1=核心字段,2=普通),
fieldCode(键), fieldName(标签),
displayComponentType(控件类型), requiredType(1/2 必填标志——待核实方向),
displaySort, fieldDataType(4=字符串,5=日期,6=日期时间...),
fieldConfigJsonObject:{fieldChnFormat,fieldRemark,fieldEngFormat},
optionsObject(下拉项;普通字段为 null)
```
### displayComponentType 已观察样例(需补全映射)
TM「常规高密度电阻率法」编辑弹窗实测控件
- 方法名称(只读文本)
- 基本信息组:名称/电极数/电极间距/测线长 = **只读文本**(核心字段 fieldUseType=1编辑时禁用
- 设备 = 下拉(必填) ; 布设日期 = 日期选择(必填) ; 天气 = 下拉(必填)
- 布设人/审核人 = 文本(必填) ; 备注 = 文本
GS「测区」formList 含:创建人/创建日期(comp6)/名称(comp1)/创建时间(comp7)/地形地貌(comp8)/岩土性质...
→ 推测 comp1=单行文本, 6=日期, 7=日期时间, 8=多行文本;**落地前需逐一在原版核实**。
### 新建 TM 的类型选择
`GET /business/tmObject/queryTmType?projectId=..&gsId=..`
`[{label:"瞬变电磁方法", value:<tmTypeId>, code:"TEM01"}]`
新建流程:选 GS → queryTmType 选方法类型 → getDynamicForm(typeId,type=2,无id) 取空表单 → 填 → POST。
### ⚠️ 未捕获(真实壁垒,不可猜)
- `POST /business/gsObject`新建GS body
- `POST /business/tmObject`新建TM body
- `PUT /business/gsObject` / `PUT /business/tmObject`(更新 body
实测:点「确定」时必填项为空→前端校验拦截,未发 PUT。要抓须填完必填项保存会改数据
- `PUT /business/dsObject/updateDsObject`更新DS body
## 三、删除(已实现 Batch1
`DELETE /business/gsObject/{id}` `DELETE /business/tmObject/{id}` `DELETE /business/dsObject/{id}`
## 四、导入 DS
TM 的 getDetail.dsList = 该 TM 可承载的 ds 类型 `[{dsTypeId,nameChn,nameEng,canImport,canExport,...}]`canImport=true 的可导入)。
`GET /business/dsObject/query/script?dsTypeId=..&tmTypeBaseConfId=..` → 该类型可用导入脚本。
`POST /business/dsObject/checkImport` → 校验脚本所需轨迹/坐标文件是否存在。
`POST /business/dsObject/import`query 参数已知aliasName, dsTypeId*, file*, projectId*, scriptCode, scriptParamListJsonStr, structParentConfType*, structParentId*
⚠️ file 为上传项multipartscriptParamListJsonStr 结构未捕获。
## 五、导出
`POST /business/templateExport/queryExportObject` body `{projectId}` → 可选导出对象树 `[{id,parentId,name,check}]`
`GET /business/templateExport/queryDataType/{tmTypeBaseConfId}` → 按方法查数据类型。
`POST /business/templateExport/export`body 未捕获)。
模板列表见 localStorage `template`templateTypeList[数据管理-数据报告/异常体报告], fileTypeList[WORD/EXCEL], fileTemplateList。
另:数据集详情页「导出」按钮点击未发请求/未弹窗(可能直接下载或需先配模板)——待核实。
## 六、插件(数据集右键)
`GET /business/model/list` → 全局模型目录 `[{id,scriptCode,scriptName,scriptOperationType,formItemList}]`
script_ert_inner_inversion「ert反演默认」、script_radar_resultant_data_processing「雷达数据处理默认」。
⚠️ "与当前 ds 关联"的过滤逻辑未捕获model/list 不含 ds 类型关联;各模型用 dsObjectList/{projectId} 声明可接受的数据源)。
`POST /business/model/task/page` → 模型任务分页(用于"数据集任务"面板)。
## 实施建议顺序
1. 动态表单引擎 `DynamicFormEditor`Qt按 formList 渲染 comp1/6/7/8 + 下拉(optionsObject) + 必填校验 + 只读核心字段;编辑用 properties 预填。**先在原版逐一核实 displayComponentType 全集映射。**
2. 编辑对象最先打通getDynamicForm 取 schema → 编辑器 → PUT。**先捕获 PUT body。**
3. 新建 GS / 新建 TMqueryTmType → 空表单 → POST。**先捕获 POST body。**
4. 导入 DSdsList 选类型 → query/script → 文件 → checkImport → import。**先捕获 import 细节。**
5. 导出queryExportObject 选对象 + 模板 → export。**先捕获 export body。**
6. 插件子菜单model/list 列出;调用/关联逻辑待定)。
## 捕获提交载荷的方法(已在原版页面注入录制器 window.__rec
在 Playwright 浏览器对原系统执行一次「编辑保存/新建保存/导入/导出」(填完必填项),
即可从 `window.__rec` 读到真实 method+url+body。这是落地步骤 2-5 的前置。

View File

@ -29,6 +29,7 @@ add_executable(geopro_desktop WIN32
panels/DatasetListPanel.cpp
panels/ObjectTreePanel.cpp
panels/DynamicFormView.cpp
panels/DynamicFormEditor.cpp
panels/ObjectExceptionPanel.cpp
panels/DescriptionPanel.cpp
panels/chart/RawDataChartView.cpp
@ -53,6 +54,7 @@ add_executable(geopro_desktop WIN32
panels/DatasetDetailPanel.cpp
CentralScene.cpp
ProjectListDialog.cpp
ObjectFormDialog.cpp
SettingsDialog.cpp
Logging.cpp)

View File

@ -0,0 +1,137 @@
#include "ObjectFormDialog.hpp"
#include <utility>
#include <QHBoxLayout>
#include <QJsonDocument>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
#include "Theme.hpp"
#include "api/NavLoads.hpp" // Q_DECLARE_METATYPE(EditableForm)
#include "api/NavRequest.hpp"
#include "panels/DynamicFormEditor.hpp"
#include "repo/IAsyncProjectRepository.hpp"
namespace geopro::app {
ObjectFormDialog::ObjectFormDialog(geopro::data::IAsyncProjectRepository& repo, QString projectId,
QWidget* parent)
: QDialog(parent), repo_(repo), projectId_(std::move(projectId)) {
setModal(true);
setMinimumSize(geopro::app::scaledPx(480), geopro::app::scaledPx(560));
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
status_ = new QLabel(QStringLiteral("加载中…"), this);
status_->setAlignment(Qt::AlignCenter);
geopro::app::applyTokenizedStyleSheet(status_,
QStringLiteral("color:{{text/disabled}};padding:16px;"));
lay->addWidget(status_);
auto* scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
editor_ = new DynamicFormEditor();
scroll->setWidget(editor_);
lay->addWidget(scroll, 1);
auto* btnRow = new QHBoxLayout();
btnRow->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm,
geopro::app::space::kLg, geopro::app::space::kMd);
btnRow->addStretch();
auto* cancel = new QPushButton(QStringLiteral("取消"), this);
okBtn_ = new QPushButton(QStringLiteral("确定"), this);
okBtn_->setDefault(true);
okBtn_->setEnabled(false);
btnRow->addWidget(cancel);
btnRow->addWidget(okBtn_);
lay->addLayout(btnRow);
QObject::connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
QObject::connect(okBtn_, &QPushButton::clicked, this, &ObjectFormDialog::onConfirm);
}
void ObjectFormDialog::editObject(const QString& typeId, const QString& objectId, int confType,
const QString& displayName) {
confType_ = confType;
objectId_ = objectId;
typeId_ = typeId;
setWindowTitle(QStringLiteral("编辑 — %1").arg(displayName));
load(typeId, objectId, confType, displayName);
}
void ObjectFormDialog::newObject(const QString& typeId, int confType, const QString& displayName) {
confType_ = confType;
objectId_.clear();
typeId_ = typeId;
setWindowTitle(QStringLiteral("新建 %1").arg(displayName));
load(typeId, QString(), confType, displayName);
}
void ObjectFormDialog::load(const QString& typeId, const QString& objectId, int confType,
const QString& titleHint) {
(void)titleHint;
status_->setText(QStringLiteral("加载中…"));
status_->setVisible(true);
okBtn_->setEnabled(false);
if (req_) req_->abort();
req_ = repo_.loadEditableFormAsync(typeId.toStdString(), objectId.toStdString(), confType,
projectId_.toStdString());
QObject::connect(req_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) {
const auto form = qvariant_cast<geopro::data::EditableForm>(v);
editor_->setForm(form);
status_->setVisible(false);
okBtn_->setEnabled(true);
});
QObject::connect(req_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("加载失败:%1").arg(msg));
status_->setVisible(true);
});
}
void ObjectFormDialog::onConfirm() {
QString missing;
if (!editor_->validateRequired(&missing)) {
QMessageBox::warning(this, QStringLiteral("校验"),
QStringLiteral("请填写必填项:%1").arg(missing));
return;
}
values_ = editor_->collectValues();
// 拼装提交体推断结构identity + properties与原版 getDynamicForm 对称)。
// 注:保存 body 未经真实提交核实,确切字段以服务端为准——失败时把后端 msg 回显,便于校正。
QJsonObject props;
for (auto it = values_.constBegin(); it != values_.constEnd(); ++it)
props.insert(it.key(), it.value());
QJsonObject body = extraBody_; // 新建的父对象上下文(编辑为空)
body.insert(QStringLiteral("typeId"), typeId_);
body.insert(QStringLiteral("type"), confType_);
body.insert(QStringLiteral("projectId"), projectId_);
if (!objectId_.isEmpty()) body.insert(QStringLiteral("id"), objectId_);
body.insert(QStringLiteral("properties"), props);
const bool isCreate = objectId_.isEmpty();
okBtn_->setEnabled(false);
status_->setText(QStringLiteral("提交中…"));
status_->setVisible(true);
if (subReq_) subReq_->abort();
subReq_ = repo_.submitObjectAsync(
confType_, isCreate, QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString());
QObject::connect(subReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) {
emit submitted(confType_);
accept();
});
QObject::connect(subReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) {
status_->setText(QStringLiteral("提交失败:%1").arg(msg));
status_->setVisible(true);
okBtn_->setEnabled(true);
});
}
} // namespace geopro::app

View File

@ -0,0 +1,63 @@
#pragma once
#include <QDialog>
#include <QJsonObject>
#include <QMap>
#include <QPointer>
#include <QString>
namespace geopro::data {
class IAsyncProjectRepository;
class NavRequest;
} // namespace geopro::data
class QLabel;
class QPushButton;
namespace geopro::app {
class DynamicFormEditor;
// 对象新建/编辑对话框:用 project/getDynamicForm 拉取可编辑表单 schema由 DynamicFormEditor 渲染。
// 编辑editObject(typeId, objectId, confType)(带 properties 预填)。
// 新建newObject(typeId, confType)(空表单)。
// 「确定」做必填校验并收集字段值values())。提交载荷接口尚未接入:调用方据 result()==Accepted
// 读取 values() 自行处理(当前仅用于演示引擎;保存接口待原版载荷捕获后接入)。
class ObjectFormDialog : public QDialog {
Q_OBJECT
public:
ObjectFormDialog(geopro::data::IAsyncProjectRepository& repo, QString projectId,
QWidget* parent = nullptr);
void editObject(const QString& typeId, const QString& objectId, int confType,
const QString& displayName);
void newObject(const QString& typeId, int confType, const QString& displayName);
// 新建时附加的请求体字段(如父 GSstructParentId / structParentConfType。编辑无需。
void setCreateExtra(const QJsonObject& extra) { extraBody_ = extra; }
QMap<QString, QString> values() const { return values_; }
QString objectId() const { return objectId_; }
int confType() const { return confType_; }
signals:
void submitted(int confType); // 提交成功(调用方据此刷新结构)
private:
void load(const QString& typeId, const QString& objectId, int confType, const QString& titleHint);
void onConfirm();
geopro::data::IAsyncProjectRepository& repo_;
QString projectId_;
QString objectId_;
QString typeId_;
int confType_ = 0;
QJsonObject extraBody_;
QMap<QString, QString> values_;
DynamicFormEditor* editor_ = nullptr;
QLabel* status_ = nullptr;
QPushButton* okBtn_ = nullptr;
QPointer<geopro::data::NavRequest> req_;
QPointer<geopro::data::NavRequest> subReq_;
};
} // namespace geopro::app

View File

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

View File

@ -37,9 +37,16 @@
#include <QFrame>
#include <QHBoxLayout>
#include <QGraphicsOpacityEffect>
#include <QDate>
#include <QLabel>
#include <QListWidget>
#include <QListWidgetItem>
#include <QInputDialog>
#include <QJsonObject>
#include <QMenu>
#include <QMessageBox>
#include <QPoint>
#include <QSet>
#include <QToolButton>
#include <QKeySequence>
#include <QProcess>
@ -84,7 +91,10 @@
#include "TopBar.hpp"
#include "CentralScene.hpp"
#include "ProjectListDialog.hpp"
#include "ObjectFormDialog.hpp"
#include "WorkbenchNavController.hpp"
#include "api/NavRequest.hpp"
#include "api/NavLoads.hpp"
#include "DatasetDetailController.hpp"
#include "panels/chart/ErtInversionStrategy.hpp"
#include "panels/chart/MeasurementStrategy.hpp"
@ -417,9 +427,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 左上 dock对象树真实结构项目根 → GS → TM。被动视图数据由控制器推送。
auto* objectTree = new geopro::app::ObjectTreePanel();
auto* leftDock = new ads::CDockWidget(QStringLiteral("对象"));
leftDock->setWidget(wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象"),
objectTree,
{{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}}));
auto* objectBox = wrapWithHeader(geopro::app::Glyph::Tree, QStringLiteral("对象"), objectTree,
{{geopro::app::Glyph::Filter, QStringLiteral("筛选")},
{geopro::app::Glyph::Plus, QStringLiteral("新建对象")}});
leftDock->setWidget(objectBox);
auto* leftArea = dockManager->addDockWidget(ads::LeftDockWidgetArea, leftDock);
// 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
@ -693,6 +704,248 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &nav,
&geopro::controller::WorkbenchNavController::setCheckedTms);
// ── 交互操作接线(对象/数据集视图:右键菜单 + 快速筛选 + 增删2D/3D 相关占位)────────
auto* anomalyTabGroup = anomalyPanel.tabGroup; // 捕指针anomalyPanel 为局部,勿按引用捕获)
// 状态栏轻提示toast 替代window 生命周期覆盖整个会话,按引用捕获安全)。
auto toast = [&window](const QString& msg) { window.statusBar()->showMessage(msg, 4000); };
// 表头操作按钮定位器:按 glyphId 在已包装面板表头里找具体 QToolButton。
auto findHeaderAction = [](QWidget* box, geopro::app::Glyph g) -> QToolButton* {
const int gid = static_cast<int>(g);
for (auto* b : box->findChildren<QToolButton*>(QStringLiteral("panelAction")))
if (b->property("glyphId").toInt() == gid) return b;
return nullptr;
};
// 对象树右键菜单动作路由。
QObject::connect(
objectTree, &geopro::app::ObjectTreePanel::contextActionRequested, &window,
[&nav, &projectRepo, &window, anomalyTabGroup, toast](
const QString& action, const QString& id, int confType, const QString& typeId,
const QString& name) {
if (action == QStringLiteral("properties")) {
nav.selectObject(id, confType);
if (anomalyTabGroup)
if (auto* b = anomalyTabGroup->button(1)) b->click(); // 切到「对象属性」页签
} else if (action == QStringLiteral("exceptionDetail")) {
nav.showObjectExceptions(id, confType);
if (anomalyTabGroup)
if (auto* b = anomalyTabGroup->button(0)) b->click(); // 切到「对象异常」页签
} else if (action == QStringLiteral("delete")) {
const auto r = QMessageBox::question(
&window, QStringLiteral("删除确认"),
QStringLiteral("确定删除「%1」该操作不可撤销。").arg(name),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (r == QMessageBox::Yes) nav.deleteObject(id, confType);
} else if (action == QStringLiteral("edit")) {
// 动态表单编辑器:拉 project/getDynamicForm 真实 schema 渲染可编辑表单;
// 确定→校验+提交PUTbody 为推断结构,确切性以服务端为准)→成功刷新结构。
auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(),
&window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->editObject(typeId, id, confType, name);
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("保存成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
} else if (action == QStringLiteral("newTm")) {
// 新建 TM先查该 GS 下可建的方法类型 → 选型 → 空表单 → 提交POST带父 GS 上下文)。
auto* req = projectRepo.queryTmTypesAsync(nav.currentProjectId().toStdString(),
id.toStdString());
QObject::connect(
req, &geopro::data::NavRequest::done, &window,
[&projectRepo, &nav, &window, toast, id, name](const QVariant& v) {
const auto types =
qvariant_cast<std::vector<geopro::data::TmTypeOption>>(v);
if (types.empty()) {
toast(QStringLiteral("「%1」下无可新建的方法类型").arg(name));
return;
}
QString chosenId, chosenLabel;
if (types.size() == 1) {
chosenId = QString::fromStdString(types[0].value);
chosenLabel = QString::fromStdString(types[0].label);
} else {
QStringList labels;
for (const auto& t : types) labels << QString::fromStdString(t.label);
bool ok = false;
const QString pick = QInputDialog::getItem(
&window, QStringLiteral("新建 TM"), QStringLiteral("选择方法类型:"),
labels, 0, false, &ok);
if (!ok) return;
chosenId = QString::fromStdString(types[labels.indexOf(pick)].value);
chosenLabel = pick;
}
auto* dlg = new geopro::app::ObjectFormDialog(
projectRepo, nav.currentProjectId(), &window);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->setCreateExtra(QJsonObject{{QStringLiteral("structParentId"), id},
{QStringLiteral("structParentConfType"), 1}});
dlg->newObject(chosenId, 2, chosenLabel);
QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window,
[&nav, toast](int) {
toast(QStringLiteral("新建成功"));
nav.switchProject(nav.currentProjectId());
});
dlg->open();
});
QObject::connect(req, &geopro::data::NavRequest::failed, &window,
[toast](const QString& msg) {
toast(QStringLiteral("加载方法类型失败:%1").arg(msg));
});
} else if (action == QStringLiteral("showHide") || action == QStringLiteral("locate")) {
toast(QStringLiteral("「%1」需要二维/三维视图,开发中").arg(name));
} else { // newGsGS 类型来源待研究)/ importDs导入向导待接入
toast(QStringLiteral("该功能开发中,即将接入"));
}
});
// 增删改结果 → 状态栏反馈(成功后控制器已自行刷新)。
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::mutationSucceeded, &window,
[toast](const QString& msg) { toast(msg); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::mutationFailed, &window,
[&window](const QString& msg) {
auto* sb = window.statusBar();
sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}")
.arg(QString::fromUtf8(geopro::app::semantic::kDanger)));
sb->showMessage(QStringLiteral("操作失败:%1").arg(msg), 6000);
QTimer::singleShot(6000, sb, [sb]() { sb->setStyleSheet(QString()); });
});
// 对象树表头「筛选」按钮 → 快速筛选弹出菜单(按类型批量勾选/反选 TM
if (auto* objFilterBtn = findHeaderAction(objectBox, geopro::app::Glyph::Filter)) {
objFilterBtn->setToolTip(QStringLiteral("快速筛选"));
QObject::connect(objFilterBtn, &QToolButton::clicked, objectTree,
[objectTree, objFilterBtn]() {
QMenu m(objectTree);
m.addAction(QStringLiteral("全选测线"), objectTree,
[objectTree]() { objectTree->setAllTmsChecked(true); });
m.addAction(QStringLiteral("取消全选"), objectTree,
[objectTree]() { objectTree->setAllTmsChecked(false); });
m.addAction(QStringLiteral("反选"), objectTree,
[objectTree]() { objectTree->invertTmChecks(); });
m.exec(objFilterBtn->mapToGlobal(QPoint(0, objFilterBtn->height())));
});
}
// 对象树表头「新建对象」按钮 → 占位(对话框待 Batch2
if (auto* objAddBtn = findHeaderAction(objectBox, geopro::app::Glyph::Plus)) {
objAddBtn->setToolTip(QStringLiteral("新建对象"));
QObject::connect(objAddBtn, &QToolButton::clicked, &window,
[toast]() { toast(QStringLiteral("新建对象功能开发中,即将接入")); });
}
// 模型/插件目录:全局一次性拉取并缓存,供数据集右键「插件」子菜单同步填充。
auto modelsCache = std::make_shared<std::vector<geopro::data::ModelInfo>>();
{
auto* mReq = projectRepo.listModelsAsync();
QObject::connect(mReq, &geopro::data::NavRequest::done, &window,
[modelsCache](const QVariant& v) {
*modelsCache = qvariant_cast<std::vector<geopro::data::ModelInfo>>(v);
});
}
// ── 数据集列表右键菜单(数据集详情 / 属性 / 插件 / 导出 / 删除)──
datasetList->setContextMenuPolicy(Qt::CustomContextMenu);
QObject::connect(
datasetList, &QWidget::customContextMenuRequested, datasetList,
[datasetList, &detailCtrl, &nav, &window, toast, modelsCache](const QPoint& pos) {
QTreeWidgetItem* item = datasetList->itemAt(pos);
if (!item || item->data(0, geopro::app::kDsLoadMoreRole).toBool()) return;
const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
if (dsId.isEmpty()) return;
const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
const QString dsName = item->data(0, geopro::app::kDsNameRole).toString();
QMenu menu(datasetList);
menu.addAction(QStringLiteral("数据集详情"), datasetList,
[&detailCtrl, dsId, ddCode, dsName]() {
detailCtrl.openDataset(dsId, ddCode, dsName);
});
menu.addAction(QStringLiteral("属性"), datasetList,
[&nav, dsId]() { nav.selectDataset(dsId); });
menu.addSeparator();
QMenu* plugins = menu.addMenu(QStringLiteral("插件"));
if (modelsCache->empty()) {
plugins->addAction(QStringLiteral("(模型列表加载中…)"))->setEnabled(false);
} else {
for (const auto& m : *modelsCache) {
const QString mn = QString::fromStdString(m.scriptName);
plugins->addAction(mn, datasetList, [toast, mn]() {
toast(QStringLiteral("插件「%1」调用待接入").arg(mn));
});
}
}
menu.addAction(QStringLiteral("导出…"), datasetList,
[toast]() { toast(QStringLiteral("导出功能开发中,即将接入")); });
menu.addSeparator();
menu.addAction(QStringLiteral("删除"), datasetList, [&nav, &window, dsId, dsName]() {
const auto r = QMessageBox::question(
&window, QStringLiteral("删除确认"),
QStringLiteral("确定删除数据集「%1」该操作不可撤销。").arg(dsName),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (r == QMessageBox::Yes) nav.deleteDataset(dsId);
});
menu.exec(datasetList->viewport()->mapToGlobal(pos));
});
// 数据集表头「筛选」按钮 → 按类型 + 创建日期快速筛选(客户端隐藏不匹配行;状态跨弹出保留)。
if (auto* dsFilterBtn = findHeaderAction(datasetBox, geopro::app::Glyph::Filter)) {
dsFilterBtn->setToolTip(QStringLiteral("快速筛选"));
auto hiddenTypes = std::make_shared<QSet<QString>>(); // 当前被取消勾选的类型
auto minDate = std::make_shared<QDate>(); // 创建日期下限(无效=不限)
auto reapply = [datasetList, hiddenTypes, minDate]() {
QSet<QString> visible;
for (const QString& x : geopro::app::collectDatasetTypeNames(datasetList))
if (!hiddenTypes->contains(x)) visible.insert(x);
geopro::app::applyDatasetFilter(datasetList, visible, *minDate);
};
QObject::connect(
dsFilterBtn, &QToolButton::clicked, datasetList,
[datasetList, dsFilterBtn, hiddenTypes, minDate, reapply]() {
const QStringList types = geopro::app::collectDatasetTypeNames(datasetList);
QMenu m(datasetList);
if (types.isEmpty()) {
m.addAction(QStringLiteral("(当前无数据集)"))->setEnabled(false);
}
for (const QString& t : types) {
QAction* a = m.addAction(t);
a->setCheckable(true);
a->setChecked(!hiddenTypes->contains(t));
QObject::connect(a, &QAction::toggled, datasetList,
[hiddenTypes, reapply, t](bool on) {
if (on) hiddenTypes->remove(t);
else hiddenTypes->insert(t);
reapply();
});
}
m.addSeparator();
QMenu* dm = m.addMenu(QStringLiteral("创建日期"));
dm->addAction(QStringLiteral("全部"), datasetList,
[minDate, reapply]() { *minDate = QDate(); reapply(); });
dm->addAction(QStringLiteral("近 7 天"), datasetList, [minDate, reapply]() {
*minDate = QDate::currentDate().addDays(-7);
reapply();
});
dm->addAction(QStringLiteral("近 30 天"), datasetList, [minDate, reapply]() {
*minDate = QDate::currentDate().addDays(-30);
reapply();
});
m.addAction(QStringLiteral("清除筛选"), datasetList,
[hiddenTypes, minDate, reapply]() {
hiddenTypes->clear();
*minDate = QDate();
reapply();
});
m.exec(dsFilterBtn->mapToGlobal(QPoint(0, dsFilterBtn->height())));
});
}
// 数据集表头「上传」按钮 → 占位(导入向导待 Batch2
if (auto* dsUploadBtn = findHeaderAction(datasetBox, geopro::app::Glyph::Upload)) {
dsUploadBtn->setToolTip(QStringLiteral("导入数据集"));
QObject::connect(dsUploadBtn, &QToolButton::clicked, &window,
[toast]() { toast(QStringLiteral("导入数据集功能开发中,即将接入")); });
}
// 控制器详情/异常/数据集表单 → 三个被动面板。
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::objectDetailLoaded, objAttrView,
[objAttrView](const QString&, const geopro::data::DynamicForm& form) {

View File

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

View File

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

View File

@ -0,0 +1,196 @@
#include "panels/DynamicFormEditor.hpp"
#include <QComboBox>
#include <QDate>
#include <QDateEdit>
#include <QDateTime>
#include <QDateTimeEdit>
#include <QDoubleValidator>
#include <QFormLayout>
#include <QIntValidator>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QVBoxLayout>
#include "Theme.hpp"
namespace geopro::app {
namespace {
// fieldDataType2=整数 3=浮点 4=字符串 5=日期 6=日期时间 8=枚举 10=设备 11=人员。
constexpr int kDtInt = 2;
constexpr int kDtFloat = 3;
// displayComponentType。
constexpr int kCompText = 1;
constexpr int kCompSelect = 4;
constexpr int kCompDate = 6;
constexpr int kCompDateTime = 7;
constexpr int kCompMultiline = 8;
// 必填字段标签:名称 + 红色 *。
QString labelText(const data::EditField& f) {
QString t = QString::fromStdString(f.name);
if (f.required == 1)
t += QStringLiteral(" <span style='color:%1'>*</span>")
.arg(QString::fromUtf8(geopro::app::semantic::kDanger));
return t;
}
// 按字段建取值控件并预填。
QWidget* buildWidget(const data::EditField& f) {
const QString val = QString::fromStdString(f.value);
switch (f.comp) {
case kCompSelect: {
auto* cb = new QComboBox();
if (f.options.empty()) {
cb->addItem(QStringLiteral("(选项待接入)"));
cb->setEnabled(false);
} else {
for (const auto& o : f.options)
cb->addItem(QString::fromStdString(o.label), QString::fromStdString(o.value));
const int idx = cb->findData(val);
if (idx >= 0) cb->setCurrentIndex(idx);
}
return cb;
}
case kCompDate: {
auto* de = new QDateEdit();
de->setCalendarPopup(true);
de->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
const QDate d = QDate::fromString(val.left(10), QStringLiteral("yyyy-MM-dd"));
de->setDate(d.isValid() ? d : QDate::currentDate());
return de;
}
case kCompDateTime: {
auto* dt = new QDateTimeEdit();
dt->setCalendarPopup(true);
dt->setDisplayFormat(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
const QDateTime v = QDateTime::fromString(val, QStringLiteral("yyyy-MM-dd HH:mm:ss"));
dt->setDateTime(v.isValid() ? v : QDateTime::currentDateTime());
return dt;
}
case kCompMultiline: {
auto* te = new QPlainTextEdit();
te->setPlainText(val);
te->setFixedHeight(geopro::app::scaledPx(64));
return te;
}
case kCompText:
default: {
auto* le = new QLineEdit();
le->setText(val);
if (f.comp != kCompText) le->setPlaceholderText(QStringLiteral("(控件类型 %1").arg(f.comp));
if (f.dataType == kDtInt) {
auto* v = new QIntValidator(le);
bool ok1 = false, ok2 = false;
const int lo = QString::fromStdString(f.limitMin).toInt(&ok1);
const int hi = QString::fromStdString(f.limitMax).toInt(&ok2);
if (ok1) v->setBottom(lo);
if (ok2) v->setTop(hi);
le->setValidator(v);
} else if (f.dataType == kDtFloat) {
le->setValidator(new QDoubleValidator(le));
}
return le;
}
}
}
QString readWidget(int comp, QWidget* w) {
switch (comp) {
case kCompSelect:
if (auto* cb = qobject_cast<QComboBox*>(w)) {
const QVariant d = cb->currentData();
return d.isValid() ? d.toString() : cb->currentText();
}
return {};
case kCompDate:
if (auto* de = qobject_cast<QDateEdit*>(w)) return de->date().toString(QStringLiteral("yyyy-MM-dd"));
return {};
case kCompDateTime:
if (auto* dt = qobject_cast<QDateTimeEdit*>(w))
return dt->dateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
return {};
case kCompMultiline:
if (auto* te = qobject_cast<QPlainTextEdit*>(w)) return te->toPlainText();
return {};
default:
if (auto* le = qobject_cast<QLineEdit*>(w)) return le->text();
return {};
}
}
bool widgetEmpty(int comp, QWidget* w) {
const QString v = readWidget(comp, w);
return v.trimmed().isEmpty();
}
} // namespace
DynamicFormEditor::DynamicFormEditor(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(0);
}
void DynamicFormEditor::setForm(const data::EditableForm& form) {
entries_.clear();
if (body_) {
body_->deleteLater();
body_ = nullptr;
}
body_ = new QWidget(this);
auto* outer = new QVBoxLayout(body_);
outer->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd,
geopro::app::space::kLg, geopro::app::space::kMd);
outer->setSpacing(geopro::app::space::kMd);
for (const auto& g : form.groups) {
if (form.groups.size() > 1 || !g.name.empty()) {
auto* sec = new QLabel(QString::fromStdString(g.name), body_);
geopro::app::applyTokenizedStyleSheet(
sec, QStringLiteral("color:{{text/secondary}};font-weight:%1;")
.arg(geopro::app::type::kWeightSemibold));
outer->addWidget(sec);
}
auto* fl = new QFormLayout();
fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
fl->setHorizontalSpacing(geopro::app::space::kMd);
fl->setVerticalSpacing(geopro::app::space::kSm);
for (const auto& f : g.fields) {
QWidget* w = buildWidget(f);
auto* lbl = new QLabel(labelText(f), body_);
lbl->setTextFormat(Qt::RichText); // 允许 * 的红色 span
fl->addRow(lbl, w);
Entry e;
e.code = QString::fromStdString(f.code);
e.name = QString::fromStdString(f.name);
e.comp = f.comp;
e.required = (f.required == 1);
e.widget = w;
entries_.push_back(e);
}
outer->addLayout(fl);
}
outer->addStretch();
layout()->addWidget(body_);
}
QMap<QString, QString> DynamicFormEditor::collectValues() const {
QMap<QString, QString> out;
for (const auto& e : entries_) out.insert(e.code, readWidget(e.comp, e.widget));
return out;
}
bool DynamicFormEditor::validateRequired(QString* missingName) const {
for (const auto& e : entries_) {
if (e.required && widgetEmpty(e.comp, e.widget)) {
if (missingName) *missingName = e.name;
return false;
}
}
return true;
}
} // namespace geopro::app

View File

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

View File

@ -1,8 +1,15 @@
#include "panels/ObjectTreePanel.hpp"
#include <QEvent>
#include <QLabel>
#include <QMenu>
#include <QModelIndex>
#include <QMouseEvent>
#include <QPoint>
#include <QSignalBlocker>
#include <QStringList>
#include <QStyle>
#include <QStyleOptionViewItem>
#include <QTimer>
#include <QTreeWidget>
#include <QTreeWidgetItem>
@ -18,6 +25,7 @@ namespace geopro::app {
namespace {
constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 idGS/TM 都存)
constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM
constexpr int kRoleTypeId = Qt::UserRole + 4; // 类型 id编辑调 getDynamicForm 用)
constexpr int kConfTypeGs = 1; // GS工区
constexpr int kConfTypeTm = 2; // TM 叶子
@ -31,6 +39,7 @@ void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNo
// 项目根:非交互容器(不设 kRoleObjId/kRoleConfType不可勾选
} else {
item->setData(0, kRoleObjId, QString::fromStdString(n.node.id));
item->setData(0, kRoleTypeId, QString::fromStdString(n.node.typeId));
if (n.isTm) {
item->setData(0, kRoleConfType, kConfTypeTm);
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
@ -64,7 +73,14 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) {
hint_->setVisible(false);
lay->addWidget(hint_);
// viewport 事件过滤:记录鼠标按下是否落在复选框区,用于区分「选中」与「勾选」手势。
tree_->viewport()->installEventFilter(this);
QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) {
if (pressOnCheckbox_) { // 点的是复选框:只切换勾选态,不当作「选中」(不重载数据集列表)
pressOnCheckbox_ = false;
return;
}
const QString id = item->data(0, kRoleObjId).toString();
const int confType = item->data(0, kRoleConfType).toInt();
if (!id.isEmpty() && confType != 0) emit objectClicked(id, confType);
@ -88,6 +104,98 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) {
emit checkedTmsChanged(tmIds);
});
});
// 右键菜单(对齐菜单文档:显示/隐藏、定位、属性、异常详情、编辑、新建GS/TM、导入DS、删除
// GS 才显示「新建GS/TM/导入DS」TM 为叶子不可承载子对象)。项目根节点不弹菜单。
tree_->setContextMenuPolicy(Qt::CustomContextMenu);
QObject::connect(tree_, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) {
QTreeWidgetItem* item = tree_->itemAt(pos);
if (!item) return;
const QString id = item->data(0, kRoleObjId).toString();
const int confType = item->data(0, kRoleConfType).toInt();
if (id.isEmpty() || confType == 0) return; // 项目根:非交互容器
const QString typeId = item->data(0, kRoleTypeId).toString();
const QString name = item->text(0);
const bool isGs = (confType == kConfTypeGs);
QMenu menu(this);
auto add = [&](const QString& text, const QString& action) {
menu.addAction(text, this,
[this, action, id, confType, typeId, name]() {
emit contextActionRequested(action, id, confType, typeId, name);
});
};
add(QStringLiteral("显示 / 隐藏"), QStringLiteral("showHide"));
add(QStringLiteral("定位"), QStringLiteral("locate"));
menu.addSeparator();
add(QStringLiteral("属性"), QStringLiteral("properties"));
add(QStringLiteral("异常详情"), QStringLiteral("exceptionDetail"));
menu.addSeparator();
add(QStringLiteral("编辑"), QStringLiteral("edit"));
if (isGs) {
add(QStringLiteral("新建 GS"), QStringLiteral("newGs"));
add(QStringLiteral("新建 TM"), QStringLiteral("newTm"));
add(QStringLiteral("导入 DS…"), QStringLiteral("importDs"));
}
menu.addSeparator();
add(QStringLiteral("删除"), QStringLiteral("delete"));
menu.exec(tree_->viewport()->mapToGlobal(pos));
});
}
bool ObjectTreePanel::eventFilter(QObject* watched, QEvent* event) {
if (tree_ && watched == tree_->viewport() && event->type() == QEvent::MouseButtonPress) {
auto* me = static_cast<QMouseEvent*>(event);
const QPoint pos = me->position().toPoint();
pressOnCheckbox_ = false;
const QModelIndex idx = tree_->indexAt(pos);
if (idx.isValid() && (idx.flags() & Qt::ItemIsUserCheckable)) {
// 用样式计算该项复选框指示区的精确矩形(含缩进偏移由 visualRect 给出)。
QStyleOptionViewItem opt;
opt.initFrom(tree_);
opt.rect = tree_->visualRect(idx);
opt.features |= QStyleOptionViewItem::HasCheckIndicator;
const QRect cb =
tree_->style()->subElementRect(QStyle::SE_ItemViewItemCheckIndicator, &opt, tree_);
if (cb.contains(pos)) pressOnCheckbox_ = true;
}
}
return QWidget::eventFilter(watched, event);
}
// ── 快速筛选:遍历所有 TM 叶子,对其 setCheckState。批量改用 SignalBlocker 屏蔽逐项 itemChanged
// 末尾手动触发一次 itemChanged 让既有 0ms 合并逻辑收集并发射 checkedTmsChanged。──
void ObjectTreePanel::setAllTmsChecked(bool checked) {
if (!tree_) return;
const Qt::CheckState st = checked ? Qt::Checked : Qt::Unchecked;
std::function<void(QTreeWidgetItem*)> walk = [&](QTreeWidgetItem* node) {
for (int i = 0; i < node->childCount(); ++i) {
QTreeWidgetItem* c = node->child(i);
if (c->data(0, kRoleConfType).toInt() == kConfTypeTm) c->setCheckState(0, st);
walk(c);
}
};
{
const QSignalBlocker block(tree_);
walk(tree_->invisibleRootItem());
}
emit tree_->itemChanged(nullptr, 0); // 触发既有合并发射
}
void ObjectTreePanel::invertTmChecks() {
if (!tree_) return;
std::function<void(QTreeWidgetItem*)> walk = [&](QTreeWidgetItem* node) {
for (int i = 0; i < node->childCount(); ++i) {
QTreeWidgetItem* c = node->child(i);
if (c->data(0, kRoleConfType).toInt() == kConfTypeTm)
c->setCheckState(0, c->checkState(0) == Qt::Checked ? Qt::Unchecked : Qt::Checked);
walk(c);
}
};
{
const QSignalBlocker block(tree_);
walk(tree_->invisibleRootItem());
}
emit tree_->itemChanged(nullptr, 0);
}
void ObjectTreePanel::setStructure(const QString& projectName,

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
#include "api/ApiProjectRepository.hpp"
#include <QByteArray>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QString>
#include <QUrl>
@ -122,4 +124,62 @@ NavRequest* ApiProjectRepository::loadExceptionsByTmAsync(const std::string& tmO
}, &isFailureA);
}
NavRequest* ApiProjectRepository::deleteObjectAsync(const std::string& objectId, int confType) {
// confType 1=GS → /gsObject/{id}2=TM → /tmObject/{id}。
const QString path = (confType == 1)
? QStringLiteral("/business/gsObject/%1").arg(enc(objectId))
: QStringLiteral("/business/tmObject/%1").arg(enc(objectId));
auto* call = api_.deleteAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
NavRequest* ApiProjectRepository::deleteDatasetAsync(const std::string& dsObjectId) {
const QString path = QStringLiteral("/business/dsObject/%1").arg(enc(dsObjectId));
auto* call = api_.deleteAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
NavRequest* ApiProjectRepository::loadEditableFormAsync(const std::string& typeId,
const std::string& objectId, int confType,
const std::string& projectId) {
QJsonObject body{{QStringLiteral("typeId"), QString::fromStdString(typeId)},
{QStringLiteral("type"), confType},
{QStringLiteral("projectId"), QString::fromStdString(projectId)}};
if (!objectId.empty()) body[QStringLiteral("id")] = QString::fromStdString(objectId);
auto* call = api_.postJsonAsync(QStringLiteral("/business/project/getDynamicForm"), body);
return new ApiNavRequest(call, [confType](const net::ApiResponse& r) {
return QVariant::fromValue(dto::parseEditableForm(r.data, confType));
}, &isFailureA);
}
NavRequest* ApiProjectRepository::queryTmTypesAsync(const std::string& projectId,
const std::string& gsId) {
const QString path = QStringLiteral("/business/tmObject/queryTmType?projectId=%1&gsId=%2")
.arg(enc(projectId), enc(gsId));
auto* call = api_.getAsync(path);
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return QVariant::fromValue(dto::parseTmTypes(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
}
NavRequest* ApiProjectRepository::submitObjectAsync(int confType, bool isCreate,
const std::string& bodyJson) {
const QString path = (confType == 1) ? QStringLiteral("/business/gsObject")
: QStringLiteral("/business/tmObject");
const QJsonObject body =
QJsonDocument::fromJson(QByteArray::fromStdString(bodyJson)).object();
auto* call = isCreate ? api_.postJsonAsync(path, body) : api_.putJsonAsync(path, body);
return new ApiNavRequest(call, [](const net::ApiResponse&) { return QVariant::fromValue(true); },
&isFailureA);
}
NavRequest* ApiProjectRepository::listModelsAsync() {
auto* call = api_.getAsync(QStringLiteral("/business/model/list"));
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return QVariant::fromValue(dto::parseModels(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
}
} // namespace geopro::data

View File

@ -25,6 +25,13 @@ public:
NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override;
NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override;
NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override;
NavRequest* deleteObjectAsync(const std::string& objectId, int confType) override;
NavRequest* deleteDatasetAsync(const std::string& dsObjectId) override;
NavRequest* loadEditableFormAsync(const std::string& typeId, const std::string& objectId,
int confType, const std::string& projectId) override;
NavRequest* queryTmTypesAsync(const std::string& projectId, const std::string& gsId) override;
NavRequest* submitObjectAsync(int confType, bool isCreate, const std::string& bodyJson) override;
NavRequest* listModelsAsync() override;
private:
net::ApiClient& api_;

View File

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

View File

@ -106,6 +106,7 @@ std::vector<StructNode> parseStructNodes(const QJsonArray& arr) {
n.parentId = str(o, "parentId");
n.typeName = str(o, "typeName");
n.confCode = str(o, "confCode");
n.typeId = str(o, "typeId");
n.type = o.value(QStringLiteral("type")).toInt();
out.push_back(std::move(n));
}
@ -212,6 +213,97 @@ DynamicForm parseDynamicForm(const QJsonObject& data) {
return form;
}
EditableForm parseEditableForm(const QJsonObject& data, int confType) {
EditableForm form;
form.typeId = str(data, "typeId");
form.name = str(data, "name");
form.confType = confType;
const QJsonObject props = data.value(QStringLiteral("properties")).toObject();
QJsonArray groups = data.value(QStringLiteral("formList")).toArray();
std::vector<QJsonObject> gv;
gv.reserve(static_cast<size_t>(groups.size()));
for (const QJsonValue& g : groups) gv.push_back(g.toObject());
std::stable_sort(gv.begin(), gv.end(), [](const QJsonObject& a, const QJsonObject& b) {
return a.value(QStringLiteral("groupSort")).toInt() <
b.value(QStringLiteral("groupSort")).toInt();
});
for (const QJsonObject& g : gv) {
EditFieldGroup grp;
grp.name = str(g, "groupName");
grp.sort = g.value(QStringLiteral("groupSort")).toInt();
QJsonArray vals = g.value(QStringLiteral("values")).toArray();
std::vector<QJsonObject> fv;
fv.reserve(static_cast<size_t>(vals.size()));
for (const QJsonValue& v : vals) fv.push_back(v.toObject());
std::stable_sort(fv.begin(), fv.end(), [](const QJsonObject& a, const QJsonObject& b) {
return a.value(QStringLiteral("displaySort")).toInt() <
b.value(QStringLiteral("displaySort")).toInt();
});
for (const QJsonObject& f : fv) {
EditField ef;
ef.code = str(f, "fieldCode");
ef.name = str(f, "fieldName");
ef.comp = f.value(QStringLiteral("displayComponentType")).toInt(1);
ef.required = f.value(QStringLiteral("requiredType")).toInt(2);
ef.dataType = f.value(QStringLiteral("fieldDataType")).toInt(4);
ef.useType = f.value(QStringLiteral("fieldUseType")).toInt(2);
ef.sort = f.value(QStringLiteral("displaySort")).toInt();
const QString code = QString::fromStdString(ef.code);
if (props.contains(code)) {
ef.value = props.value(code).toVariant().toString().toStdString();
ef.fromProps = true;
}
const QJsonObject cfg = f.value(QStringLiteral("fieldConfigJsonObject")).toObject();
ef.limitMin = cfg.value(QStringLiteral("limitMin")).toVariant().toString().toStdString();
ef.limitMax = cfg.value(QStringLiteral("limitMax")).toVariant().toString().toStdString();
const QJsonValue ov = f.value(QStringLiteral("optionsObject"));
if (ov.isArray()) {
for (const QJsonValue& o : ov.toArray()) {
const QJsonObject oo = o.toObject();
EditFieldOption opt;
opt.label = str(oo, "label");
opt.value = oo.value(QStringLiteral("value")).toVariant().toString().toStdString();
ef.options.push_back(std::move(opt));
}
}
grp.fields.push_back(std::move(ef));
}
form.groups.push_back(std::move(grp));
}
return form;
}
std::vector<TmTypeOption> parseTmTypes(const QJsonArray& arr) {
std::vector<TmTypeOption> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
TmTypeOption t;
t.label = str(o, "label");
t.value = o.value(QStringLiteral("value")).toVariant().toString().toStdString();
t.code = str(o, "code");
out.push_back(std::move(t));
}
return out;
}
std::vector<ModelInfo> parseModels(const QJsonArray& arr) {
std::vector<ModelInfo> out;
out.reserve(static_cast<size_t>(arr.size()));
for (const QJsonValue& v : arr) {
const QJsonObject o = v.toObject();
ModelInfo m;
m.id = o.value(QStringLiteral("id")).toVariant().toString().toStdString();
m.scriptCode = str(o, "scriptCode");
m.scriptName = str(o, "scriptName");
m.operationType = o.value(QStringLiteral("scriptOperationType")).toInt();
out.push_back(std::move(m));
}
return out;
}
std::vector<ExceptionRow> parseExceptions(const QJsonArray& arr) {
std::vector<ExceptionRow> out;
out.reserve(static_cast<size_t>(arr.size()));

View File

@ -43,6 +43,17 @@ std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat)
// 表头 name 取 data["name"]。
DynamicForm parseDynamicForm(const QJsonObject& data);
// project/getDynamicForm 的 data → 可编辑表单(保留字段元信息驱动控件)。
// 组按 groupSort、字段按 displaySort 排序;值取 properties[fieldCode](命中即标记 fromProps
// confType 由调用方传入1=GS 2=TM
EditableForm parseEditableForm(const QJsonObject& data, int confType);
// tmObject/queryTmType 的 data["value"] 数组 → TM 类型选项 [{label,value,code}]。
std::vector<TmTypeOption> parseTmTypes(const QJsonArray& arr);
// model/list 的 data["value"] 数组 → 模型/插件 [{id,scriptCode,scriptName,operationType}]。
std::vector<ModelInfo> parseModels(const QJsonArray& arr);
// ExceptionVO 数组 → [ExceptionRow]。字段id、name=exceptionName、typeName=exceptionTypeName、
// createTimeconsortium* 取自 consortiumId/consortiumName/consortiumType来源待 live 验证);
// detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。

View File

@ -26,6 +26,21 @@ public:
virtual NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) = 0; // DynamicForm
virtual NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm
virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector<ExceptionRow>
// 删除confType 1=GS 2=TMDS 单独。payload=bool成功标志
virtual NavRequest* deleteObjectAsync(const std::string& objectId, int confType) = 0; // bool
virtual NavRequest* deleteDatasetAsync(const std::string& dsObjectId) = 0; // bool
// 可编辑动态表单(新建/编辑对象objectId 空=新建(仅取空表单),非空=编辑(含 properties 预填)。
virtual NavRequest* loadEditableFormAsync(const std::string& typeId, const std::string& objectId,
int confType, const std::string& projectId) = 0; // EditableForm
// 某 GS 下可新建的 TM 方法类型列表。
virtual NavRequest* queryTmTypesAsync(const std::string& projectId,
const std::string& gsId) = 0; // std::vector<TmTypeOption>
// 提交对象confType 1=GS 2=TMisCreate=true→POST(新建)false→PUT(编辑)。
// bodyJson 为序列化后的请求体(由上层按 getDynamicForm 结构拼装。payload=bool。
virtual NavRequest* submitObjectAsync(int confType, bool isCreate,
const std::string& bodyJson) = 0; // bool
// 模型/插件列表(数据集右键「插件」用)。
virtual NavRequest* listModelsAsync() = 0; // std::vector<ModelInfo>
};
} // namespace geopro::data

View File

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

View File

@ -53,4 +53,17 @@ IApiCall* ApiClient::postJsonAsync(const QString& path, const QJsonObject& body)
return new ApiCall(reply);
}
IApiCall* ApiClient::putJsonAsync(const QString& path, const QJsonObject& body) {
QNetworkRequest req = impl_->buildRequest(path);
const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact);
QNetworkReply* reply = impl_->nam.put(req, payload);
return new ApiCall(reply);
}
IApiCall* ApiClient::deleteAsync(const QString& path) {
QNetworkRequest req = impl_->buildRequest(path);
QNetworkReply* reply = impl_->nam.deleteResource(req);
return new ApiCall(reply);
}
} // namespace geopro::net

View File

@ -36,9 +36,12 @@ public:
// 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。
void setToken(const QString& token);
// 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。
// 异步 GET / POST / PUT(JSON) / DELETE:立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。
IApiCall* getAsync(const QString& path);
IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);
IApiCall* putJsonAsync(const QString& path, const QJsonObject& body);
// DELETE后端多数以路径 id 标识资源、无请求体。
IApiCall* deleteAsync(const QString& path);
private:
struct Impl;

View File

@ -61,6 +61,24 @@ struct StubAsyncRepo : data::IAsyncProjectRepository {
exceptions.push_back(r);
return r;
}
data::NavRequest* deleteObjectAsync(const std::string&, int) override {
return lastMutate = new StubNavRequest;
}
data::NavRequest* deleteDatasetAsync(const std::string&) override {
return lastMutate = new StubNavRequest;
}
data::NavRequest* loadEditableFormAsync(const std::string&, const std::string&, int,
const std::string&) override {
return new StubNavRequest;
}
data::NavRequest* queryTmTypesAsync(const std::string&, const std::string&) override {
return new StubNavRequest;
}
data::NavRequest* submitObjectAsync(int, bool, const std::string&) override {
return lastMutate = new StubNavRequest;
}
data::NavRequest* listModelsAsync() override { return new StubNavRequest; }
StubNavRequest* lastMutate = nullptr;
};
QVariant wsVar() {

View File

@ -68,10 +68,10 @@ TEST(NavDto, ParseStructNodesMapsParentAndType) {
TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) {
const std::vector<StructNode> flat = {
{"gs1", "工区1", "", "GS", "", 1},
{"tm1", "测线1", "gs1", "TM", "", 2},
{"tm2", "测线2", "gs1", "TM", "", 2},
{"tmD", "直挂测线", "", "TM", "", 2}, // TM 直挂项目(无 GS
{"gs1", "工区1", "", "GS", "", "", 1},
{"tm1", "测线1", "gs1", "TM", "", "", 2},
{"tm2", "测线2", "gs1", "TM", "", "", 2},
{"tmD", "直挂测线", "", "TM", "", "", 2}, // TM 直挂项目(无 GS
};
const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 2u); // gs1 + tmD
@ -86,7 +86,7 @@ TEST(NavDto, BuildStructTreeNestsGsTmAndDirectTm) {
TEST(NavDto, BuildStructTreeOrphanParentBecomesRoot) {
const std::vector<StructNode> flat = {
{"tmX", "孤儿测线", "ghost", "TM", "", 2}, // parentId 不在集合内
{"tmX", "孤儿测线", "ghost", "TM", "", "", 2}, // parentId 不在集合内
};
const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 1u);
@ -101,10 +101,10 @@ TEST(NavDto, BuildStructTreeEmpty) {
TEST(NavDto, BuildStructTreeHandlesCycleWithoutInfiniteRecursion) {
// 不可信数据:重复 id 形成可达环R→X→Y→重复X…。必须终止、不崩。
const std::vector<StructNode> flat = {
{"R", "", "", "GS", "", 1},
{"X", "x", "R", "GS", "", 1},
{"Y", "y", "X", "GS", "", 1},
{"X", "x2", "Y", "TM", "", 2}, // 重复 id X父=Y → 若不防环将无限递归
{"R", "", "", "GS", "", "", 1},
{"X", "x", "R", "GS", "", "", 1},
{"Y", "y", "X", "GS", "", "", 1},
{"X", "x2", "Y", "TM", "", "", 2}, // 重复 id X父=Y → 若不防环将无限递归
};
const auto roots = dto::buildStructTree(flat); // 不挂起即通过
ASSERT_EQ(roots.size(), 1u);
@ -131,11 +131,12 @@ TEST(NavDto, ParseProjectListArrayMapsItem) {
TEST(NavDto, BuildStructTreeDropsDsAndTmStaysLeaf) {
// 真实形态:项目(1) → TM(2) → DS(3)。DS 不进树;带 DS 子节点的 TM 仍是 TM 叶子。
// 字段序id,name,parentId,typeName,confCode,typeId,typetypeId 为新增,测试填空串)。
const std::vector<StructNode> flat = {
{"P", "项目", "0", "PRJ", "", 1},
{"T1", "ERT1", "P", "ERT", "ERT", 2},
{"D1", "批次1","T1", "", "", 3}, // DS应被过滤
{"T2", "ERT2", "P", "ERT", "ERT", 2},
{"P", "项目", "0", "PRJ", "", "", 1},
{"T1", "ERT1", "P", "ERT", "ERT", "", 2},
{"D1", "批次1","T1", "", "", "", 3}, // DS应被过滤
{"T2", "ERT2", "P", "ERT", "ERT", "", 2},
};
const auto roots = dto::buildStructTree(flat);
ASSERT_EQ(roots.size(), 1u); // 仅项目根parentId "0"