feat/vtk-3d-view #7

Merged
gaozheng merged 301 commits from feat/vtk-3d-view into main 2026-06-27 18:43:52 +08:00
20 changed files with 863 additions and 190 deletions
Showing only changes of commit 6bc7c23a8c - Show all commits

View File

@ -52,6 +52,7 @@ add_executable(geopro_desktop WIN32
panels/chart/WhiteningDialog.cpp panels/chart/WhiteningDialog.cpp
panels/chart/FilterDialog.cpp panels/chart/FilterDialog.cpp
panels/chart/ExceptionDialog.cpp panels/chart/ExceptionDialog.cpp
panels/chart/ExceptionTextDialog.cpp
panels/chart/ExceptionDetailDialog.cpp panels/chart/ExceptionDetailDialog.cpp
panels/chart/AutoAnnotationDialog.cpp panels/chart/AutoAnnotationDialog.cpp
panels/chart/ContourSimplify.cpp panels/chart/ContourSimplify.cpp

View File

@ -6,7 +6,9 @@
#include <QToolButton> #include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
namespace geopro::app { namespace geopro::app {
static QString markName(int t) { return t == 1 ? "" : t == 3 ? "多边形" : "多段线"; } static QString markName(int t) {
return t == 1 ? "" : t == 3 ? "多边形" : t == 4 ? "文字" : "多段线";
}
AnomalyTablePanel::AnomalyTablePanel(QWidget* parent) : QWidget(parent) { AnomalyTablePanel::AnomalyTablePanel(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0);
@ -53,9 +55,10 @@ void AnomalyTablePanel::setAnomalies(const std::vector<geopro::core::Anomaly>& l
connect(btnDetail, &QToolButton::clicked, this, [this, i]() { emit detailRequested(i); }); connect(btnDetail, &QToolButton::clicked, this, [this, i]() { emit detailRequested(i); });
auto* btnDelete = new QToolButton(ops); btnDelete->setText(QStringLiteral("删除")); auto* btnDelete = new QToolButton(ops); btnDelete->setText(QStringLiteral("删除"));
connect(btnDelete, &QToolButton::clicked, this, [this, i]() { connect(btnDelete, &QToolButton::clicked, this, [this, i]() {
// 原版 a-popconfirm 二次确认 → 这里用 QMessageBox 确认 // 原版 a-popconfirm 二次确认contourContentDelete→ 这里用 QMessageBox文案对齐原版
if (QMessageBox::question(this, QStringLiteral("提示"), if (QMessageBox::question(this, QStringLiteral("提示"),
QStringLiteral("确定删除该异常?")) == QMessageBox::Yes) QStringLiteral("该操作会删除该异常标注数据,确认?")) ==
QMessageBox::Yes)
emit deleteRequested(i); emit deleteRequested(i);
}); });
opLay->addWidget(eye); opLay->addWidget(eye);

View File

@ -6,7 +6,6 @@
#include <QPushButton> #include <QPushButton>
#include <QTextCharFormat> #include <QTextCharFormat>
#include <QTextEdit> #include <QTextEdit>
#include <QTextListFormat>
#include <QToolBar> #include <QToolBar>
#include <QToolButton> #include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -20,6 +19,13 @@ namespace {
// 字号下拉选项px——对照原版 ql-size 的 12~32px。 // 字号下拉选项px——对照原版 ql-size 的 12~32px。
const int kFontSizesPx[] = {12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32}; const int kFontSizesPx[] = {12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32};
// 字体族——对照原版 ql-font显示名 + Qt family。
struct FontOption { const char* label; const char* family; };
const FontOption kFontFamilies[] = {
{"微软雅黑", "Microsoft YaHei"}, {"宋体", "SimSun"}, {"仿宋", "FangSong"},
{"楷体", "KaiTi"}, {"黑体", "SimHei"}, {"仿宋_GB2312", "FangSong"},
};
} // namespace } // namespace
DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) { DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) {
@ -57,6 +63,8 @@ void DescriptionPanel::buildToolbar(QToolBar* tb) {
connect(btn, &QToolButton::toggled, this, applier); connect(btn, &QToolButton::toggled, this, applier);
return btn; return btn;
}; };
// 对照原版 Quill 工具栏:粗/斜 + 字色/背景色 + 对齐 + 标题 + 字号 + 字体族。
// (原版无下划线/列表,故此处不设,以贴近原版。)
addToggle(QStringLiteral("B"), [this](bool on) { addToggle(QStringLiteral("B"), [this](bool on) {
QTextCharFormat f; QTextCharFormat f;
f.setFontWeight(on ? QFont::Bold : QFont::Normal); f.setFontWeight(on ? QFont::Bold : QFont::Normal);
@ -67,15 +75,11 @@ void DescriptionPanel::buildToolbar(QToolBar* tb) {
f.setFontItalic(on); f.setFontItalic(on);
edit_->mergeCurrentCharFormat(f); edit_->mergeCurrentCharFormat(f);
}); });
addToggle(QStringLiteral("U"), [this](bool on) {
QTextCharFormat f;
f.setFontUnderline(on);
edit_->mergeCurrentCharFormat(f);
});
// 字色:弹色板,作用于选区前景色。 // 字色:弹色板,作用于选区前景色。
auto* colorBtn = new QToolButton(tb); auto* colorBtn = new QToolButton(tb);
colorBtn->setText(QStringLiteral("A")); colorBtn->setText(QStringLiteral("A"));
colorBtn->setToolTip(QStringLiteral("字体颜色"));
tb->addWidget(colorBtn); tb->addWidget(colorBtn);
connect(colorBtn, &QToolButton::clicked, this, [this]() { connect(colorBtn, &QToolButton::clicked, this, [this]() {
const QColor c = QColorDialog::getColor(Qt::black, this, QStringLiteral("字体颜色")); const QColor c = QColorDialog::getColor(Qt::black, this, QStringLiteral("字体颜色"));
@ -85,18 +89,33 @@ void DescriptionPanel::buildToolbar(QToolBar* tb) {
edit_->mergeCurrentCharFormat(f); edit_->mergeCurrentCharFormat(f);
}); });
// 字号下拉px // 背景色:弹色板,作用于选区背景色(对照原版 ql-background
auto* sizeBox = new QComboBox(tb); auto* bgBtn = new QToolButton(tb);
for (int px : kFontSizesPx) sizeBox->addItem(QStringLiteral("%1px").arg(px), px); bgBtn->setText(QStringLiteral(""));
sizeBox->setCurrentIndex(2); // 默认 16px与原版一致 bgBtn->setToolTip(QStringLiteral("背景颜色"));
tb->addWidget(sizeBox); tb->addWidget(bgBtn);
connect(sizeBox, &QComboBox::currentIndexChanged, this, [this, sizeBox](int) { connect(bgBtn, &QToolButton::clicked, this, [this]() {
const int px = sizeBox->currentData().toInt(); const QColor c = QColorDialog::getColor(Qt::yellow, this, QStringLiteral("背景颜色"));
if (!c.isValid()) return;
QTextCharFormat f; QTextCharFormat f;
f.setFontPointSize(px * 3.0 / 4.0); // px→pt。 f.setBackground(c);
edit_->mergeCurrentCharFormat(f); edit_->mergeCurrentCharFormat(f);
}); });
// 对齐:左/中/右/两端(对照原版 ql-align——块级。
auto addAlignBtn = [this, tb](const QString& label, Qt::Alignment align) {
auto* btn = new QToolButton(tb);
btn->setText(label);
tb->addWidget(btn);
connect(btn, &QToolButton::clicked, this, [this, align]() {
edit_->setAlignment(align);
});
};
addAlignBtn(QStringLiteral(""), Qt::AlignLeft);
addAlignBtn(QStringLiteral(""), Qt::AlignHCenter);
addAlignBtn(QStringLiteral(""), Qt::AlignRight);
addAlignBtn(QStringLiteral(""), Qt::AlignJustify);
// 标题下拉(正文 / H1~H4——块级作用于当前段。 // 标题下拉(正文 / H1~H4——块级作用于当前段。
auto* headerBox = new QComboBox(tb); auto* headerBox = new QComboBox(tb);
headerBox->addItem(QStringLiteral("正文"), 0); headerBox->addItem(QStringLiteral("正文"), 0);
@ -111,17 +130,29 @@ void DescriptionPanel::buildToolbar(QToolBar* tb) {
edit_->setTextCursor(cur); edit_->setTextCursor(cur);
}); });
// 有序 / 无序列表——块级。 // 字号下拉px
auto addListBtn = [this, tb](const QString& label, QTextListFormat::Style style) { auto* sizeBox = new QComboBox(tb);
auto* btn = new QToolButton(tb); for (int px : kFontSizesPx) sizeBox->addItem(QStringLiteral("%1px").arg(px), px);
btn->setText(label); sizeBox->setCurrentIndex(2); // 默认 16px与原版一致
tb->addWidget(btn); tb->addWidget(sizeBox);
connect(btn, &QToolButton::clicked, this, [this, style]() { connect(sizeBox, &QComboBox::currentIndexChanged, this, [this, sizeBox](int) {
edit_->textCursor().createList(style); const int px = sizeBox->currentData().toInt();
}); QTextCharFormat f;
}; f.setFontPointSize(px * 3.0 / 4.0); // px→pt。
addListBtn(QStringLiteral("1."), QTextListFormat::ListDecimal); edit_->mergeCurrentCharFormat(f);
addListBtn(QStringLiteral(""), QTextListFormat::ListDisc); });
// 字体族下拉(对照原版 ql-font
auto* fontBox = new QComboBox(tb);
for (const auto& fo : kFontFamilies)
fontBox->addItem(QString::fromUtf8(fo.label), QString::fromUtf8(fo.family));
tb->addWidget(fontBox);
connect(fontBox, &QComboBox::currentIndexChanged, this, [this, fontBox](int) {
const QString family = fontBox->currentData().toString();
QTextCharFormat f;
f.setFontFamilies({family});
edit_->mergeCurrentCharFormat(f);
});
} }
void DescriptionPanel::setDelta(const QJsonArray& ops) { void DescriptionPanel::setDelta(const QJsonArray& ops) {

View File

@ -15,6 +15,28 @@ namespace {
// 颜色转 Quill 习惯的小写 #rrggbb与原版 ql-color/ql-background 选项一致)。 // 颜色转 Quill 习惯的小写 #rrggbb与原版 ql-color/ql-background 选项一致)。
QString hexOf(const QColor& c) { return c.name(QColor::HexRgb); } QString hexOf(const QColor& c) { return c.name(QColor::HexRgb); }
// 字体族:原版 ql-font 的 token ↔ Qt QFont family 名。
// tokenwhitelist 值Microsoft-YaHei / SimSun / SimHei / KaiTi / FangSong / FangSong_GB2312。
// Delta 写出用 token反序列化时把 token 转成 Qt 可识别的 family连字符→空格等
QString fontTokenToFamily(const QString& token) {
if (token == QStringLiteral("Microsoft-YaHei")) return QStringLiteral("Microsoft YaHei");
if (token == QStringLiteral("SimSun")) return QStringLiteral("SimSun");
if (token == QStringLiteral("SimHei")) return QStringLiteral("SimHei");
if (token == QStringLiteral("KaiTi")) return QStringLiteral("KaiTi");
if (token == QStringLiteral("FangSong")) return QStringLiteral("FangSong");
if (token == QStringLiteral("FangSong_GB2312")) return QStringLiteral("FangSong");
return token; // Arial / sans-serif 等原样
}
QString familyToFontToken(const QString& family) {
if (family == QStringLiteral("Microsoft YaHei")) return QStringLiteral("Microsoft-YaHei");
if (family == QStringLiteral("SimSun")) return QStringLiteral("SimSun");
if (family == QStringLiteral("SimHei")) return QStringLiteral("SimHei");
if (family == QStringLiteral("KaiTi")) return QStringLiteral("KaiTi");
if (family == QStringLiteral("FangSong")) return QStringLiteral("FangSong");
return family;
}
// 行内样式 → attributes 对象bold/italic/underline/color/background/size // 行内样式 → attributes 对象bold/italic/underline/color/background/size
QJsonObject inlineAttrs(const QTextCharFormat& fmt) { QJsonObject inlineAttrs(const QTextCharFormat& fmt) {
QJsonObject a; QJsonObject a;
@ -28,6 +50,9 @@ QJsonObject inlineAttrs(const QTextCharFormat& fmt) {
const double pt = fmt.fontPointSize(); const double pt = fmt.fontPointSize();
if (pt > 0.0) // 以 px 表达(原版 ql-size 用 "NNpx"pt→px 约 *4/3 取整)。 if (pt > 0.0) // 以 px 表达(原版 ql-size 用 "NNpx"pt→px 约 *4/3 取整)。
a[QStringLiteral("size")] = QStringLiteral("%1px").arg(qRound(pt * 4.0 / 3.0)); a[QStringLiteral("size")] = QStringLiteral("%1px").arg(qRound(pt * 4.0 / 3.0));
const QStringList fams = fmt.fontFamilies().toStringList();
if (!fams.isEmpty() && !fams.first().isEmpty()) // 字体族(原版 ql-font token
a[QStringLiteral("font")] = familyToFontToken(fams.first());
return a; return a;
} }
@ -88,6 +113,8 @@ void applyInlineAttrs(const QJsonObject& attrs, QTextCharFormat& fmt) {
const double px = size.chopped(2).toDouble(); const double px = size.chopped(2).toDouble();
if (px > 0.0) fmt.setFontPointSize(px * 3.0 / 4.0); // px→pt。 if (px > 0.0) fmt.setFontPointSize(px * 3.0 / 4.0); // px→pt。
} }
const QString font = attrs.value(QStringLiteral("font")).toString();
if (!font.isEmpty()) fmt.setFontFamilies({fontTokenToFamily(font)});
} }
// 把 block attributes 应用到块格式 / 列表(作用于换行前的当前块)。 // 把 block attributes 应用到块格式 / 列表(作用于换行前的当前块)。

View File

@ -1,8 +1,12 @@
#include "panels/chart/AutoAnnotationDialog.hpp" #include "panels/chart/AutoAnnotationDialog.hpp"
#include <algorithm>
#include <cmath>
#include <utility> #include <utility>
#include <QButtonGroup>
#include <QComboBox> #include <QComboBox>
#include <QFormLayout>
#include <QFrame> #include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
@ -12,8 +16,10 @@
#include <QMessageBox> #include <QMessageBox>
#include <QPointer> #include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QRadioButton>
#include <QSpinBox> #include <QSpinBox>
#include <QTableWidget> #include <QTableWidget>
#include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "FormKit.hpp" #include "FormKit.hpp"
@ -26,54 +32,88 @@ namespace {
constexpr int kDefaultMinPoints = 4; // 原版 minPointCount 默认 4 constexpr int kDefaultMinPoints = 4; // 原版 minPointCount 默认 4
// 面异常类型固定 remarkSourceType="3"(原版自动标注仅支持面/polygon // 面异常类型固定 remarkSourceType="3"(原版自动标注仅支持面/polygon
const QString kPolygonType = QStringLiteral("3"); const QString kPolygonType = QStringLiteral("3");
// 从网格标量算 max/min/mean/median过滤 NaN。空 → 全 '-'。
struct Stats { bool valid = false; double mx = 0, mn = 0, mean = 0, median = 0; };
Stats computeStats(const std::vector<double>& raw) {
std::vector<double> v;
v.reserve(raw.size());
for (double d : raw)
if (!std::isnan(d)) v.push_back(d);
if (v.empty()) return {};
std::sort(v.begin(), v.end());
Stats s;
s.valid = true;
s.mn = v.front();
s.mx = v.back();
double sum = 0;
for (double d : v) sum += d;
s.mean = sum / static_cast<double>(v.size());
const size_t m = v.size() / 2;
s.median = (v.size() % 2 == 0) ? (v[m - 1] + v[m]) / 2.0 : v[m];
return s;
}
QString fmt2(bool valid, double x) {
return valid ? QString::number(x, 'f', 2) : QStringLiteral("-");
}
} // namespace } // namespace
AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo,
QString dsObjectId, QString projectId, QWidget* parent) QString dsObjectId, QString projectId,
std::vector<double> gridValues, QWidget* parent)
: QDialog(parent), : QDialog(parent),
repo_(repo), repo_(repo),
dsObjectId_(std::move(dsObjectId)), dsObjectId_(std::move(dsObjectId)),
projectId_(std::move(projectId)) { projectId_(std::move(projectId)),
gridValues_(std::move(gridValues)) {
setWindowTitle(QStringLiteral("自动标注")); setWindowTitle(QStringLiteral("自动标注"));
setModal(true); setModal(true);
resize(820, 520); resize(geopro::app::scaledPx(1400), geopro::app::scaledPx(600));
auto* root = formkit::dialogRoot(this); auto* root = formkit::dialogRoot(this);
auto* split = new QHBoxLayout(); auto* split = new QHBoxLayout();
// ── 左:规则列表 ──────────────────────────────────────────────── // ── 左:规则卡片列表35%,对照原版左栏)────────────────────────────────
auto* leftCol = new QVBoxLayout(); auto* leftCol = new QVBoxLayout();
leftCol->addWidget(new QLabel(QStringLiteral("标注规则:"), this)); leftCol->addWidget(new QLabel(QStringLiteral("异常判定规则"), this));
auto* ruleContainer = new QWidget(this); auto* ruleContainer = new QWidget(this);
ruleHost_ = new QVBoxLayout(ruleContainer); ruleHost_ = new QVBoxLayout(ruleContainer);
ruleHost_->setContentsMargins(0, 0, 0, 0); ruleHost_->setContentsMargins(0, 0, 0, 0);
leftCol->addWidget(ruleContainer); leftCol->addWidget(ruleContainer);
auto* addBtn = new QPushButton(QStringLiteral("加规则"), this); auto* addBtn = new QPushButton(QStringLiteral("加规则"), this);
connect(addBtn, &QPushButton::clicked, this, [this]() { addRule(); }); connect(addBtn, &QPushButton::clicked, this, [this]() { addRule(); });
leftCol->addWidget(addBtn); leftCol->addWidget(addBtn);
leftCol->addStretch(); leftCol->addStretch();
split->addLayout(leftCol, 1); auto* leftWrap = new QWidget(this);
leftWrap->setLayout(leftCol);
split->addWidget(leftWrap, 35);
// ── 右:预览表 ────────────────────────────────────────────────── // ── 右:上统计 + 下预览表 ───────────────────────────────────────────────
auto* rightCol = new QVBoxLayout(); auto* rightCol = new QVBoxLayout();
rightCol->addWidget(new QLabel(QStringLiteral("预览:"), this)); buildStatsBar(rightCol);
previewTable_ = new QTableWidget(0, 4, this);
previewTable_->setHorizontalHeaderLabels( detectedLabel_ = new QLabel(QStringLiteral("自动标注结果"), this);
{QStringLiteral("异常名称"), QStringLiteral("异常类型"), QStringLiteral("阈值范围"), rightCol->addWidget(detectedLabel_);
QStringLiteral("阈值模式")}); previewTable_ = new QTableWidget(0, 6, this);
previewTable_->setHorizontalHeaderLabels({QStringLiteral("序号"), QStringLiteral("异常名称"),
QStringLiteral("异常类型"), QStringLiteral("阈值范围"),
QStringLiteral("阈值模式"), QStringLiteral("操作")});
previewTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); previewTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
previewTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); previewTable_->setEditTriggers(QAbstractItemView::NoEditTriggers);
rightCol->addWidget(previewTable_, 1); rightCol->addWidget(previewTable_, 1);
split->addLayout(rightCol, 1); auto* rightWrap = new QWidget(this);
rightWrap->setLayout(rightCol);
split->addWidget(rightWrap, 65);
root->addLayout(split, 1); root->addLayout(split, 1);
// ── 底部按钮 ──────────────────────────────────────────────────── // ── 底部按钮:取消 / 执行自动标注 / 确认保存 ─────────────────────────────
auto* btnLay = new QHBoxLayout(); auto* btnLay = new QHBoxLayout();
btnLay->addStretch(); btnLay->addStretch();
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
auto* execBtn = new QPushButton(QStringLiteral("执行自动标注"), this); auto* execBtn = new QPushButton(QStringLiteral("执行自动标注"), this);
saveBtn_ = new QPushButton(QStringLiteral("保存"), this); saveBtn_ = new QPushButton(QStringLiteral("保存"), this);
saveBtn_->setEnabled(false); // 必须先执行得到预览才能保存 saveBtn_->setEnabled(false); // 必须先执行得到预览才能保存
btnLay->addWidget(cancelBtn); btnLay->addWidget(cancelBtn);
btnLay->addWidget(execBtn); btnLay->addWidget(execBtn);
@ -88,6 +128,24 @@ AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandReposito
loadExceptionTypes(); // 拉面异常类型 loadExceptionTypes(); // 拉面异常类型
} }
void AutoAnnotationDialog::buildStatsBar(QVBoxLayout* into) {
// 数据统计max/min/mean/median从网格标量算。预览图后置当前以统计条替代
// 后续如复用 ContourPlotItem 渲染异常预览图可在此区追加。
const Stats s = computeStats(gridValues_);
auto* bar = new QFrame(this);
bar->setFrameShape(QFrame::StyledPanel);
auto* lay = new QHBoxLayout(bar);
auto addStat = [&](const QString& name, double v) {
lay->addWidget(new QLabel(QStringLiteral("%1%2").arg(name, fmt2(s.valid, v)), bar));
};
addStat(QStringLiteral("最大值"), s.mx);
addStat(QStringLiteral("最小值"), s.mn);
addStat(QStringLiteral("均值"), s.mean);
addStat(QStringLiteral("中位数"), s.median);
lay->addStretch();
into->addWidget(bar);
}
void AutoAnnotationDialog::loadExceptionTypes() { void AutoAnnotationDialog::loadExceptionTypes() {
if (!repo_) return; if (!repo_) return;
QPointer<AutoAnnotationDialog> self(this); QPointer<AutoAnnotationDialog> self(this);
@ -104,7 +162,7 @@ void AutoAnnotationDialog::loadExceptionTypes() {
self->exceptionTypeOptions_.append( self->exceptionTypeOptions_.append(
QJsonObject{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}}); QJsonObject{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}});
} }
// 回填已存在的规则行下拉。 // 回填已存在规则卡片下拉。
for (auto& r : self->rules_) { for (auto& r : self->rules_) {
r.type->clear(); r.type->clear();
for (const QJsonValue& ov : self->exceptionTypeOptions_) { for (const QJsonValue& ov : self->exceptionTypeOptions_) {
@ -119,39 +177,113 @@ void AutoAnnotationDialog::loadExceptionTypes() {
void AutoAnnotationDialog::addRule() { void AutoAnnotationDialog::addRule() {
auto* card = new QFrame(this); auto* card = new QFrame(this);
card->setFrameShape(QFrame::StyledPanel); card->setFrameShape(QFrame::StyledPanel);
auto* lay = new QHBoxLayout(card); auto* cardLay = new QVBoxLayout(card);
lay->setContentsMargins(4, 4, 4, 4); cardLay->setContentsMargins(6, 6, 6, 6);
RuleRow row; RuleCard rc;
row.mode = new QComboBox(card); rc.frame = card;
row.mode->addItem(QStringLiteral("数值"), 1);
row.mode->addItem(QStringLiteral("百分位"), 2); // 卡片头:折叠 + 「规则N」 + 删除。
row.min = new QLineEdit(card); auto* header = new QHBoxLayout();
row.min->setPlaceholderText(QStringLiteral("min")); auto* collapseBtn = new QToolButton(card);
row.min->setFixedWidth(scaledPx(60)); collapseBtn->setText(QStringLiteral(""));
row.max = new QLineEdit(card); collapseBtn->setCheckable(true);
row.max->setPlaceholderText(QStringLiteral("max")); rc.title = new QLabel(card);
row.max->setFixedWidth(scaledPx(60)); auto* delBtn = new QToolButton(card);
row.minPoints = new QSpinBox(card); delBtn->setText(QStringLiteral("删除"));
row.minPoints->setRange(1, 100000); header->addWidget(collapseBtn);
row.minPoints->setValue(kDefaultMinPoints); header->addWidget(rc.title, 1);
row.type = new QComboBox(card); header->addWidget(delBtn);
cardLay->addLayout(header);
// 卡片主体(可折叠)。
rc.body = new QWidget(card);
auto* form = formkit::makeEditForm();
// 阈值模式radio-button 组(数值/百分位)。
auto* modeRow = new QWidget(rc.body);
auto* modeLay = new QHBoxLayout(modeRow);
modeLay->setContentsMargins(0, 0, 0, 0);
auto* rbNum = new QRadioButton(QStringLiteral("数值"), modeRow);
auto* rbPct = new QRadioButton(QStringLiteral("百分位"), modeRow);
rbNum->setChecked(true);
rc.modeGroup = new QButtonGroup(rc.body);
rc.modeGroup->addButton(rbNum, 1);
rc.modeGroup->addButton(rbPct, 2);
modeLay->addWidget(rbNum);
modeLay->addWidget(rbPct);
modeLay->addStretch();
form->addRow(formkit::editLabel(QStringLiteral("阈值模式")), modeRow);
// 阈值范围min - max。
auto* rangeRow = new QWidget(rc.body);
auto* rangeLay = new QHBoxLayout(rangeRow);
rangeLay->setContentsMargins(0, 0, 0, 0);
rc.min = new QLineEdit(rangeRow);
rc.min->setPlaceholderText(QStringLiteral("最小"));
rc.max = new QLineEdit(rangeRow);
rc.max->setPlaceholderText(QStringLiteral("最大"));
rangeLay->addWidget(rc.min);
rangeLay->addWidget(new QLabel(QStringLiteral("-"), rangeRow));
rangeLay->addWidget(rc.max);
form->addRow(formkit::editLabel(QStringLiteral("阈值范围")), rangeRow);
// 切模式清空 min/max对照原版 handleThresholdModeChange
auto clearRange = [min = rc.min, max = rc.max]() { min->clear(); max->clear(); };
connect(rbNum, &QRadioButton::toggled, rc.body, [clearRange](bool on) { if (on) clearRange(); });
connect(rbPct, &QRadioButton::toggled, rc.body, [clearRange](bool on) { if (on) clearRange(); });
rc.minPoints = new QSpinBox(rc.body);
rc.minPoints->setRange(1, 100000);
rc.minPoints->setValue(kDefaultMinPoints);
form->addRow(formkit::editLabel(QStringLiteral("最小点数")), rc.minPoints);
rc.type = new QComboBox(rc.body);
for (const QJsonValue& ov : exceptionTypeOptions_) { for (const QJsonValue& ov : exceptionTypeOptions_) {
const QJsonObject o = ov.toObject(); const QJsonObject o = ov.toObject();
row.type->addItem(o.value(QStringLiteral("name")).toString(), rc.type->addItem(o.value(QStringLiteral("name")).toString(),
o.value(QStringLiteral("id")).toString()); o.value(QStringLiteral("id")).toString());
} }
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), rc.type);
lay->addWidget(new QLabel(QStringLiteral("模式"), card)); rc.body->setLayout(form);
lay->addWidget(row.mode); cardLay->addWidget(rc.body);
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); // 折叠/展开。
connect(collapseBtn, &QToolButton::toggled, rc.body, [rc, collapseBtn](bool collapsed) {
rc.body->setVisible(!collapsed);
collapseBtn->setText(collapsed ? QStringLiteral("") : QStringLiteral(""));
});
// 删除(至少保留一条)。
connect(delBtn, &QToolButton::clicked, this, [this, card]() { removeRule(card); });
rules_.push_back(rc);
ruleHost_->addWidget(card); ruleHost_->addWidget(card);
renumberRules();
}
void AutoAnnotationDialog::removeRule(QWidget* frame) {
if (rules_.size() <= 1) { // 对照原版 atLeastOneRule。
QMessageBox::warning(this, windowTitle(), QStringLiteral("至少保留一条规则"));
return;
}
auto it = std::find_if(rules_.begin(), rules_.end(),
[frame](const RuleCard& c) { return c.frame == frame; });
if (it == rules_.end()) return;
ruleHost_->removeWidget(frame);
frame->deleteLater();
rules_.erase(it);
renumberRules();
}
void AutoAnnotationDialog::renumberRules() {
for (int i = 0; i < static_cast<int>(rules_.size()); ++i)
rules_[i].title->setText(QStringLiteral("规则 %1").arg(i + 1));
}
int AutoAnnotationDialog::currentMode(const RuleCard& c) const {
const int id = c.modeGroup->checkedId();
return id > 0 ? id : 1; // 默认数值
} }
QJsonArray AutoAnnotationDialog::buildRuleList() const { QJsonArray AutoAnnotationDialog::buildRuleList() const {
@ -159,7 +291,7 @@ QJsonArray AutoAnnotationDialog::buildRuleList() const {
for (const auto& r : rules_) { for (const auto& r : rules_) {
QJsonObject rule{ QJsonObject rule{
{QStringLiteral("exceptionTypeId"), r.type->currentData().toString()}, {QStringLiteral("exceptionTypeId"), r.type->currentData().toString()},
{QStringLiteral("thresholdMode"), r.mode->currentData().toInt()}, {QStringLiteral("thresholdMode"), currentMode(r)},
{QStringLiteral("minPointCount"), r.minPoints->value()}, {QStringLiteral("minPointCount"), r.minPoints->value()},
}; };
// min/max空 → null对照原版 Number(...) 或 null // min/max空 → null对照原版 Number(...) 或 null
@ -176,11 +308,18 @@ QJsonArray AutoAnnotationDialog::buildRuleList() const {
void AutoAnnotationDialog::onExecute() { void AutoAnnotationDialog::onExecute() {
if (!repo_) return; if (!repo_) return;
const QJsonArray rules = buildRuleList(); // 校验:每条规则 min/max 至少填一个、异常类型必选(对照原版 handleExecute
if (rules.isEmpty()) { for (const auto& r : rules_) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("请至少添加一条规则")); if (r.min->text().trimmed().isEmpty() && r.max->text().trimmed().isEmpty()) {
return; QMessageBox::warning(this, windowTitle(), QStringLiteral("阈值范围至少填写一项"));
return;
}
if (r.type->currentData().toString().isEmpty()) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型"));
return;
}
} }
const QJsonArray rules = buildRuleList();
QJsonObject body{ QJsonObject body{
{QStringLiteral("dsObjectId"), dsObjectId_}, {QStringLiteral("dsObjectId"), dsObjectId_},
{QStringLiteral("projectId"), projectId_}, {QStringLiteral("projectId"), projectId_},
@ -191,37 +330,81 @@ void AutoAnnotationDialog::onExecute() {
if (!self) return; if (!self) return;
if (!ok) { if (!ok) {
QMessageBox::warning(self, self->windowTitle(), QMessageBox::warning(self, self->windowTitle(),
msg.isEmpty() ? QStringLiteral("执行失败") : msg); msg.isEmpty() ? QStringLiteral("自动标注执行失败") : msg);
return; return;
} }
// 预览异常:兼容 data 直接为数组(wireObject 包成 value) 或 data.list。 // 预览异常:兼容 data 直接为数组(wireObject 包成 value) 或 data.list。
QJsonArray list = data.value(QStringLiteral("value")).toArray(); QJsonArray list = data.value(QStringLiteral("value")).toArray();
if (list.isEmpty()) list = data.value(QStringLiteral("list")).toArray(); if (list.isEmpty()) list = data.value(QStringLiteral("list")).toArray();
self->previewExceptions_ = list; self->previewExceptions_ = list;
self->detectedLabel_->setText(
QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(list.size()));
self->previewTable_->setRowCount(list.size()); self->previewTable_->setRowCount(list.size());
for (int i = 0; i < list.size(); ++i) { for (int i = 0; i < list.size(); ++i) {
const QJsonObject o = list[i].toObject(); const QJsonObject o = list[i].toObject();
self->previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1)));
self->previewTable_->setItem( self->previewTable_->setItem(
i, 0, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString())); i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString()));
self->previewTable_->setItem( self->previewTable_->setItem(
i, 1, i, 2,
new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString())); new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString()));
self->previewTable_->setItem( self->previewTable_->setItem(
i, 2, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString())); i, 3, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString()));
self->previewTable_->setItem( self->previewTable_->setItem(
i, 3, i, 4,
new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString())); new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString()));
// 操作列:逐条删除(对照原版预览表 删除)。
auto* delBtn = new QPushButton(QStringLiteral("删除"), self->previewTable_);
QPointer<AutoAnnotationDialog> weak(self);
QObject::connect(delBtn, &QPushButton::clicked, self, [weak, i]() {
if (weak) weak->deletePreviewRow(i);
});
self->previewTable_->setCellWidget(i, 5, delBtn);
} }
self->saveBtn_->setEnabled(!list.isEmpty()); self->saveBtn_->setEnabled(!list.isEmpty());
if (list.isEmpty()) if (list.isEmpty())
QMessageBox::information(self, self->windowTitle(), QMessageBox::information(self, self->windowTitle(), QStringLiteral("暂未识别到异常"));
QStringLiteral("未生成异常(无满足规则的区域)"));
}); });
} }
void AutoAnnotationDialog::deletePreviewRow(int row) {
if (row < 0 || row >= previewExceptions_.size()) return;
const QString name = previewExceptions_[row].toObject().value(QStringLiteral("exceptionName")).toString();
if (QMessageBox::question(this, QStringLiteral("确认删除"),
QStringLiteral("%1确认删除").arg(name)) != QMessageBox::Yes)
return;
previewExceptions_.removeAt(row);
// 重建预览表(重排序号 + 重绑删除)。复用 onExecute 的填表分支会重发请求,故就地重建。
previewTable_->setRowCount(previewExceptions_.size());
for (int i = 0; i < previewExceptions_.size(); ++i) {
const QJsonObject o = previewExceptions_[i].toObject();
previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1)));
previewTable_->setItem(
i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString()));
previewTable_->setItem(
i, 2, new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString()));
previewTable_->setItem(i, 3,
new QTableWidgetItem(o.value(QStringLiteral("remark")).toString()));
previewTable_->setItem(
i, 4, new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString()));
auto* delBtn = new QPushButton(QStringLiteral("删除"), previewTable_);
QPointer<AutoAnnotationDialog> weak(this);
connect(delBtn, &QPushButton::clicked, this, [weak, i]() {
if (weak) weak->deletePreviewRow(i);
});
previewTable_->setCellWidget(i, 5, delBtn);
}
detectedLabel_->setText(
QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(previewExceptions_.size()));
saveBtn_->setEnabled(!previewExceptions_.isEmpty());
}
void AutoAnnotationDialog::onSave() { void AutoAnnotationDialog::onSave() {
if (!repo_ || previewExceptions_.isEmpty()) return; if (!repo_ || previewExceptions_.isEmpty()) {
// 组装 exceptionList保留 execute 返回项的关键字段。 QMessageBox::warning(this, windowTitle(), QStringLiteral("暂无可保存的异常,请先执行自动标注"));
return;
}
// 组装 exceptionList保留 execute 返回项的关键字段(对照原版 batchCreateException
QJsonArray exceptionList; QJsonArray exceptionList;
for (const QJsonValue& v : previewExceptions_) { for (const QJsonValue& v : previewExceptions_) {
const QJsonObject o = v.toObject(); const QJsonObject o = v.toObject();

View File

@ -4,12 +4,16 @@
#include <QString> #include <QString>
#include <vector> #include <vector>
class QButtonGroup;
class QComboBox; class QComboBox;
class QLineEdit; class QLineEdit;
class QSpinBox; class QSpinBox;
class QTableWidget; class QTableWidget;
class QPushButton; class QPushButton;
class QToolButton;
class QLabel;
class QVBoxLayout; class QVBoxLayout;
class QWidget;
namespace geopro::data { namespace geopro::data {
class IDatasetCommandRepository; class IDatasetCommandRepository;
@ -17,40 +21,56 @@ class IDatasetCommandRepository;
namespace geopro::app { namespace geopro::app {
// 自动标注对话框I13复刻原版 AutoAnnotationDialog // 自动标注对话框I13复刻原版 AutoAnnotationDialog1400×600
// 左:规则列表(阈值模式 数值/百分位、min/max、最小点数、异常类型 // 左规则卡片列表标题「规则N」+ 折叠 + 删除;阈值模式 radio-button 数值/百分位、min/max、
// 执行 → executeExceptionMark(预演) → 预览表;确定保存 → batchCreateException → reloadGrid。 // 最小点数、异常类型)+「添加规则」。
// 异常类型仅支持面/polygon原版同故 listExceptionTypes 取 remarkSourceType="3"。 // 右上:数据统计(最大/最小/均值/中位数,从网格标量算)+ 预览图(后置标注)。
// 右下:预览表(序号/异常名称/异常类型/阈值范围/阈值模式/操作删除)。
// 执行 → executeExceptionMark(预演) → 预览表;确认保存 → batchCreateException → reloadGrid。
// 异常类型仅支持面/polygon原版同listExceptionTypes 取 remarkSourceType="3"。
class AutoAnnotationDialog : public QDialog { class AutoAnnotationDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
// gridValues网格标量用于右上数据统计 max/min/mean/median可空统计显示 '-')。
AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId,
QString projectId, QWidget* parent = nullptr); QString projectId, std::vector<double> gridValues,
QWidget* parent = nullptr);
private: private:
struct RuleRow { // 一条规则卡片的控件集合(卡片标题/折叠/删除 + 模式 radio + min/max + 最小点数 + 类型)。
QComboBox* mode = nullptr; // 1 数值 / 2 百分位 struct RuleCard {
QWidget* frame = nullptr; // 整张卡片(删除时移除)
QWidget* body = nullptr; // 折叠隐藏的主体
QLabel* title = nullptr; // 「规则N」
QButtonGroup* modeGroup = nullptr; // 1 数值 / 2 百分位radio-button
QLineEdit* min = nullptr; QLineEdit* min = nullptr;
QLineEdit* max = nullptr; QLineEdit* max = nullptr;
QSpinBox* minPoints = nullptr; QSpinBox* minPoints = nullptr;
QComboBox* type = nullptr; // userData = 异常类型 id QComboBox* type = nullptr; // userData = 异常类型 id
}; };
void loadExceptionTypes(); // 拉面异常类型(填充所有规则行下拉) void buildStatsBar(QVBoxLayout* into); // 右上统计条max/min/mean/median
void loadExceptionTypes(); // 拉面异常类型(填充所有规则卡片下拉)
void addRule(); // 加一条规则卡片 void addRule(); // 加一条规则卡片
void removeRule(QWidget* frame); // 删除指定卡片(至少保留一条)
void renumberRules(); // 重排卡片标题「规则N」
int currentMode(const RuleCard& c) const; // 取当前 radio 模式1/2
QJsonArray buildRuleList() const; // 组装 exceptionMarkRuleList QJsonArray buildRuleList() const; // 组装 exceptionMarkRuleList
void onExecute(); // executeExceptionMark → 预览 void onExecute(); // executeExceptionMark → 预览
void onSave(); // batchCreateException void onSave(); // batchCreateException
void deletePreviewRow(int row); // 删除一条预览异常
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;
QString dsObjectId_; QString dsObjectId_;
QString projectId_; QString projectId_;
std::vector<double> gridValues_;
QVBoxLayout* ruleHost_ = nullptr; QVBoxLayout* ruleHost_ = nullptr;
std::vector<RuleRow> rules_; std::vector<RuleCard> rules_;
QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则复用 QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则卡片复用
QTableWidget* previewTable_ = nullptr; QTableWidget* previewTable_ = nullptr;
QJsonArray previewExceptions_; // execute 返回的预览异常confirm 时批量存) QJsonArray previewExceptions_; // execute 返回的预览异常confirm 时批量存)
QLabel* detectedLabel_ = nullptr; // 「共识别到 N 个异常」
QPushButton* saveBtn_ = nullptr; QPushButton* saveBtn_ = nullptr;
}; };

View File

@ -1,9 +1,10 @@
#include "panels/chart/ExceptionDetailDialog.hpp" #include "panels/chart/ExceptionDetailDialog.hpp"
#include <QColorDialog>
#include <QComboBox> #include <QComboBox>
#include <QDoubleSpinBox> #include <QFile>
#include <QFileDialog>
#include <QFormLayout> #include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QJsonObject> #include <QJsonObject>
@ -13,7 +14,9 @@
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QPointer> #include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QTabWidget>
#include <QTableWidget> #include <QTableWidget>
#include <QTextStream>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "FormKit.hpp" #include "FormKit.hpp"
@ -22,82 +25,58 @@
namespace geopro::app { namespace geopro::app {
namespace {
// 只读色块(对照原版 disabled ColorPicker显示 hex + 背景色,不可点。
QLabel* readonlySwatch(const QString& hex, QWidget* parent) {
auto* lbl = new QLabel(hex, parent);
lbl->setStyleSheet(
QStringLiteral("background:%1;border:1px solid #ccc;padding:2px 6px;color:#fff;").arg(hex));
return lbl;
}
// 线型 code → 中文(对照原版 solid/dash
QString lineTypeName(bool dashed) {
return dashed ? QStringLiteral("虚线") : QStringLiteral("实线");
}
} // namespace
ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandRepository* repo, ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandRepository* repo,
const geopro::core::Anomaly& anomaly, QWidget* parent) const geopro::core::Anomaly& anomaly, QWidget* parent)
: QDialog(parent), repo_(repo), anomaly_(anomaly) { : QDialog(parent), repo_(repo), anomaly_(anomaly) {
setWindowTitle(QStringLiteral("异常详情")); setWindowTitle(QStringLiteral("标注详情"));
setModal(true); setModal(true);
resize(420, 460); // 右侧抽屉观感:窄而高(对照原版 ADrawer width=380
resize(geopro::app::scaledPx(380), geopro::app::scaledPx(560));
lineColor_ = QString::fromStdString(anomaly_.lineColor);
if (lineColor_.isEmpty()) lineColor_ = QStringLiteral("#000000");
auto* root = formkit::dialogRoot(this); auto* root = formkit::dialogRoot(this);
auto* card = formkit::formCard(this); // ── 头部:名称(可编辑) + 异常类型(只读) ───────────────────────────────────
auto* cardLay = formkit::cardBody(card); auto* head = formkit::makeEditForm();
auto* form = formkit::makeEditForm();
nameEdit_ = new QLineEdit(QString::fromStdString(anomaly_.name), this); nameEdit_ = new QLineEdit(QString::fromStdString(anomaly_.name), this);
formkit::capField(nameEdit_); formkit::capField(nameEdit_);
form->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_); head->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_);
auto* typeLabel = new QLabel(QString::fromStdString(anomaly_.typeName), this); auto* typeLabel = new QLabel(QString::fromStdString(anomaly_.typeName), this);
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), typeLabel); head->addRow(formkit::editLabel(QStringLiteral("异常类型")), typeLabel);
root->addLayout(head);
// 图例:线色 / 线宽 / 线型(对照原版 legend.polyline*)。 // ── 双 Tab图例信息 / 坐标信息 ───────────────────────────────────────────
colorBtn_ = new QPushButton(lineColor_, this); auto* tabs = new QTabWidget(this);
colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(lineColor_)); tabs->addTab(buildLegendTab(), QStringLiteral("图例信息"));
connect(colorBtn_, &QPushButton::clicked, this, [this]() { tabs->addTab(buildCoordTab(), QStringLiteral("坐标信息"));
const QColor c = QColorDialog::getColor(QColor(lineColor_), this, QStringLiteral("线色")); root->addWidget(tabs, 1);
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_);
// ── 底部:备注(可编辑) ───────────────────────────────────────────────────
root->addWidget(new QLabel(QStringLiteral("备注:"), this));
remarkEdit_ = new QPlainTextEdit(QString::fromStdString(anomaly_.remark), this); remarkEdit_ = new QPlainTextEdit(QString::fromStdString(anomaly_.remark), this);
remarkEdit_->setFixedHeight(geopro::app::scaledPx(60)); remarkEdit_->setFixedHeight(geopro::app::scaledPx(70));
formkit::capField(remarkEdit_); root->addWidget(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<int>(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<int>(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(); auto* btnLay = new QHBoxLayout();
btnLay->addStretch(); btnLay->addStretch();
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
okBtn_ = new QPushButton(QStringLiteral("确定"), this); okBtn_ = new QPushButton(QStringLiteral("更新"), this); // 对照原版 ok-text="更新"
okBtn_->setDefault(true); okBtn_->setDefault(true);
btnLay->addWidget(cancelBtn); btnLay->addWidget(cancelBtn);
btnLay->addWidget(okBtn_); btnLay->addWidget(okBtn_);
@ -107,6 +86,132 @@ ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandReposi
connect(okBtn_, &QPushButton::clicked, this, &ExceptionDetailDialog::onConfirm); connect(okBtn_, &QPushButton::clicked, this, &ExceptionDetailDialog::onConfirm);
} }
QWidget* ExceptionDetailDialog::buildLegendTab() {
auto* tab = new QWidget(this);
auto* form = formkit::makeEditForm();
// 类型 + 顶点数/端点数(对照原版:多边形→顶点数,多段线→端点数)。
const int mt = static_cast<int>(anomaly_.markType);
const QString geoTypeName = mt == 1 ? QStringLiteral("")
: mt == 3 ? QStringLiteral("多边形")
: mt == 4 ? QStringLiteral("文字")
: QStringLiteral("多段线");
form->addRow(formkit::editLabel(QStringLiteral("类型")), new QLabel(geoTypeName, tab));
if (mt == 2 || mt == 3) {
const QString cntLabel = mt == 3 ? QStringLiteral("顶点数") : QStringLiteral("端点数");
form->addRow(formkit::editLabel(cntLabel),
new QLabel(QString::number(anomaly_.localPts.size()), tab));
}
// 线样式(只读展示,对照原版 disabled 控件:线色/线宽/线型)。
form->addRow(formkit::editLabel(QStringLiteral("线色")),
readonlySwatch(QString::fromStdString(anomaly_.lineColor), tab));
form->addRow(formkit::editLabel(QStringLiteral("线宽")),
new QLabel(QString::number(anomaly_.lineWidth), tab));
form->addRow(formkit::editLabel(QStringLiteral("线型")),
new QLabel(lineTypeName(anomaly_.dashed), tab));
// 文字类型:另展示字体/字号/字色/不透明度(只读)。
if (mt == 4) {
form->addRow(formkit::editLabel(QStringLiteral("内容")),
new QLabel(QString::fromStdString(anomaly_.textContent), tab));
form->addRow(formkit::editLabel(QStringLiteral("字色")),
readonlySwatch(QString::fromStdString(anomaly_.textColor), tab));
form->addRow(formkit::editLabel(QStringLiteral("字号")),
new QLabel(QString::number(anomaly_.textSize), tab));
}
auto* lay = new QVBoxLayout(tab);
lay->addLayout(form);
lay->addStretch();
return tab;
}
QWidget* ExceptionDetailDialog::buildCoordTab() {
auto* tab = new QWidget(this);
auto* lay = new QVBoxLayout(tab);
// 坐标系切换 + 顶点数 + 导出(对照原版坐标信息 tab
auto* topRow = new QHBoxLayout();
topRow->addWidget(new QLabel(QStringLiteral("坐标系:"), tab));
coordSysCombo_ = new QComboBox(tab);
coordSysCombo_->addItem(QStringLiteral("图形坐标"), QStringLiteral("jb"));
// 经纬度/投影客户端暂无换算数据DTO 只解析图形坐标),先占位、切换给提示,不静默。
coordSysCombo_->addItem(QStringLiteral("经纬度坐标"), QStringLiteral("lonlat"));
coordSysCombo_->addItem(QStringLiteral("投影坐标"), QStringLiteral("projection"));
topRow->addWidget(coordSysCombo_);
topRow->addStretch();
vertexCountLabel_ =
new QLabel(QStringLiteral("顶点数:%1").arg(anomaly_.localPts.size()), tab);
topRow->addWidget(vertexCountLabel_);
auto* exportBtn = new QPushButton(QStringLiteral("导出"), tab);
topRow->addWidget(exportBtn);
lay->addLayout(topRow);
coordTable_ = new QTableWidget(0, 4, tab);
coordTable_->setHorizontalHeaderLabels(
{QStringLiteral("序号"), QStringLiteral("X"), QStringLiteral("Y"), QStringLiteral("Z")});
coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
coordTable_->setEditTriggers(QAbstractItemView::NoEditTriggers);
lay->addWidget(coordTable_, 1);
connect(coordSysCombo_, &QComboBox::currentIndexChanged, this,
[this](int) { onCoordSystemChanged(); });
connect(exportBtn, &QPushButton::clicked, this, &ExceptionDetailDialog::exportCoords);
onCoordSystemChanged(); // 初次填图形坐标
return tab;
}
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());
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, 3, new QTableWidgetItem(QString())); // Z 空(对照原版)
}
}
void ExceptionDetailDialog::exportCoords() {
if (coordSysCombo_->currentData().toString() != QStringLiteral("jb") ||
anomaly_.localPts.empty()) {
QMessageBox::information(this, windowTitle(), QStringLiteral("当前坐标系无可导出的坐标。"));
return;
}
const QString base = QString::fromStdString(anomaly_.name);
const QString path = QFileDialog::getSaveFileName(
this, QStringLiteral("导出坐标"),
(base.isEmpty() ? QStringLiteral("coordinates") : base) + QStringLiteral(".txt"),
QStringLiteral("Text (*.txt)"));
if (path.isEmpty()) return;
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this, windowTitle(), QStringLiteral("无法写入文件"));
return;
}
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";
}
f.close();
}
void ExceptionDetailDialog::onConfirm() { void ExceptionDetailDialog::onConfirm() {
if (!repo_ || anomaly_.id.empty()) { reject(); return; } if (!repo_ || anomaly_.id.empty()) { reject(); return; }
const QString name = nameEdit_->text().trimmed(); const QString name = nameEdit_->text().trimmed();
@ -114,9 +219,8 @@ void ExceptionDetailDialog::onConfirm() {
QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称")); QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称"));
return; return;
} }
// 原版详情抽屉「改名称/备注」走 PUT /business/exception 的局部更新, // 对照原版 drawerExceptionInfo onOk图例样式控件 disabled、不发 legend
// 仅发 {id, exceptionName, remark}(线样式是另一条独立 PUT且抽屉里样式控件 disabled // 仅 PUT {id, exceptionName, remark}。
// 对齐原版 contourPage.vue onOk不合并/不重发 legend。
QJsonObject body{ QJsonObject body{
{QStringLiteral("id"), QString::fromStdString(anomaly_.id)}, {QStringLiteral("id"), QString::fromStdString(anomaly_.id)},
{QStringLiteral("exceptionName"), name}, {QStringLiteral("exceptionName"), name},

View File

@ -6,9 +6,10 @@
class QLineEdit; class QLineEdit;
class QPlainTextEdit; class QPlainTextEdit;
class QDoubleSpinBox;
class QComboBox; class QComboBox;
class QTableWidget;
class QPushButton; class QPushButton;
class QLabel;
namespace geopro::data { namespace geopro::data {
class IDatasetCommandRepository; class IDatasetCommandRepository;
@ -16,10 +17,13 @@ class IDatasetCommandRepository;
namespace geopro::app { namespace geopro::app {
// 异常详情/编辑对话框I11复刻原版 drawerExceptionInfo 的可编辑部分): // 异常详情/编辑对话框I11复刻原版 drawerExceptionInfo 右侧抽屉形态):
// 名称(可编辑) / 异常类型(只读) / 图例样式(线色/线宽/线型) / 备注(可编辑) / 坐标(只读展示)。 // 双 Tab「图例信息 / 坐标信息」。
// 确认 → updateException(PUT body {id, exceptionName, remark, legend:{polylineColor, // - 头部:名称(可编辑) + 异常类型(只读)。
// polylineWidth, polylineShape}}),成功 accept(),调用方随后 reloadGrid。 // - 图例信息:类型 + 顶点/端点数 + 线色/线宽/线型/不透明度(全部「只读展示」,对照原版 disabled
// - 坐标信息:坐标系切换(图形/经纬度/投影) + 顶点数 + 坐标表(7位小数) + 导出 txt。
// - 底部:备注(可编辑)。
// 确认 → updateException(PUT body 仅 {id, exceptionName, remark},与原版一致;线样式只读不发)。
class ExceptionDetailDialog : public QDialog { class ExceptionDetailDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
@ -28,16 +32,19 @@ public:
private: private:
void onConfirm(); void onConfirm();
void onCoordSystemChanged(); // 切换坐标系 → 重填坐标表(图形有数据,经纬度/投影暂无)
void exportCoords(); // 导出当前坐标系坐标为 txt7位小数
QWidget* buildLegendTab(); // 图例信息 Tab只读样式
QWidget* buildCoordTab(); // 坐标信息 Tab
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;
geopro::core::Anomaly anomaly_; // 拷贝(不可变范式:编辑后组装新 body不改原对象 geopro::core::Anomaly anomaly_; // 拷贝(不可变范式:编辑后组装新 body不改原对象
QLineEdit* nameEdit_ = nullptr; QLineEdit* nameEdit_ = nullptr;
QPlainTextEdit* remarkEdit_ = nullptr; QPlainTextEdit* remarkEdit_ = nullptr;
QPushButton* colorBtn_ = nullptr; // 线色选择(弹 QColorDialog QComboBox* coordSysCombo_ = nullptr; // jb 图形 / lonlat 经纬度 / projection 投影
QString lineColor_; // 当前线色 hex QTableWidget* coordTable_ = nullptr;
QDoubleSpinBox* widthSpin_ = nullptr; QLabel* vertexCountLabel_ = nullptr;
QComboBox* shapeCombo_ = nullptr; // solid / dash
QPushButton* okBtn_ = nullptr; QPushButton* okBtn_ = nullptr;
}; };

View File

@ -56,9 +56,24 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo,
formkit::capField(markTypeCombo_); formkit::capField(markTypeCombo_);
form->addRow(formkit::editLabel(QStringLiteral("标注类型")), markTypeCombo_); form->addRow(formkit::editLabel(QStringLiteral("标注类型")), markTypeCombo_);
// 异常类型行:下拉 + 「新增异常类型」按钮(对照原版 exceptionDialog 同行布局)。
exceptionTypeCombo_ = new QComboBox(this); exceptionTypeCombo_ = new QComboBox(this);
formkit::capField(exceptionTypeCombo_); formkit::capField(exceptionTypeCombo_);
form->addRow(formkit::editLabel(QStringLiteral("异常类型")), exceptionTypeCombo_); addTypeBtn_ = new QPushButton(QStringLiteral("新增异常类型"), this);
auto* typeRow = new QWidget(this);
auto* typeRowLay = new QHBoxLayout(typeRow);
typeRowLay->setContentsMargins(0, 0, 0, 0);
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 端完成,新增后此处下拉会同步可选。"));
});
nameEdit_ = new QLineEdit(this); nameEdit_ = new QLineEdit(this);
nameEdit_->setPlaceholderText(QStringLiteral("数据名称+异常类型代号+序号")); nameEdit_->setPlaceholderText(QStringLiteral("数据名称+异常类型代号+序号"));
@ -143,10 +158,20 @@ QJsonArray ExceptionDialog::manualCoordinates() const {
} }
void ExceptionDialog::onTypeChanged() { void ExceptionDialog::onTypeChanged() {
// 主路径为图上绘形 → 坐标表默认留空(不自动补行),仅刷新异常类型列表。 // 对照原版 handleAnnotationTypeChange标注类型变 → 清空名称(待重选类型后回填)+
// 重拉对应几何形态的异常类型列表 + 刷新「新增类型」按钮可用性。
nameEdit_->clear();
updateAddTypeEnabled();
loadExceptionTypes(); loadExceptionTypes();
} }
void ExceptionDialog::updateAddTypeEnabled() {
if (!addTypeBtn_) return;
// 原版:文字类型(4) 或 未选标注类型时禁用「新增异常类型」。
const QString mt = markTypeValue();
addTypeBtn_->setEnabled(!mt.isEmpty() && mt != QStringLiteral("4"));
}
void ExceptionDialog::loadExceptionTypes() { void ExceptionDialog::loadExceptionTypes() {
if (!repo_) return; if (!repo_) return;
QPointer<ExceptionDialog> self(this); QPointer<ExceptionDialog> self(this);
@ -172,12 +197,9 @@ void ExceptionDialog::suggestName() {
const QString typeId = exceptionTypeCombo_->currentData().toString(); const QString typeId = exceptionTypeCombo_->currentData().toString();
if (typeId.isEmpty()) return; if (typeId.isEmpty()) return;
QPointer<ExceptionDialog> self(this); QPointer<ExceptionDialog> self(this);
repo_->getExceptionName(typeId, remarkSourceId_, [self](bool ok, QJsonObject data, QString) { // 对照原版 handleExceptionTypeChange每次选/换异常类型都回填名称res.data 为纯字符串)。
repo_->getExceptionName(typeId, remarkSourceId_, [self](bool ok, QString name, QString) {
if (!self || !ok) return; 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); self->nameEdit_->setText(name);
}); });
} }

View File

@ -39,10 +39,11 @@ public:
QJsonArray manualCoordinates() const; QJsonArray manualCoordinates() const;
private: private:
void onTypeChanged(); // 标注类型变 → 重拉异常类型列表 + 调整坐标表最少行 void onTypeChanged(); // 标注类型变 → 清名称 + 重拉异常类型列表 + 刷新「新增类型」可用性
void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType) void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType)
void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称建议 void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称回填
void onConfirm(); // 校验 → 有手填坐标则直接 newException否则 accept() 交给绘形 void onConfirm(); // 校验 → 有手填坐标则直接 newException否则 accept() 交给绘形
void updateAddTypeEnabled(); // 「新增异常类型」可用性:文字类型/未选类型时禁用(对照原版)
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;
QString projectId_; QString projectId_;
@ -50,6 +51,7 @@ private:
QComboBox* markTypeCombo_ = nullptr; // userData = "1".."4" QComboBox* markTypeCombo_ = nullptr; // userData = "1".."4"
QComboBox* exceptionTypeCombo_ = nullptr; // userData = 异常类型 id QComboBox* exceptionTypeCombo_ = nullptr; // userData = 异常类型 id
QPushButton* addTypeBtn_ = nullptr; // 新增异常类型(对照原版,文字/未选类型禁用)
QLineEdit* nameEdit_ = nullptr; QLineEdit* nameEdit_ = nullptr;
QPlainTextEdit* remarkEdit_ = nullptr; QPlainTextEdit* remarkEdit_ = nullptr;
QTableWidget* coordTable_ = nullptr; QTableWidget* coordTable_ = nullptr;

View File

@ -0,0 +1,113 @@
#include "panels/chart/ExceptionTextDialog.hpp"
#include <QColorDialog>
#include <QComboBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QSlider>
#include <QSpinBox>
#include <QVBoxLayout>
#include "FormKit.hpp"
#include "Theme.hpp"
namespace geopro::app {
ExceptionTextDialog::ExceptionTextDialog(QWidget* parent)
: QDialog(parent), color_(QStringLiteral("#000000")) {
setWindowTitle(QStringLiteral("文本编辑"));
setModal(true);
resize(geopro::app::scaledPx(560), geopro::app::scaledPx(420));
auto* root = formkit::dialogRoot(this);
auto* card = formkit::formCard(this);
auto* cardLay = formkit::cardBody(card);
auto* form = formkit::makeEditForm();
// 字体(对照原版 1宋体/2微软雅黑/3黑体/4楷体值为字符串 int
fontCombo_ = new QComboBox(this);
fontCombo_->addItem(QStringLiteral("宋体"), QStringLiteral("1"));
fontCombo_->addItem(QStringLiteral("微软雅黑"), QStringLiteral("2"));
fontCombo_->addItem(QStringLiteral("黑体"), QStringLiteral("3"));
fontCombo_->addItem(QStringLiteral("楷体"), QStringLiteral("4"));
formkit::capField(fontCombo_);
form->addRow(formkit::editLabel(QStringLiteral("字体")), fontCombo_);
// 大小px默认 12
sizeSpin_ = new QSpinBox(this);
sizeSpin_->setRange(1, 200);
sizeSpin_->setValue(12);
formkit::capField(sizeSpin_);
form->addRow(formkit::editLabel(QStringLiteral("大小")), sizeSpin_);
// 颜色(默认黑)。
colorBtn_ = new QPushButton(color_, this);
colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(color_));
connect(colorBtn_, &QPushButton::clicked, this, &ExceptionTextDialog::pickColor);
formkit::capField(colorBtn_);
form->addRow(formkit::editLabel(QStringLiteral("颜色")), colorBtn_);
// 不透明度0100%,默认 100
auto* opRow = new QWidget(this);
auto* opLay = new QHBoxLayout(opRow);
opLay->setContentsMargins(0, 0, 0, 0);
opacitySlider_ = new QSlider(Qt::Horizontal, opRow);
opacitySlider_->setRange(0, 100);
opacitySlider_->setValue(100);
opacityLabel_ = new QLabel(QStringLiteral("100%"), opRow);
opacityLabel_->setFixedWidth(geopro::app::scaledPx(40));
connect(opacitySlider_, &QSlider::valueChanged, this,
[this](int v) { opacityLabel_->setText(QStringLiteral("%1%").arg(v)); });
opLay->addWidget(opacitySlider_, 1);
opLay->addWidget(opacityLabel_);
form->addRow(formkit::editLabel(QStringLiteral("不透明度")), opRow);
cardLay->addLayout(form);
// 内容(必填)。
cardLay->addWidget(new QLabel(QStringLiteral("内容:"), this));
contentEdit_ = new QPlainTextEdit(this);
contentEdit_->setPlaceholderText(QStringLiteral("请输入内容"));
contentEdit_->setFixedHeight(geopro::app::scaledPx(140));
cardLay->addWidget(contentEdit_);
root->addWidget(card);
auto* btnLay = new QHBoxLayout();
btnLay->addStretch();
auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this);
auto* 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, &ExceptionTextDialog::onConfirm);
}
void ExceptionTextDialog::pickColor() {
const QColor c = QColorDialog::getColor(QColor(color_), this, QStringLiteral("颜色"));
if (!c.isValid()) return;
color_ = c.name(QColor::HexRgb);
colorBtn_->setText(color_);
colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(color_));
}
QString ExceptionTextDialog::fontFamilyValue() const { return fontCombo_->currentData().toString(); }
int ExceptionTextDialog::fontSize() const { return sizeSpin_->value(); }
QString ExceptionTextDialog::color() const { return color_; }
int ExceptionTextDialog::opacityPercent() const { return opacitySlider_->value(); }
QString ExceptionTextDialog::content() const { return contentEdit_->toPlainText().trimmed(); }
void ExceptionTextDialog::onConfirm() {
if (content().isEmpty()) { // 对照原版:内容必填。
QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入文本内容"));
return;
}
accept();
}
} // namespace geopro::app

View File

@ -0,0 +1,44 @@
#pragma once
#include <QDialog>
#include <QString>
class QComboBox;
class QSpinBox;
class QSlider;
class QPlainTextEdit;
class QPushButton;
class QLabel;
namespace geopro::app {
// 文字标注编辑对话框I9复刻原版 exceptionText.vue「文本编辑」
// 字体(1宋体/2微软雅黑/3黑体/4楷体) / 大小(px) / 颜色 / 不透明度(0100%) / 内容(必填)。
// 确定 → accept(),调用方读取各字段组装 newException 的 customLegend。
// 时序对照原版:文字类型绘制落点后弹此对话框,提交带 customLegend
// {text, content, color, size, font(CSS族), opacity(01)}。
class ExceptionTextDialog : public QDialog {
Q_OBJECT
public:
explicit ExceptionTextDialog(QWidget* parent = nullptr);
// accept() 后供调用方读取。
QString fontFamilyValue() const; // "1".."4"(字体族 int 字符串)
int fontSize() const; // px
QString color() const; // #rrggbb
int opacityPercent() const; // 0100
QString content() const; // 文字内容
private:
void onConfirm(); // 校验内容非空 → accept()
void pickColor();
QComboBox* fontCombo_ = nullptr;
QSpinBox* sizeSpin_ = nullptr;
QPushButton* colorBtn_ = nullptr;
QString color_; // 当前色 #rrggbb
QSlider* opacitySlider_ = nullptr;
QLabel* opacityLabel_ = nullptr;
QPlainTextEdit* contentEdit_ = nullptr;
};
} // namespace geopro::app

View File

@ -4,6 +4,7 @@
#include <QCheckBox> #include <QCheckBox>
#include <QCursor> #include <QCursor>
#include <QHash>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
@ -40,6 +41,7 @@
#include "panels/chart/ContourPlotItem.hpp" #include "panels/chart/ContourPlotItem.hpp"
#include "panels/chart/ExceptionDetailDialog.hpp" #include "panels/chart/ExceptionDetailDialog.hpp"
#include "panels/chart/ExceptionDialog.hpp" #include "panels/chart/ExceptionDialog.hpp"
#include "panels/chart/ExceptionTextDialog.hpp"
#include "panels/chart/FilterDialog.hpp" #include "panels/chart/FilterDialog.hpp"
#include "panels/chart/GridWizardDialog.hpp" #include "panels/chart/GridWizardDialog.hpp"
#include "panels/chart/LivePanner.hpp" #include "panels/chart/LivePanner.hpp"
@ -435,6 +437,28 @@ void GridDataChartView::openExceptionDialog() {
QJsonArray coords; QJsonArray coords;
for (const QPointF& p : pts) for (const QPointF& p : pts)
coords.append(QJsonObject{{QStringLiteral("x"), p.x()}, {QStringLiteral("y"), p.y()}}); coords.append(QJsonObject{{QStringLiteral("x"), p.x()}, {QStringLiteral("y"), p.y()}});
// 文字类型(4):落点后另弹「文本编辑」对话框,提交带 customLegend对照原版 exceptionText
if (markType == QStringLiteral("4")) {
ExceptionTextDialog tdlg(self);
if (tdlg.exec() != QDialog::Accepted) return; // 取消 → 不提交
// 字体族 int → CSS family对照原版 fontFamily 映射)。
static const QHash<QString, QString> kCssFont{
{QStringLiteral("1"), QStringLiteral("SimSun")},
{QStringLiteral("2"), QStringLiteral("Microsoft YaHei")},
{QStringLiteral("3"), QStringLiteral("SimHei")},
{QStringLiteral("4"), QStringLiteral("KaiTi")}};
const QString content = tdlg.content();
QJsonObject customLegend{
{QStringLiteral("text"), content},
{QStringLiteral("content"), content},
{QStringLiteral("color"), tdlg.color()},
{QStringLiteral("size"), tdlg.fontSize()},
{QStringLiteral("font"), kCssFont.value(tdlg.fontFamilyValue())},
{QStringLiteral("opacity"), tdlg.opacityPercent() / 100.0}, // 01
};
self->submitDrawnException(markType, typeId, name, remark, coords, customLegend);
return;
}
self->submitDrawnException(markType, typeId, name, remark, coords); self->submitDrawnException(markType, typeId, name, remark, coords);
}); });
drawTool_->setOnCancel([] {}); // 取消绘形:无操作(不提交) drawTool_->setOnCancel([] {}); // 取消绘形:无操作(不提交)
@ -443,7 +467,8 @@ void GridDataChartView::openExceptionDialog() {
void GridDataChartView::submitDrawnException(const QString& markType, const QString& typeId, void GridDataChartView::submitDrawnException(const QString& markType, const QString& typeId,
const QString& name, const QString& remark, const QString& name, const QString& remark,
const QJsonArray& coords) { const QJsonArray& coords,
const QJsonObject& customLegend) {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) return; if (!cmdRepo_ || dsId.isEmpty()) return;
@ -456,6 +481,8 @@ void GridDataChartView::submitDrawnException(const QString& markType, const QStr
{QStringLiteral("projectId"), projectId}, {QStringLiteral("projectId"), projectId},
{QStringLiteral("location"), QJsonObject{{QStringLiteral("coordinate"), coords}}}, {QStringLiteral("location"), QJsonObject{{QStringLiteral("coordinate"), coords}}},
}; };
// 文字类型带 customLegend对照原版仅文字非空其它形态不带此字段
if (!customLegend.isEmpty()) body.insert(QStringLiteral("customLegend"), customLegend);
QPointer<GridDataChartView> self(this); QPointer<GridDataChartView> self(this);
cmdRepo_->newException(body, [self](bool ok, QString msg) { cmdRepo_->newException(body, [self](bool ok, QString msg) {
if (!self) return; if (!self) return;
@ -472,7 +499,8 @@ void GridDataChartView::openAutoAnnotation() {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; }
AutoAnnotationDialog dlg(cmdRepo_, dsId, projectId, this); // 透传网格标量用于右上数据统计max/min/mean/median
AutoAnnotationDialog dlg(cmdRepo_, dsId, projectId, grid_.values(), this);
if (dlg.exec() == QDialog::Accepted) reloadGrid(); if (dlg.exec() == QDialog::Accepted) reloadGrid();
} }

View File

@ -3,6 +3,7 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include <QJsonObject> // submitDrawnException 默认参数 const QJsonObject& = {} 需完整类型
#include <QString> #include <QString>
#include <QWidget> #include <QWidget>
@ -81,10 +82,12 @@ private:
void applySimplify(); // I8把当前滑块容差透传给 ContourPlotItem 并重绘 void applySimplify(); // I8把当前滑块容差透传给 ContourPlotItem 并重绘
void showNotImplemented(QWidget* anchor); // 占位提示(无仓储/无 dsId void showNotImplemented(QWidget* anchor); // 占位提示(无仓储/无 dsId
void openExceptionDialog(); // I9 异常创建(弹窗选类型 → 图上绘形 → 提交) void openExceptionDialog(); // I9 异常创建(弹窗选类型 → 图上绘形 →[文字另弹文本编辑]→ 提交)
// I9 图上绘形完成:组装 body 提交 newException成功 reloadGrid // I9 图上绘形完成:组装 body 提交 newException成功 reloadGrid
// customLegend 仅文字类型非空(对照原版:文字 customLegend其它形态留空 {})。
void submitDrawnException(const QString& markType, const QString& typeId, const QString& name, void submitDrawnException(const QString& markType, const QString& typeId, const QString& name,
const QString& remark, const QJsonArray& coords); const QString& remark, const QJsonArray& coords,
const QJsonObject& customLegend = {});
void openAutoAnnotation(); // I13 自动标注 void openAutoAnnotation(); // I13 自动标注
void deleteAnomaly(int index); // I10 异常删除 void deleteAnomaly(int index); // I10 异常删除
void showAnomalyDetail(int index); // I11 异常详情/编辑 void showAnomalyDetail(int index); // I11 异常详情/编辑

View File

@ -3,7 +3,7 @@
#include <vector> #include <vector>
namespace geopro::core { namespace geopro::core {
enum class AnomalyMarkType { Point = 1, Polyline = 2, Polygon = 3 }; enum class AnomalyMarkType { Point = 1, Polyline = 2, Polygon = 3, Text = 4 };
struct Vec2 { double x, y; }; struct Vec2 { double x, y; };
struct Vec3 { double x, y, z; }; struct Vec3 { double x, y, z; };
@ -27,6 +27,13 @@ struct Anomaly {
std::string lineColor = "#000000"; // legend.polylineColor std::string lineColor = "#000000"; // legend.polylineColor
double lineWidth = 1.0; // legend.polylineWidth double lineWidth = 1.0; // legend.polylineWidth
bool dashed = true; // legend.polylineShape == "dash" bool dashed = true; // legend.polylineShape == "dash"
// 文字标注markType==Text专属customLegend 字段(对照原版 exceptionText.vue
// 仅文字类型有意义;其它类型留默认。
std::string textContent; // customLegend.content / text文字内容
std::string textColor = "#000000"; // customLegend.color字色
int textSize = 12; // customLegend.size字号 px
int textFont = 1; // customLegend 字体族 int1宋体/2微软雅黑/3黑体/4楷体
double textOpacity = 1.0; // customLegend.opacity01
}; };
} // namespace geopro::core } // namespace geopro::core

View File

@ -50,6 +50,24 @@ void wireArray(net::IApiCall* call, std::function<void(bool, QJsonArray, QString
}); });
} }
// 返回字符串:(bool ok, QString value, QString msg)。
// 原版 res.data 为纯字符串时parseBody 会包成 {"value": "<string>"},故取 data.value。
void wireString(net::IApiCall* call, std::function<void(bool, QString, QString)> 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")).toString(), resp.msg);
});
}
// 返回对象:(bool ok, QJsonObject data, QString msg)。 // 返回对象:(bool ok, QJsonObject data, QString msg)。
void wireObject(net::IApiCall* call, std::function<void(bool, QJsonObject, QString)> cb) { void wireObject(net::IApiCall* call, std::function<void(bool, QJsonObject, QString)> cb) {
if (call == nullptr) { if (call == nullptr) {
@ -368,10 +386,11 @@ void ApiDatasetCommandRepository::listExceptionTypes(
void ApiDatasetCommandRepository::getExceptionName( void ApiDatasetCommandRepository::getExceptionName(
const QString& exceptionTypeId, const QString& remarkSourceId, const QString& exceptionTypeId, const QString& remarkSourceId,
std::function<void(bool, QJsonObject, QString)> cb) { std::function<void(bool, QString, QString)> cb) {
QJsonObject body{{QStringLiteral("exceptionTypeId"), exceptionTypeId}, QJsonObject body{{QStringLiteral("exceptionTypeId"), exceptionTypeId},
{QStringLiteral("remarkSourceId"), remarkSourceId}}; {QStringLiteral("remarkSourceId"), remarkSourceId}};
wireObject(api_.postJsonAsync(QStringLiteral("/business/exception/getExceptionName"), body), // 原版 res.data 直接是名称字符串,回传纯字符串。
wireString(api_.postJsonAsync(QStringLiteral("/business/exception/getExceptionName"), body),
std::move(cb)); std::move(cb));
} }

View File

@ -83,7 +83,7 @@ public:
std::function<void(bool ok, QJsonArray list, QString msg)> cb) override; std::function<void(bool ok, QJsonArray list, QString msg)> cb) override;
void getExceptionName( void getExceptionName(
const QString& exceptionTypeId, const QString& remarkSourceId, const QString& exceptionTypeId, const QString& remarkSourceId,
std::function<void(bool ok, QJsonObject data, QString msg)> cb) override; std::function<void(bool ok, QString name, QString msg)> cb) override;
void newException(const QJsonObject& body, void newException(const QJsonObject& body,
std::function<void(bool ok, QString msg)> cb) override; std::function<void(bool ok, QString msg)> cb) override;
void deleteException(const QString& id, void deleteException(const QString& id,

View File

@ -71,13 +71,23 @@ std::vector<Anomaly> parseDatasetAnomalies(const QJsonArray& arr) {
a.exceptionTypeId = o.value("exceptionTypeId").toString().toStdString(); a.exceptionTypeId = o.value("exceptionTypeId").toString().toStdString();
a.remark = o.value("remark").toString().toStdString(); a.remark = o.value("remark").toString().toStdString();
a.createTime = o.value("createTime").toString().toStdString(); a.createTime = o.value("createTime").toString().toStdString();
const int mt = o.value("exceptionMarkType").toInt(2); // 1=点 2=线 3=面 const int mt = o.value("exceptionMarkType").toInt(2); // 1=点 2=线 3=面 4=文字
a.markType = (mt >= 1 && mt <= 3) ? static_cast<AnomalyMarkType>(mt) a.markType = (mt >= 1 && mt <= 4) ? static_cast<AnomalyMarkType>(mt)
: AnomalyMarkType::Polyline; // 越界值兜底为线 : AnomalyMarkType::Polyline; // 越界值兜底为线
const QJsonObject lg = o.value("legend").toObject(); const QJsonObject lg = o.value("legend").toObject();
a.lineColor = lg.value("polylineColor").toString("#000000").toStdString(); a.lineColor = lg.value("polylineColor").toString("#000000").toStdString();
a.lineWidth = lg.value("polylineWidth").toDouble(1.0); a.lineWidth = lg.value("polylineWidth").toDouble(1.0);
a.dashed = lg.value("polylineShape").toString() == "dash"; a.dashed = lg.value("polylineShape").toString() == "dash";
// 文字标注 customLegend详情只读展示用content/color/size/opacity。
const QJsonObject cl = o.value("customLegend").toObject();
if (!cl.isEmpty()) {
QString content = cl.value("content").toString();
if (content.isEmpty()) content = cl.value("text").toString();
a.textContent = content.toStdString();
a.textColor = cl.value("color").toString("#000000").toStdString();
a.textSize = cl.value("size").toInt(12);
a.textOpacity = cl.value("opacity").toDouble(1.0);
}
for (auto c : o.value("location").toObject().value("coordinate").toArray()) { for (auto c : o.value("location").toObject().value("coordinate").toArray()) {
const QJsonObject p = c.toObject(); const QJsonObject p = c.toObject();
a.localPts.push_back(Vec2{p.value("x").toDouble(), p.value("y").toDouble()}); a.localPts.push_back(Vec2{p.value("x").toDouble(), p.value("y").toDouble()});

View File

@ -169,10 +169,10 @@ public:
// 获取异常名称POST /business/exception/getExceptionName // 获取异常名称POST /business/exception/getExceptionName
// body {exceptionTypeId, remarkSourceId}(对应原版 queryExceptionNameInProfileInversion // body {exceptionTypeId, remarkSourceId}(对应原版 queryExceptionNameInProfileInversion
// 回调 data = 响应 data 对象(含建议名称) // 原版 res.data 是「纯字符串」(建议名称本身),故回调直接回传 name 字符串
virtual void getExceptionName( virtual void getExceptionName(
const QString& exceptionTypeId, const QString& remarkSourceId, const QString& exceptionTypeId, const QString& remarkSourceId,
std::function<void(bool ok, QJsonObject data, QString msg)> cb) = 0; std::function<void(bool ok, QString name, QString msg)> cb) = 0;
// 新增异常POST /business/exception // 新增异常POST /business/exception
// body 含 {exceptionName, exceptionTypeId, location, projectId, remarkSourceId, remarkSourceType, remark} // body 含 {exceptionName, exceptionTypeId, location, projectId, remarkSourceId, remarkSourceType, remark}

View File

@ -146,6 +146,55 @@ TEST(QuillDelta, OrderedListBlockRoundTrip) {
EXPECT_TRUE(found); EXPECT_TRUE(found);
} }
// ── 行内:背景色往返(原版 ql-background─────────────────────────────────
TEST(QuillDelta, BackgroundColorRoundTrip) {
const auto ops = opsFromJson(R"([
{"insert":"hl","attributes":{"background":"#ffff00"}},
{"insert":"\n"}
])");
QTextDocument doc;
deltaToDocument(ops, doc);
const QTextCharFormat f = doc.begin().begin().fragment().charFormat();
EXPECT_EQ(f.background().color().name(QColor::HexRgb), QStringLiteral("#ffff00"));
const QJsonObject a = attrsOf(documentToDelta(doc), 0);
EXPECT_EQ(a.value(QStringLiteral("background")).toString(), QStringLiteral("#ffff00"));
}
// ── 行内:字体族往返(原版 ql-font token ↔ Qt family──────────────────────
TEST(QuillDelta, FontFamilyRoundTrip) {
const auto ops = opsFromJson(R"([
{"insert":"font","attributes":{"font":"Microsoft-YaHei"}},
{"insert":"\n"}
])");
QTextDocument doc;
deltaToDocument(ops, doc);
const QTextCharFormat f = doc.begin().begin().fragment().charFormat();
EXPECT_EQ(f.fontFamilies().toStringList().value(0), QStringLiteral("Microsoft YaHei"));
// 回写应还原为原版 token连字符形式
const QJsonObject a = attrsOf(documentToDelta(doc), 0);
EXPECT_EQ(a.value(QStringLiteral("font")).toString(), QStringLiteral("Microsoft-YaHei"));
}
// ── 块级:对齐往返(原版 ql-align────────────────────────────────────────
TEST(QuillDelta, AlignBlockRoundTrip) {
const auto ops = opsFromJson(R"([
{"insert":"centered"},
{"insert":"\n","attributes":{"align":"center"}}
])");
QTextDocument doc;
deltaToDocument(ops, doc);
EXPECT_EQ(doc.begin().blockFormat().alignment() & Qt::AlignHorizontal_Mask, Qt::AlignHCenter);
bool found = false;
for (const QJsonValue& v : documentToDelta(doc)) {
const QJsonObject a = v.toObject().value(QStringLiteral("attributes")).toObject();
if (a.value(QStringLiteral("align")).toString() == QStringLiteral("center")) found = true;
}
EXPECT_TRUE(found);
}
// ── 容错:无法识别的 attributes 降级(保留文本,不崩) ────────────────────── // ── 容错:无法识别的 attributes 降级(保留文本,不崩) ──────────────────────
TEST(QuillDelta, UnknownAttributesDegradeGracefully) { TEST(QuillDelta, UnknownAttributesDegradeGracefully) {
const auto ops = opsFromJson(R"([ const auto ops = opsFromJson(R"([