feat(detail): 异常详情坐标系/网格色阶templateId/新增异常类型 收尾1:1

- I11 异常详情经纬度/投影坐标:Anomaly 加 lonLatPts/eastNorthPts,parseDatasetAnomalies
  按原版响应字段(latitudeLongitude.latLon / geographicalCoordinates.coordinates)解析;
  坐标系下拉条件显示(有 latLon 才给三项,对照原版 latLon.length===0),纯展示不换算
- 网格剖面色阶 templateId:ContourPayload 加 templateId,inversion.grid 加载/重载解析
  getDetail 顶层 templateId,GridDataChartView 传入色阶编辑器→网格色阶另存覆盖可用
- 新增异常类型:仓储加 newCustomExceptionType(POST /business/customExceptionType
  {projectId,exceptionTypeName}),ExceptionDialog 按钮接通+刷新类型下拉

build all 绿,338/338。
This commit is contained in:
gaozheng 2026-06-23 14:35:27 +08:00
parent 3dfe8b54f5
commit 6cc973a183
14 changed files with 149 additions and 33 deletions

View File

@ -136,9 +136,12 @@ QWidget* ExceptionDetailDialog::buildCoordTab() {
topRow->addWidget(new QLabel(QStringLiteral("坐标系:"), tab));
coordSysCombo_ = new QComboBox(tab);
coordSysCombo_->addItem(QStringLiteral("图形坐标"), QStringLiteral("jb"));
// 经纬度/投影客户端暂无换算数据DTO 只解析图形坐标),先占位、切换给提示,不静默。
// 条件显示(对照原版 drawerExceptionInfolatLon.length===0 → 仅图形坐标;否则三项)。
// 纯展示响应坐标,不做客户端换算;响应未携带经纬度时退化为仅图形坐标,与原版一致。
if (!anomaly_.lonLatPts.empty()) {
coordSysCombo_->addItem(QStringLiteral("经纬度坐标"), QStringLiteral("lonlat"));
coordSysCombo_->addItem(QStringLiteral("投影坐标"), QStringLiteral("projection"));
}
topRow->addWidget(coordSysCombo_);
topRow->addStretch();
vertexCountLabel_ =
@ -163,31 +166,32 @@ QWidget* ExceptionDetailDialog::buildCoordTab() {
return tab;
}
const std::vector<geopro::core::Vec2>& ExceptionDetailDialog::activeCoords() const {
// 按当前坐标系返回对应点集(对照原版 handleCoordChangejb=图形 / lonlat=经纬度 / projection=投影)。
const QString sys = coordSysCombo_ ? coordSysCombo_->currentData().toString() : QString();
if (sys == QStringLiteral("lonlat")) return anomaly_.lonLatPts; // x=经度 y=纬度
if (sys == QStringLiteral("projection")) return anomaly_.eastNorthPts; // x=northCoord y=eastCoord
return anomaly_.localPts; // 图形坐标
}
void ExceptionDetailDialog::onCoordSystemChanged() {
if (!coordTable_) return;
const QString sys = coordSysCombo_->currentData().toString();
// 仅图形坐标有数据;经纬度/投影暂无换算能力 → 清表 + 提示(不静默吞)。
if (sys != QStringLiteral("jb")) {
coordTable_->setRowCount(0);
QMessageBox::information(this, windowTitle(),
QStringLiteral("经纬度/投影坐标换算暂未在客户端提供,仅支持图形坐标。"));
return;
}
const int n = static_cast<int>(anomaly_.localPts.size());
// 纯展示响应坐标(不做客户端换算):按当前坐标系填表(对照原版 showCoord 重填)。
const std::vector<geopro::core::Vec2>& pts = activeCoords();
const int n = static_cast<int>(pts.size());
coordTable_->setRowCount(n);
for (int r = 0; r < n; ++r) {
coordTable_->setItem(r, 0, new QTableWidgetItem(QString::number(r + 1)));
coordTable_->setItem(
r, 1, new QTableWidgetItem(QString::number(anomaly_.localPts[r].x, 'f', 7)));
coordTable_->setItem(
r, 2, new QTableWidgetItem(QString::number(anomaly_.localPts[r].y, 'f', 7)));
coordTable_->setItem(r, 1, new QTableWidgetItem(QString::number(pts[r].x, 'f', 7)));
coordTable_->setItem(r, 2, new QTableWidgetItem(QString::number(pts[r].y, 'f', 7)));
coordTable_->setItem(r, 3, new QTableWidgetItem(QString())); // Z 空(对照原版)
}
if (vertexCountLabel_) vertexCountLabel_->setText(QStringLiteral("顶点数:%1").arg(n));
}
void ExceptionDetailDialog::exportCoords() {
if (coordSysCombo_->currentData().toString() != QStringLiteral("jb") ||
anomaly_.localPts.empty()) {
const std::vector<geopro::core::Vec2>& pts = activeCoords();
if (pts.empty()) {
QMessageBox::information(this, windowTitle(), QStringLiteral("当前坐标系无可导出的坐标。"));
return;
}
@ -205,9 +209,9 @@ void ExceptionDetailDialog::exportCoords() {
QTextStream ts(&f);
// 对照原版TSV「序号\tX\tY\tZ」X/Y 7位小数Z 空。
ts << QStringLiteral("序号\tX\tY\tZ\n");
for (int i = 0; i < static_cast<int>(anomaly_.localPts.size()); ++i) {
ts << (i + 1) << '\t' << QString::number(anomaly_.localPts[i].x, 'f', 7) << '\t'
<< QString::number(anomaly_.localPts[i].y, 'f', 7) << "\t\n";
for (int i = 0; i < static_cast<int>(pts.size()); ++i) {
ts << (i + 1) << '\t' << QString::number(pts[i].x, 'f', 7) << '\t'
<< QString::number(pts[i].y, 'f', 7) << "\t\n";
}
f.close();
}

View File

@ -32,8 +32,10 @@ public:
private:
void onConfirm();
void onCoordSystemChanged(); // 切换坐标系 → 重填坐标表(图形有数据,经纬度/投影暂无
void onCoordSystemChanged(); // 切换坐标系 → 按对应点集重填坐标表(图形/经纬度/投影
void exportCoords(); // 导出当前坐标系坐标为 txt7位小数
// 当前坐标系对应的点集jb=图形 / lonlat=经纬度 / projection=投影;纯展示响应数据,不换算)。
const std::vector<geopro::core::Vec2>& activeCoords() const;
QWidget* buildLegendTab(); // 图例信息 Tab只读样式
QWidget* buildCoordTab(); // 坐标信息 Tab

View File

@ -6,6 +6,7 @@
#include <QFormLayout>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QInputDialog>
#include <QJsonArray>
#include <QLabel>
#include <QLineEdit>
@ -66,14 +67,7 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo,
typeRowLay->addWidget(exceptionTypeCombo_, 1);
typeRowLay->addWidget(addTypeBtn_);
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), typeRow);
connect(addTypeBtn_, &QPushButton::clicked, this, [this]() {
// 原版点开「标注类型」新增弹窗(异常属性+名称,含完整 legend提交 addExceptionType。
// 该子流程含整套 legend 编辑,后端 addExceptionType 端点客户端尚未接入;此处先提示,
// 不静默吞操作。用户可在 web 端新增类型后回到客户端选用。
QMessageBox::information(
this, windowTitle(),
QStringLiteral("新增异常类型请在 Web 端完成,新增后此处下拉会同步可选。"));
});
connect(addTypeBtn_, &QPushButton::clicked, this, &ExceptionDialog::onAddType);
nameEdit_ = new QLineEdit(this);
nameEdit_->setPlaceholderText(QStringLiteral("数据名称+异常类型代号+序号"));
@ -165,6 +159,33 @@ void ExceptionDialog::onTypeChanged() {
loadExceptionTypes();
}
void ExceptionDialog::onAddType() {
if (!repo_) return;
// 最小可用复刻:小弹窗输类型名 → newCustomExceptionType → 成功后刷新下拉并选中新建项。
// (原版按钮打开「标注属性+名称」完整 legend 子弹窗走 addExceptionType此处仅类型名差距已记录。
bool ok = false;
const QString name =
QInputDialog::getText(this, QStringLiteral("新增异常类型"), QStringLiteral("类型名称:"),
QLineEdit::Normal, QString(), &ok)
.trimmed();
if (!ok || name.isEmpty()) return;
addTypeBtn_->setEnabled(false);
QPointer<ExceptionDialog> self(this);
repo_->newCustomExceptionType(projectId_, name, [self, name](bool success, QString msg) {
if (!self) return;
self->updateAddTypeEnabled(); // 恢复按钮可用性(按当前标注类型)
if (!success) {
QMessageBox::warning(self, self->windowTitle(),
msg.isEmpty() ? QStringLiteral("新增异常类型失败") : msg);
return;
}
// 成功 → 记录待选中类型名,重拉列表后按名称匹配选中(对照原版刷新下拉)。
self->pendingSelectTypeName_ = name;
self->loadExceptionTypes();
});
}
void ExceptionDialog::updateAddTypeEnabled() {
if (!addTypeBtn_) return;
// 原版:文字类型(4) 或 未选标注类型时禁用「新增异常类型」。
@ -188,6 +209,12 @@ void ExceptionDialog::loadExceptionTypes() {
if (id.isEmpty()) id = o.value(QStringLiteral("id")).toString();
if (!id.isEmpty()) self->exceptionTypeCombo_->addItem(label, id);
}
// 若刚新建了类型 → 按名称匹配选中(找不到则保持默认首项,不报错)。
if (!self->pendingSelectTypeName_.isEmpty()) {
const int idx = self->exceptionTypeCombo_->findText(self->pendingSelectTypeName_);
if (idx >= 0) self->exceptionTypeCombo_->setCurrentIndex(idx);
self->pendingSelectTypeName_.clear();
}
if (self->exceptionTypeCombo_->count() > 0) self->suggestName();
});
}

View File

@ -40,6 +40,7 @@ public:
private:
void onTypeChanged(); // 标注类型变 → 清名称 + 重拉异常类型列表 + 刷新「新增类型」可用性
void onAddType(); // 「新增异常类型」:小弹窗输类型名 → newCustomExceptionType → 刷新+选中
void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType)
void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称回填
void onConfirm(); // 校验 → 有手填坐标则直接 newException否则 accept() 交给绘形
@ -52,6 +53,7 @@ private:
QComboBox* markTypeCombo_ = nullptr; // userData = "1".."4"
QComboBox* exceptionTypeCombo_ = nullptr; // userData = 异常类型 id
QPushButton* addTypeBtn_ = nullptr; // 新增异常类型(对照原版,文字/未选类型禁用)
QString pendingSelectTypeName_; // 新建类型后待选中的类型名(下一次列表刷新时匹配选中)
QLineEdit* nameEdit_ = nullptr;
QPlainTextEdit* remarkEdit_ = nullptr;
QTableWidget* coordTable_ = nullptr;

View File

@ -250,6 +250,7 @@ void GridDataChartView::setPayload(const QVariant& payload) {
return;
}
const auto p = payload.value<geopro::core::ContourPayload>();
lvlTemplateId_ = p.templateId; // 色阶模板 id保存/覆盖回带,对照原版 lvlTemplateId
setGridData(p.grid, p.scale, p.anomalies);
}
@ -318,9 +319,9 @@ void GridDataChartView::openColorScaleEditor() {
// projectId 在打开时取一次(随项目切换生效);无 getter 退化为空 → 后端按钮禁用。
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
// 网格剖面载荷ContourPayload无 templateId 字段 → lvlTemplateId 传空(覆盖复选框禁用)。
// 传入网格色阶模板 idgetDetail type2 顶层 templateId→ 「另存为覆盖」可用(对照原版 lvlTemplateId)。
ColorScaleConfigDialog dlg(gridScale_, grid_.vmin, grid_.vmax, std::move(samples), lineCfg_,
tplRepo_, projectId, QString(), this);
tplRepo_, projectId, lvlTemplateId_, this);
if (dlg.exec() != QDialog::Accepted) return;
gridScale_ = dlg.colorScale();
@ -369,6 +370,7 @@ void GridDataChartView::reloadGrid() {
msg.isEmpty() ? QStringLiteral("重载失败") : msg);
return;
}
self->lvlTemplateId_ = p.templateId; // 重载后同步模板 id色阶覆盖回带
self->setGridData(p.grid, p.scale, p.anomalies);
});
}

View File

@ -113,6 +113,7 @@ private:
geopro::core::Grid grid_{1, 1};
geopro::core::ColorScale gridScale_;
std::vector<geopro::core::Anomaly> anoms_;
QString lvlTemplateId_; // 网格色阶模板 idgetDetail type2 顶层 templateId色阶「另存为覆盖」用
bool hasGrid_ = false;
// 工具条显隐开关

View File

@ -19,6 +19,12 @@ struct Anomaly {
std::string createTime; // 创建时间(异常列表展示用,只读)
AnomalyMarkType markType = AnomalyMarkType::Polyline;
std::vector<Vec2> localPts; // 2D 局部坐标剖面详情x=距离, y=深度)
// 经纬度 / 投影坐标(详情坐标系切换用,纯展示,不做客户端换算;对照原版 drawerExceptionInfo
// 来源响应字段latitudeLongitude.latLon[].{longitude,latitude}→lonLatPts: x=经度 y=纬度)、
// geographicalCoordinates.coordinates[].{northCoord,eastCoord}→eastNorthPts: x=northCoord y=eastCoord
// 空 = 响应未携带 → 坐标系下拉退化为仅「图形坐标」(与原版 latLon.length===0 一致)。
std::vector<Vec2> lonLatPts; // 经纬度坐标x=经度(longitude), y=纬度(latitude)
std::vector<Vec2> eastNorthPts; // 投影坐标x=northCoord, y=eastCoord对照原版映射
// VTK 三维:异常多边形/折线/点的世界 3D 坐标(落在所在切片平面上)+ 平面(法向/一点)
// 用于 3D 渲染与重定位/正视;与切片生命周期解耦(切片可删,异常按 worldPts/plane 仍可显示)。
std::vector<Vec3> worldPts;

View File

@ -49,6 +49,9 @@ struct ContourPayload {
geopro::core::Grid grid{1, 1};
geopro::core::ColorScale scale;
std::vector<geopro::core::Anomaly> anomalies;
// 色阶模板 id来自 lvl/colorGradation/getDetail type2 的顶层 templateId保存/覆盖色阶时回带
// (对照原版 contourPage lvlTemplateId = lvlConfig?.templateId可空
QString templateId;
};
// 列渲染种类Text=预格式化文本默认Toggle=每行开关蓝色药丸开关ON=可见)。

View File

@ -302,6 +302,9 @@ void ApiDatasetCommandRepository::loadInversionGrid(
dto::parseColorBar(r[1].data),
dto::parseDatasetAnomalies(
r[2].data.value(QStringLiteral("value")).toArray())};
// 顶层 templateId对照原版 lvlTemplateId色阶保存/覆盖回带)。
p.templateId =
r[1].data.value(QStringLiteral("templateId")).toVariant().toString();
cb(true, p, {});
});
QObject::connect(batch, &net::ApiBatch::failed, batch,
@ -399,6 +402,16 @@ void ApiDatasetCommandRepository::newException(const QJsonObject& body,
wireStatus(api_.postJsonAsync(QStringLiteral("/business/exception"), body), std::move(cb));
}
void ApiDatasetCommandRepository::newCustomExceptionType(
const QString& projectId, const QString& name, std::function<void(bool, QString)> cb) {
// 对照原版 newCustomExceptionTypedatasetInfo/index.js:160POST /business/customExceptionType
// body {projectId, exceptionTypeName}projectId 必填。
QJsonObject body{{QStringLiteral("projectId"), projectId},
{QStringLiteral("exceptionTypeName"), name}};
wireStatus(api_.postJsonAsync(QStringLiteral("/business/customExceptionType"), body),
std::move(cb));
}
void ApiDatasetCommandRepository::deleteException(const QString& id,
std::function<void(bool, QString)> cb) {
wireStatus(api_.deleteAsync(QStringLiteral("/business/exception/%1").arg(enc(id))),

View File

@ -86,6 +86,8 @@ public:
std::function<void(bool ok, QString name, QString msg)> cb) override;
void newException(const QJsonObject& body,
std::function<void(bool ok, QString msg)> cb) override;
void newCustomExceptionType(const QString& projectId, const QString& name,
std::function<void(bool ok, QString msg)> cb) override;
void deleteException(const QString& id,
std::function<void(bool ok, QString msg)> cb) override;
void updateException(const QJsonObject& body,

View File

@ -34,6 +34,7 @@ struct GridParts {
geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位
geopro::core::ColorScale gridScale;
std::vector<geopro::core::Anomaly> anomalies;
QString templateId; // 网格色阶模板 id保存/覆盖回带,对照原版 lvlTemplateId
};
// 失败判定(原 must() 口径):业务码 != 200 或传输错误。
@ -78,6 +79,8 @@ GridParts parseGridParts(const QList<net::ApiResponse>& r) {
p.grid = dto::parseInversionGrid(r[0].data);
p.gridScale = dto::parseColorBar(r[1].data);
p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray());
// 顶层 templateId对照原版 lvlConfig?.templateId与散点 parseScatterParts 同范式)。
p.templateId = r[1].data.value(QStringLiteral("templateId")).toVariant().toString();
return p;
}
@ -179,7 +182,9 @@ DetailLoad* ApiDatasetRepository::makeInversionScatter(const std::string& dsId)
DetailLoad* ApiDatasetRepository::makeInversionGrid(const std::string& dsId) {
return new ApiDetailLoad(inversionGridBatch(api_, dsId), [](const QList<net::ApiResponse>& r) {
GridParts p = parseGridParts(r);
return QVariant::fromValue(core::ContourPayload{p.grid, p.gridScale, p.anomalies});
core::ContourPayload payload{p.grid, p.gridScale, p.anomalies};
payload.templateId = p.templateId; // 色阶保存/覆盖回带(对照原版 lvlTemplateId
return QVariant::fromValue(payload);
});
}

View File

@ -92,6 +92,18 @@ std::vector<Anomaly> parseDatasetAnomalies(const QJsonArray& arr) {
const QJsonObject p = c.toObject();
a.localPts.push_back(Vec2{p.value("x").toDouble(), p.value("y").toDouble()});
}
// 经纬度坐标对照原版latitudeLongitude.latLon[].{longitude,latitude})。
// 纯展示,不换算;空/缺失 → 详情坐标系下拉只给「图形坐标」(与原版 latLon.length===0 一致)。
for (auto c : o.value("latitudeLongitude").toObject().value("latLon").toArray()) {
const QJsonObject p = c.toObject();
a.lonLatPts.push_back(Vec2{p.value("longitude").toDouble(), p.value("latitude").toDouble()});
}
// 投影坐标对照原版geographicalCoordinates.coordinates[].{northCoord,eastCoord}
// 原版映射 x=northCoord、y=eastCoord
for (auto c : o.value("geographicalCoordinates").toObject().value("coordinates").toArray()) {
const QJsonObject p = c.toObject();
a.eastNorthPts.push_back(Vec2{p.value("northCoord").toDouble(), p.value("eastCoord").toDouble()});
}
out.push_back(std::move(a));
}
return out;

View File

@ -180,6 +180,14 @@ public:
virtual void newException(const QJsonObject& body,
std::function<void(bool ok, QString msg)> cb) = 0;
// 新增自定义异常类型POST /business/customExceptionType
// body {projectId, exceptionTypeName}(对应原版 newCustomExceptionTypedatasetInfo/index.js:160
// 注:原版「新增异常类型」按钮实际走的是 addExceptionType(POST /business/exceptionType)
// 的完整 legend 编辑子流程;客户端此处复刻为「最小可用」——仅类型名,调用更简的
// customExceptionType 端点(见 IDialog 注释,差距已记录)。
virtual void newCustomExceptionType(const QString& projectId, const QString& name,
std::function<void(bool ok, QString msg)> cb) = 0;
// 删除异常DELETE /business/exception/{id}(对应原版 deleteExceptionDataInProfileInversion
virtual void deleteException(const QString& id,
std::function<void(bool ok, QString msg)> cb) = 0;

View File

@ -56,6 +56,35 @@ TEST(DatasetChartDto, ParsesAnomalyPolyline) {
EXPECT_DOUBLE_EQ(v[0].localPts[1].x, 3.0);
EXPECT_TRUE(v[0].dashed);
}
TEST(DatasetChartDto, ParsesAnomalyLatLonAndEastNorth) {
// 对照原版 drawerExceptionInfo经纬度 latitudeLongitude.latLon[].{longitude,latitude}、
// 投影 geographicalCoordinates.coordinates[].{northCoord,eastCoord}x=northCoord y=eastCoord
auto arr = QJsonDocument::fromJson(
R"([{"id":"exc-2","exceptionName":"A2","exceptionMarkType":2,
"location":{"coordinate":[{"x":1,"y":2}]},
"latitudeLongitude":{"latLon":[{"longitude":120.5,"latitude":30.25}]},
"geographicalCoordinates":{"coordinates":[{"northCoord":3350000.0,"eastCoord":500000.0}]}}])")
.array();
auto v = parseDatasetAnomalies(arr);
ASSERT_EQ(v.size(), 1u);
ASSERT_EQ(v[0].lonLatPts.size(), 1u);
EXPECT_DOUBLE_EQ(v[0].lonLatPts[0].x, 120.5); // 经度
EXPECT_DOUBLE_EQ(v[0].lonLatPts[0].y, 30.25); // 纬度
ASSERT_EQ(v[0].eastNorthPts.size(), 1u);
EXPECT_DOUBLE_EQ(v[0].eastNorthPts[0].x, 3350000.0); // northCoord → X
EXPECT_DOUBLE_EQ(v[0].eastNorthPts[0].y, 500000.0); // eastCoord → Y
}
TEST(DatasetChartDto, AnomalyWithoutGeoCoordsLeavesVectorsEmpty) {
// 响应未携带经纬度/投影 → 两向量为空(详情坐标系下拉退化为仅「图形坐标」,与原版一致)。
auto arr = QJsonDocument::fromJson(
R"([{"id":"exc-3","exceptionName":"A3","exceptionMarkType":2,
"location":{"coordinate":[{"x":1,"y":2}]}}])")
.array();
auto v = parseDatasetAnomalies(arr);
ASSERT_EQ(v.size(), 1u);
EXPECT_TRUE(v[0].lonLatPts.empty());
EXPECT_TRUE(v[0].eastNorthPts.empty());
}
TEST(DatasetChartDto, ParsesAnomalyIdentityFields) {
// I10/I11/I12 需要 id删除/更新/定位)、备注、异常类型 id、创建时间。
auto arr = QJsonDocument::fromJson(