From 6bc7c23a8c5ff4a01c91ef32a4152a5d5b6e83de Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 23 Jun 2026 12:26:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(detail):=20inversion=20=E5=BC=82=E5=B8=B8/?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=A0=87=E6=B3=A8/=E6=8F=8F=E8=BF=B0?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E8=BF=94=E5=B7=A5=E5=AF=B9=E9=BD=90=E5=8E=9F?= =?UTF-8?q?=E7=89=88=20+=20=E4=BF=AE=20getExceptionName?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - I9 文字标注:落点后弹 ExceptionTextDialog(字体/大小/颜色/不透明度/内容)写 customLegend; 补"新增异常类型"按钮(完整子流程标注待办);Anomaly 增 Text=4 + 文字字段 - 修 getExceptionName:原版 data 为纯字符串,客户端误当对象解析→名称回填失败; 改 wireString 解析,回调签名改 (bool,QString,QString);切类型每次回填 - I10 删除文案对齐原版 contourContentDelete - I11 详情返工:380px 抽屉式双Tab(图例/坐标),线样式改只读,坐标系切换(图形/经纬度/投影) +顶点数+导出txt(经纬度/投影无换算数据,标注;图形坐标可用),提交体仍 {id,exceptionName,remark} - I13 自动标注返工:1400px,规则卡片(标题/折叠/删除),阈值模式 radio(切换清空), 右上统计(max/min/mean/median),预览表序号+逐条删除(等值线预览图高成本待办) - I14 富文本补 背景色/对齐/字体族 工具栏 + QuillDelta 字体族往返;去下划线/列表(原版无) build all 绿,339/339。GPR/金字塔 WIP 未碰。 --- src/app/CMakeLists.txt | 1 + src/app/panels/AnomalyTablePanel.cpp | 9 +- src/app/panels/DescriptionPanel.cpp | 81 +++-- src/app/panels/QuillDelta.cpp | 27 ++ src/app/panels/chart/AutoAnnotationDialog.cpp | 297 ++++++++++++++---- src/app/panels/chart/AutoAnnotationDialog.hpp | 42 ++- .../panels/chart/ExceptionDetailDialog.cpp | 232 ++++++++++---- .../panels/chart/ExceptionDetailDialog.hpp | 25 +- src/app/panels/chart/ExceptionDialog.cpp | 36 ++- src/app/panels/chart/ExceptionDialog.hpp | 6 +- src/app/panels/chart/ExceptionTextDialog.cpp | 113 +++++++ src/app/panels/chart/ExceptionTextDialog.hpp | 44 +++ src/app/panels/chart/GridDataChartView.cpp | 32 +- src/app/panels/chart/GridDataChartView.hpp | 7 +- src/core/model/Anomaly.hpp | 9 +- src/data/api/ApiDatasetCommandRepository.cpp | 23 +- src/data/api/ApiDatasetCommandRepository.hpp | 2 +- src/data/dto/DatasetChartDto.cpp | 14 +- src/data/repo/IDatasetCommandRepository.hpp | 4 +- tests/app/test_quill_delta.cpp | 49 +++ 20 files changed, 863 insertions(+), 190 deletions(-) create mode 100644 src/app/panels/chart/ExceptionTextDialog.cpp create mode 100644 src/app/panels/chart/ExceptionTextDialog.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 0604841..f9535de 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -52,6 +52,7 @@ add_executable(geopro_desktop WIN32 panels/chart/WhiteningDialog.cpp panels/chart/FilterDialog.cpp panels/chart/ExceptionDialog.cpp + panels/chart/ExceptionTextDialog.cpp panels/chart/ExceptionDetailDialog.cpp panels/chart/AutoAnnotationDialog.cpp panels/chart/ContourSimplify.cpp diff --git a/src/app/panels/AnomalyTablePanel.cpp b/src/app/panels/AnomalyTablePanel.cpp index ad6794e..34526d8 100644 --- a/src/app/panels/AnomalyTablePanel.cpp +++ b/src/app/panels/AnomalyTablePanel.cpp @@ -6,7 +6,9 @@ #include #include 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) { auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); @@ -53,9 +55,10 @@ void AnomalyTablePanel::setAnomalies(const std::vector& l connect(btnDetail, &QToolButton::clicked, this, [this, i]() { emit detailRequested(i); }); auto* btnDelete = new QToolButton(ops); btnDelete->setText(QStringLiteral("删除")); connect(btnDelete, &QToolButton::clicked, this, [this, i]() { - // 原版 a-popconfirm 二次确认 → 这里用 QMessageBox 确认。 + // 原版 a-popconfirm 二次确认(contourContentDelete)→ 这里用 QMessageBox,文案对齐原版。 if (QMessageBox::question(this, QStringLiteral("提示"), - QStringLiteral("确定删除该异常?")) == QMessageBox::Yes) + QStringLiteral("该操作会删除该异常标注数据,确认?")) == + QMessageBox::Yes) emit deleteRequested(i); }); opLay->addWidget(eye); diff --git a/src/app/panels/DescriptionPanel.cpp b/src/app/panels/DescriptionPanel.cpp index 3cbc2b6..b06b2a8 100644 --- a/src/app/panels/DescriptionPanel.cpp +++ b/src/app/panels/DescriptionPanel.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -20,6 +19,13 @@ namespace { // 字号下拉选项(px)——对照原版 ql-size 的 12~32px。 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 DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) { @@ -57,6 +63,8 @@ void DescriptionPanel::buildToolbar(QToolBar* tb) { connect(btn, &QToolButton::toggled, this, applier); return btn; }; + // 对照原版 Quill 工具栏:粗/斜 + 字色/背景色 + 对齐 + 标题 + 字号 + 字体族。 + // (原版无下划线/列表,故此处不设,以贴近原版。) addToggle(QStringLiteral("B"), [this](bool on) { QTextCharFormat f; f.setFontWeight(on ? QFont::Bold : QFont::Normal); @@ -67,15 +75,11 @@ void DescriptionPanel::buildToolbar(QToolBar* tb) { f.setFontItalic(on); edit_->mergeCurrentCharFormat(f); }); - addToggle(QStringLiteral("U"), [this](bool on) { - QTextCharFormat f; - f.setFontUnderline(on); - edit_->mergeCurrentCharFormat(f); - }); // 字色:弹色板,作用于选区前景色。 auto* colorBtn = new QToolButton(tb); colorBtn->setText(QStringLiteral("A")); + colorBtn->setToolTip(QStringLiteral("字体颜色")); tb->addWidget(colorBtn); connect(colorBtn, &QToolButton::clicked, this, [this]() { const QColor c = QColorDialog::getColor(Qt::black, this, QStringLiteral("字体颜色")); @@ -85,18 +89,33 @@ void DescriptionPanel::buildToolbar(QToolBar* tb) { edit_->mergeCurrentCharFormat(f); }); - // 字号下拉(px)。 - auto* sizeBox = new QComboBox(tb); - for (int px : kFontSizesPx) sizeBox->addItem(QStringLiteral("%1px").arg(px), px); - sizeBox->setCurrentIndex(2); // 默认 16px(与原版一致)。 - tb->addWidget(sizeBox); - connect(sizeBox, &QComboBox::currentIndexChanged, this, [this, sizeBox](int) { - const int px = sizeBox->currentData().toInt(); + // 背景色:弹色板,作用于选区背景色(对照原版 ql-background)。 + auto* bgBtn = new QToolButton(tb); + bgBtn->setText(QStringLiteral("▢")); + bgBtn->setToolTip(QStringLiteral("背景颜色")); + tb->addWidget(bgBtn); + connect(bgBtn, &QToolButton::clicked, this, [this]() { + const QColor c = QColorDialog::getColor(Qt::yellow, this, QStringLiteral("背景颜色")); + if (!c.isValid()) return; QTextCharFormat f; - f.setFontPointSize(px * 3.0 / 4.0); // px→pt。 + f.setBackground(c); 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)——块级,作用于当前段。 auto* headerBox = new QComboBox(tb); headerBox->addItem(QStringLiteral("正文"), 0); @@ -111,17 +130,29 @@ void DescriptionPanel::buildToolbar(QToolBar* tb) { edit_->setTextCursor(cur); }); - // 有序 / 无序列表——块级。 - auto addListBtn = [this, tb](const QString& label, QTextListFormat::Style style) { - auto* btn = new QToolButton(tb); - btn->setText(label); - tb->addWidget(btn); - connect(btn, &QToolButton::clicked, this, [this, style]() { - edit_->textCursor().createList(style); - }); - }; - addListBtn(QStringLiteral("1."), QTextListFormat::ListDecimal); - addListBtn(QStringLiteral("•"), QTextListFormat::ListDisc); + // 字号下拉(px)。 + auto* sizeBox = new QComboBox(tb); + for (int px : kFontSizesPx) sizeBox->addItem(QStringLiteral("%1px").arg(px), px); + sizeBox->setCurrentIndex(2); // 默认 16px(与原版一致)。 + tb->addWidget(sizeBox); + connect(sizeBox, &QComboBox::currentIndexChanged, this, [this, sizeBox](int) { + const int px = sizeBox->currentData().toInt(); + QTextCharFormat f; + f.setFontPointSize(px * 3.0 / 4.0); // px→pt。 + edit_->mergeCurrentCharFormat(f); + }); + + // 字体族下拉(对照原版 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) { diff --git a/src/app/panels/QuillDelta.cpp b/src/app/panels/QuillDelta.cpp index eab7b97..2130aea 100644 --- a/src/app/panels/QuillDelta.cpp +++ b/src/app/panels/QuillDelta.cpp @@ -15,6 +15,28 @@ namespace { // 颜色转 Quill 习惯的小写 #rrggbb(与原版 ql-color/ql-background 选项一致)。 QString hexOf(const QColor& c) { return c.name(QColor::HexRgb); } +// 字体族:原版 ql-font 的 token ↔ Qt QFont family 名。 +// token(whitelist 值):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)。 QJsonObject inlineAttrs(const QTextCharFormat& fmt) { QJsonObject a; @@ -28,6 +50,9 @@ QJsonObject inlineAttrs(const QTextCharFormat& fmt) { const double pt = fmt.fontPointSize(); if (pt > 0.0) // 以 px 表达(原版 ql-size 用 "NNpx";pt→px 约 *4/3 取整)。 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; } @@ -88,6 +113,8 @@ void applyInlineAttrs(const QJsonObject& attrs, QTextCharFormat& fmt) { const double px = size.chopped(2).toDouble(); 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 应用到块格式 / 列表(作用于换行前的当前块)。 diff --git a/src/app/panels/chart/AutoAnnotationDialog.cpp b/src/app/panels/chart/AutoAnnotationDialog.cpp index f38e300..4227ad0 100644 --- a/src/app/panels/chart/AutoAnnotationDialog.cpp +++ b/src/app/panels/chart/AutoAnnotationDialog.cpp @@ -1,8 +1,12 @@ #include "panels/chart/AutoAnnotationDialog.hpp" +#include +#include #include +#include #include +#include #include #include #include @@ -12,8 +16,10 @@ #include #include #include +#include #include #include +#include #include #include "FormKit.hpp" @@ -26,54 +32,88 @@ namespace { constexpr int kDefaultMinPoints = 4; // 原版 minPointCount 默认 4 // 面异常类型固定 remarkSourceType="3"(原版自动标注仅支持面/polygon)。 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& raw) { + std::vector 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(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 AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, - QString dsObjectId, QString projectId, QWidget* parent) + QString dsObjectId, QString projectId, + std::vector gridValues, QWidget* parent) : QDialog(parent), repo_(repo), dsObjectId_(std::move(dsObjectId)), - projectId_(std::move(projectId)) { + projectId_(std::move(projectId)), + gridValues_(std::move(gridValues)) { setWindowTitle(QStringLiteral("自动标注")); setModal(true); - resize(820, 520); + resize(geopro::app::scaledPx(1400), geopro::app::scaledPx(600)); auto* root = formkit::dialogRoot(this); auto* split = new QHBoxLayout(); - // ── 左:规则列表 ──────────────────────────────────────────────── + // ── 左:规则卡片列表(35%,对照原版左栏)──────────────────────────────── auto* leftCol = new QVBoxLayout(); - leftCol->addWidget(new QLabel(QStringLiteral("标注规则:"), this)); + leftCol->addWidget(new QLabel(QStringLiteral("异常判定规则"), this)); auto* ruleContainer = new QWidget(this); ruleHost_ = new QVBoxLayout(ruleContainer); ruleHost_->setContentsMargins(0, 0, 0, 0); leftCol->addWidget(ruleContainer); - auto* addBtn = new QPushButton(QStringLiteral("加规则"), this); + auto* addBtn = new QPushButton(QStringLiteral("添加规则"), this); connect(addBtn, &QPushButton::clicked, this, [this]() { addRule(); }); leftCol->addWidget(addBtn); leftCol->addStretch(); - split->addLayout(leftCol, 1); + auto* leftWrap = new QWidget(this); + leftWrap->setLayout(leftCol); + split->addWidget(leftWrap, 35); - // ── 右:预览表 ────────────────────────────────────────────────── + // ── 右:上统计 + 下预览表 ─────────────────────────────────────────────── auto* rightCol = new QVBoxLayout(); - rightCol->addWidget(new QLabel(QStringLiteral("预览:"), this)); - previewTable_ = new QTableWidget(0, 4, this); - previewTable_->setHorizontalHeaderLabels( - {QStringLiteral("异常名称"), QStringLiteral("异常类型"), QStringLiteral("阈值范围"), - QStringLiteral("阈值模式")}); + buildStatsBar(rightCol); + + detectedLabel_ = new QLabel(QStringLiteral("自动标注结果"), this); + rightCol->addWidget(detectedLabel_); + previewTable_ = new QTableWidget(0, 6, this); + previewTable_->setHorizontalHeaderLabels({QStringLiteral("序号"), QStringLiteral("异常名称"), + QStringLiteral("异常类型"), QStringLiteral("阈值范围"), + QStringLiteral("阈值模式"), QStringLiteral("操作")}); previewTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); previewTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); rightCol->addWidget(previewTable_, 1); - split->addLayout(rightCol, 1); + auto* rightWrap = new QWidget(this); + rightWrap->setLayout(rightCol); + split->addWidget(rightWrap, 65); root->addLayout(split, 1); - // ── 底部按钮 ──────────────────────────────────────────────────── + // ── 底部按钮:取消 / 执行自动标注 / 确认保存 ───────────────────────────── auto* btnLay = new QHBoxLayout(); btnLay->addStretch(); auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); auto* execBtn = new QPushButton(QStringLiteral("执行自动标注"), this); - saveBtn_ = new QPushButton(QStringLiteral("确定保存"), this); + saveBtn_ = new QPushButton(QStringLiteral("确认保存"), this); saveBtn_->setEnabled(false); // 必须先执行得到预览才能保存 btnLay->addWidget(cancelBtn); btnLay->addWidget(execBtn); @@ -88,6 +128,24 @@ AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandReposito 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() { if (!repo_) return; QPointer self(this); @@ -104,7 +162,7 @@ void AutoAnnotationDialog::loadExceptionTypes() { self->exceptionTypeOptions_.append( QJsonObject{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}}); } - // 回填已存在的规则行下拉。 + // 回填已存在规则卡片下拉。 for (auto& r : self->rules_) { r.type->clear(); for (const QJsonValue& ov : self->exceptionTypeOptions_) { @@ -119,39 +177,113 @@ void AutoAnnotationDialog::loadExceptionTypes() { void AutoAnnotationDialog::addRule() { auto* card = new QFrame(this); card->setFrameShape(QFrame::StyledPanel); - auto* lay = new QHBoxLayout(card); - lay->setContentsMargins(4, 4, 4, 4); + auto* cardLay = new QVBoxLayout(card); + cardLay->setContentsMargins(6, 6, 6, 6); - RuleRow row; - row.mode = new QComboBox(card); - row.mode->addItem(QStringLiteral("数值"), 1); - row.mode->addItem(QStringLiteral("百分位"), 2); - row.min = new QLineEdit(card); - row.min->setPlaceholderText(QStringLiteral("min")); - row.min->setFixedWidth(scaledPx(60)); - row.max = new QLineEdit(card); - row.max->setPlaceholderText(QStringLiteral("max")); - row.max->setFixedWidth(scaledPx(60)); - row.minPoints = new QSpinBox(card); - row.minPoints->setRange(1, 100000); - row.minPoints->setValue(kDefaultMinPoints); - row.type = new QComboBox(card); + RuleCard rc; + rc.frame = card; + + // 卡片头:折叠 + 「规则N」 + 删除。 + auto* header = new QHBoxLayout(); + auto* collapseBtn = new QToolButton(card); + collapseBtn->setText(QStringLiteral("▼")); + collapseBtn->setCheckable(true); + rc.title = new QLabel(card); + auto* delBtn = new QToolButton(card); + delBtn->setText(QStringLiteral("删除")); + header->addWidget(collapseBtn); + header->addWidget(rc.title, 1); + 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_) { const QJsonObject o = ov.toObject(); - row.type->addItem(o.value(QStringLiteral("name")).toString(), - o.value(QStringLiteral("id")).toString()); + rc.type->addItem(o.value(QStringLiteral("name")).toString(), + o.value(QStringLiteral("id")).toString()); } + form->addRow(formkit::editLabel(QStringLiteral("异常类型")), rc.type); - lay->addWidget(new QLabel(QStringLiteral("模式"), card)); - lay->addWidget(row.mode); - lay->addWidget(row.min); - lay->addWidget(row.max); - lay->addWidget(new QLabel(QStringLiteral("最小点数"), card)); - lay->addWidget(row.minPoints); - lay->addWidget(row.type, 1); + rc.body->setLayout(form); + cardLay->addWidget(rc.body); - 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); + 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(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 { @@ -159,7 +291,7 @@ QJsonArray AutoAnnotationDialog::buildRuleList() const { for (const auto& r : rules_) { QJsonObject rule{ {QStringLiteral("exceptionTypeId"), r.type->currentData().toString()}, - {QStringLiteral("thresholdMode"), r.mode->currentData().toInt()}, + {QStringLiteral("thresholdMode"), currentMode(r)}, {QStringLiteral("minPointCount"), r.minPoints->value()}, }; // min/max:空 → null(对照原版 Number(...) 或 null)。 @@ -176,11 +308,18 @@ QJsonArray AutoAnnotationDialog::buildRuleList() const { void AutoAnnotationDialog::onExecute() { if (!repo_) return; - const QJsonArray rules = buildRuleList(); - if (rules.isEmpty()) { - QMessageBox::warning(this, windowTitle(), QStringLiteral("请至少添加一条规则")); - return; + // 校验:每条规则 min/max 至少填一个、异常类型必选(对照原版 handleExecute)。 + for (const auto& r : rules_) { + if (r.min->text().trimmed().isEmpty() && r.max->text().trimmed().isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("阈值范围至少填写一项")); + return; + } + if (r.type->currentData().toString().isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型")); + return; + } } + const QJsonArray rules = buildRuleList(); QJsonObject body{ {QStringLiteral("dsObjectId"), dsObjectId_}, {QStringLiteral("projectId"), projectId_}, @@ -191,37 +330,81 @@ void AutoAnnotationDialog::onExecute() { if (!self) return; if (!ok) { QMessageBox::warning(self, self->windowTitle(), - msg.isEmpty() ? QStringLiteral("执行失败") : msg); + msg.isEmpty() ? QStringLiteral("自动标注执行失败") : msg); return; } // 预览异常:兼容 data 直接为数组(wireObject 包成 value) 或 data.list。 QJsonArray list = data.value(QStringLiteral("value")).toArray(); if (list.isEmpty()) list = data.value(QStringLiteral("list")).toArray(); self->previewExceptions_ = list; + self->detectedLabel_->setText( + QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(list.size())); self->previewTable_->setRowCount(list.size()); for (int i = 0; i < list.size(); ++i) { const QJsonObject o = list[i].toObject(); + self->previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1))); self->previewTable_->setItem( - i, 0, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString())); + i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString())); self->previewTable_->setItem( - i, 1, + i, 2, new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString())); self->previewTable_->setItem( - i, 2, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString())); + i, 3, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString())); self->previewTable_->setItem( - i, 3, + i, 4, new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString())); + // 操作列:逐条删除(对照原版预览表 删除)。 + auto* delBtn = new QPushButton(QStringLiteral("删除"), self->previewTable_); + QPointer 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()); if (list.isEmpty()) - QMessageBox::information(self, self->windowTitle(), - QStringLiteral("未生成异常(无满足规则的区域)")); + QMessageBox::information(self, self->windowTitle(), 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 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() { - if (!repo_ || previewExceptions_.isEmpty()) return; - // 组装 exceptionList:保留 execute 返回项的关键字段。 + if (!repo_ || previewExceptions_.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("暂无可保存的异常,请先执行自动标注")); + return; + } + // 组装 exceptionList:保留 execute 返回项的关键字段(对照原版 batchCreateException)。 QJsonArray exceptionList; for (const QJsonValue& v : previewExceptions_) { const QJsonObject o = v.toObject(); diff --git a/src/app/panels/chart/AutoAnnotationDialog.hpp b/src/app/panels/chart/AutoAnnotationDialog.hpp index 0c3e9c4..62554b9 100644 --- a/src/app/panels/chart/AutoAnnotationDialog.hpp +++ b/src/app/panels/chart/AutoAnnotationDialog.hpp @@ -4,12 +4,16 @@ #include #include +class QButtonGroup; class QComboBox; class QLineEdit; class QSpinBox; class QTableWidget; class QPushButton; +class QToolButton; +class QLabel; class QVBoxLayout; +class QWidget; namespace geopro::data { class IDatasetCommandRepository; @@ -17,40 +21,56 @@ class IDatasetCommandRepository; namespace geopro::app { -// 自动标注对话框(I13,复刻原版 AutoAnnotationDialog): -// 左:规则列表(阈值模式 数值/百分位、min/max、最小点数、异常类型)。 -// 执行 → executeExceptionMark(预演) → 预览表;确定保存 → batchCreateException → reloadGrid。 -// 异常类型仅支持面/polygon(原版同),故 listExceptionTypes 取 remarkSourceType="3"。 +// 自动标注对话框(I13,复刻原版 AutoAnnotationDialog,1400×600): +// 左:规则卡片列表(标题「规则N」+ 折叠 + 删除;阈值模式 radio-button 数值/百分位、min/max、 +// 最小点数、异常类型)+「添加规则」。 +// 右上:数据统计(最大/最小/均值/中位数,从网格标量算)+ 预览图(后置标注)。 +// 右下:预览表(序号/异常名称/异常类型/阈值范围/阈值模式/操作删除)。 +// 执行 → executeExceptionMark(预演) → 预览表;确认保存 → batchCreateException → reloadGrid。 +// 异常类型仅支持面/polygon(原版同),listExceptionTypes 取 remarkSourceType="3"。 class AutoAnnotationDialog : public QDialog { Q_OBJECT public: + // gridValues:网格标量(用于右上数据统计 max/min/mean/median);可空(统计显示 '-')。 AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, - QString projectId, QWidget* parent = nullptr); + QString projectId, std::vector gridValues, + QWidget* parent = nullptr); private: - struct RuleRow { - QComboBox* mode = nullptr; // 1 数值 / 2 百分位 + // 一条规则卡片的控件集合(卡片标题/折叠/删除 + 模式 radio + min/max + 最小点数 + 类型)。 + struct RuleCard { + QWidget* frame = nullptr; // 整张卡片(删除时移除) + QWidget* body = nullptr; // 折叠隐藏的主体 + QLabel* title = nullptr; // 「规则N」 + QButtonGroup* modeGroup = nullptr; // 1 数值 / 2 百分位(radio-button) QLineEdit* min = nullptr; QLineEdit* max = 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 removeRule(QWidget* frame); // 删除指定卡片(至少保留一条) + void renumberRules(); // 重排卡片标题「规则N」 + int currentMode(const RuleCard& c) const; // 取当前 radio 模式(1/2) QJsonArray buildRuleList() const; // 组装 exceptionMarkRuleList void onExecute(); // executeExceptionMark → 预览 void onSave(); // batchCreateException + void deletePreviewRow(int row); // 删除一条预览异常 geopro::data::IDatasetCommandRepository* repo_ = nullptr; QString dsObjectId_; QString projectId_; + std::vector gridValues_; QVBoxLayout* ruleHost_ = nullptr; - std::vector rules_; - QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则行复用 + std::vector rules_; + QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则卡片复用 QTableWidget* previewTable_ = nullptr; QJsonArray previewExceptions_; // execute 返回的预览异常(confirm 时批量存) + QLabel* detectedLabel_ = nullptr; // 「共识别到 N 个异常」 QPushButton* saveBtn_ = nullptr; }; diff --git a/src/app/panels/chart/ExceptionDetailDialog.cpp b/src/app/panels/chart/ExceptionDetailDialog.cpp index ee485d7..cf30b99 100644 --- a/src/app/panels/chart/ExceptionDetailDialog.cpp +++ b/src/app/panels/chart/ExceptionDetailDialog.cpp @@ -1,9 +1,10 @@ #include "panels/chart/ExceptionDetailDialog.hpp" -#include #include -#include +#include +#include #include +#include #include #include #include @@ -13,7 +14,9 @@ #include #include #include +#include #include +#include #include #include "FormKit.hpp" @@ -22,82 +25,58 @@ 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, const geopro::core::Anomaly& anomaly, QWidget* parent) : QDialog(parent), repo_(repo), anomaly_(anomaly) { - setWindowTitle(QStringLiteral("异常详情")); + setWindowTitle(QStringLiteral("标注详情")); setModal(true); - resize(420, 460); - - lineColor_ = QString::fromStdString(anomaly_.lineColor); - if (lineColor_.isEmpty()) lineColor_ = QStringLiteral("#000000"); + // 右侧抽屉观感:窄而高(对照原版 ADrawer width=380)。 + resize(geopro::app::scaledPx(380), geopro::app::scaledPx(560)); auto* root = formkit::dialogRoot(this); - auto* card = formkit::formCard(this); - auto* cardLay = formkit::cardBody(card); - auto* form = formkit::makeEditForm(); - + // ── 头部:名称(可编辑) + 异常类型(只读) ─────────────────────────────────── + auto* head = formkit::makeEditForm(); nameEdit_ = new QLineEdit(QString::fromStdString(anomaly_.name), this); formkit::capField(nameEdit_); - form->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_); - + head->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_); 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*)。 - colorBtn_ = new QPushButton(lineColor_, this); - colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(lineColor_)); - connect(colorBtn_, &QPushButton::clicked, this, [this]() { - const QColor c = QColorDialog::getColor(QColor(lineColor_), this, QStringLiteral("线色")); - if (c.isValid()) { - lineColor_ = c.name(); - colorBtn_->setText(lineColor_); - colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(lineColor_)); - } - }); - formkit::capField(colorBtn_); - form->addRow(formkit::editLabel(QStringLiteral("线色")), colorBtn_); - - widthSpin_ = new QDoubleSpinBox(this); - widthSpin_->setRange(0.1, 20.0); - widthSpin_->setSingleStep(0.5); - widthSpin_->setValue(anomaly_.lineWidth > 0 ? anomaly_.lineWidth : 1.0); - formkit::capField(widthSpin_); - form->addRow(formkit::editLabel(QStringLiteral("线宽")), widthSpin_); - - shapeCombo_ = new QComboBox(this); - shapeCombo_->addItem(QStringLiteral("实线"), QStringLiteral("solid")); - shapeCombo_->addItem(QStringLiteral("虚线"), QStringLiteral("dash")); - shapeCombo_->setCurrentIndex(anomaly_.dashed ? 1 : 0); - formkit::capField(shapeCombo_); - form->addRow(formkit::editLabel(QStringLiteral("线型")), shapeCombo_); + // ── 双 Tab:图例信息 / 坐标信息 ─────────────────────────────────────────── + auto* tabs = new QTabWidget(this); + tabs->addTab(buildLegendTab(), QStringLiteral("图例信息")); + tabs->addTab(buildCoordTab(), QStringLiteral("坐标信息")); + root->addWidget(tabs, 1); + // ── 底部:备注(可编辑) ─────────────────────────────────────────────────── + root->addWidget(new QLabel(QStringLiteral("备注:"), this)); remarkEdit_ = new QPlainTextEdit(QString::fromStdString(anomaly_.remark), this); - remarkEdit_->setFixedHeight(geopro::app::scaledPx(60)); - formkit::capField(remarkEdit_); - form->addRow(formkit::editLabel(QStringLiteral("备注")), remarkEdit_); - cardLay->addLayout(form); - root->addWidget(card); - - // 坐标(只读展示,对照原版坐标信息 tab)。 - root->addWidget(new QLabel(QStringLiteral("坐标:"), this)); - auto* coordTable = new QTableWidget(static_cast(anomaly_.localPts.size()), 2, this); - coordTable->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")}); - coordTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); - coordTable->setEditTriggers(QAbstractItemView::NoEditTriggers); - for (int r = 0; r < static_cast(anomaly_.localPts.size()); ++r) { - coordTable->setItem(r, 0, - new QTableWidgetItem(QString::number(anomaly_.localPts[r].x, 'f', 7))); - coordTable->setItem(r, 1, - new QTableWidgetItem(QString::number(anomaly_.localPts[r].y, 'f', 7))); - } - root->addWidget(coordTable, 1); + remarkEdit_->setFixedHeight(geopro::app::scaledPx(70)); + root->addWidget(remarkEdit_); auto* btnLay = new QHBoxLayout(); btnLay->addStretch(); auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); - okBtn_ = new QPushButton(QStringLiteral("确定"), this); + okBtn_ = new QPushButton(QStringLiteral("更新"), this); // 对照原版 ok-text="更新" okBtn_->setDefault(true); btnLay->addWidget(cancelBtn); btnLay->addWidget(okBtn_); @@ -107,6 +86,132 @@ ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandReposi connect(okBtn_, &QPushButton::clicked, this, &ExceptionDetailDialog::onConfirm); } +QWidget* ExceptionDetailDialog::buildLegendTab() { + auto* tab = new QWidget(this); + auto* form = formkit::makeEditForm(); + + // 类型 + 顶点数/端点数(对照原版:多边形→顶点数,多段线→端点数)。 + const int mt = static_cast(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(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(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() { if (!repo_ || anomaly_.id.empty()) { reject(); return; } const QString name = nameEdit_->text().trimmed(); @@ -114,9 +219,8 @@ void ExceptionDetailDialog::onConfirm() { QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称")); return; } - // 原版详情抽屉「改名称/备注」走 PUT /business/exception 的局部更新, - // 仅发 {id, exceptionName, remark}(线样式是另一条独立 PUT,且抽屉里样式控件 disabled)。 - // 对齐原版 contourPage.vue onOk:不合并/不重发 legend。 + // 对照原版 drawerExceptionInfo onOk:图例样式控件 disabled、不发 legend, + // 仅 PUT {id, exceptionName, remark}。 QJsonObject body{ {QStringLiteral("id"), QString::fromStdString(anomaly_.id)}, {QStringLiteral("exceptionName"), name}, diff --git a/src/app/panels/chart/ExceptionDetailDialog.hpp b/src/app/panels/chart/ExceptionDetailDialog.hpp index bcb6508..8df725f 100644 --- a/src/app/panels/chart/ExceptionDetailDialog.hpp +++ b/src/app/panels/chart/ExceptionDetailDialog.hpp @@ -6,9 +6,10 @@ class QLineEdit; class QPlainTextEdit; -class QDoubleSpinBox; class QComboBox; +class QTableWidget; class QPushButton; +class QLabel; namespace geopro::data { class IDatasetCommandRepository; @@ -16,10 +17,13 @@ class IDatasetCommandRepository; namespace geopro::app { -// 异常详情/编辑对话框(I11,复刻原版 drawerExceptionInfo 的可编辑部分): -// 名称(可编辑) / 异常类型(只读) / 图例样式(线色/线宽/线型) / 备注(可编辑) / 坐标(只读展示)。 -// 确认 → updateException(PUT body {id, exceptionName, remark, legend:{polylineColor, -// polylineWidth, polylineShape}}),成功 accept(),调用方随后 reloadGrid。 +// 异常详情/编辑对话框(I11,复刻原版 drawerExceptionInfo 右侧抽屉形态): +// 双 Tab「图例信息 / 坐标信息」。 +// - 头部:名称(可编辑) + 异常类型(只读)。 +// - 图例信息:类型 + 顶点/端点数 + 线色/线宽/线型/不透明度(全部「只读展示」,对照原版 disabled)。 +// - 坐标信息:坐标系切换(图形/经纬度/投影) + 顶点数 + 坐标表(7位小数) + 导出 txt。 +// - 底部:备注(可编辑)。 +// 确认 → updateException(PUT body 仅 {id, exceptionName, remark},与原版一致;线样式只读不发)。 class ExceptionDetailDialog : public QDialog { Q_OBJECT public: @@ -28,16 +32,19 @@ public: private: void onConfirm(); + void onCoordSystemChanged(); // 切换坐标系 → 重填坐标表(图形有数据,经纬度/投影暂无) + void exportCoords(); // 导出当前坐标系坐标为 txt(7位小数) + QWidget* buildLegendTab(); // 图例信息 Tab(只读样式) + QWidget* buildCoordTab(); // 坐标信息 Tab geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::core::Anomaly anomaly_; // 拷贝(不可变范式:编辑后组装新 body,不改原对象) QLineEdit* nameEdit_ = nullptr; QPlainTextEdit* remarkEdit_ = nullptr; - QPushButton* colorBtn_ = nullptr; // 线色选择(弹 QColorDialog) - QString lineColor_; // 当前线色 hex - QDoubleSpinBox* widthSpin_ = nullptr; - QComboBox* shapeCombo_ = nullptr; // solid / dash + QComboBox* coordSysCombo_ = nullptr; // jb 图形 / lonlat 经纬度 / projection 投影 + QTableWidget* coordTable_ = nullptr; + QLabel* vertexCountLabel_ = nullptr; QPushButton* okBtn_ = nullptr; }; diff --git a/src/app/panels/chart/ExceptionDialog.cpp b/src/app/panels/chart/ExceptionDialog.cpp index 3e9f134..5be5fda 100644 --- a/src/app/panels/chart/ExceptionDialog.cpp +++ b/src/app/panels/chart/ExceptionDialog.cpp @@ -56,9 +56,24 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, formkit::capField(markTypeCombo_); form->addRow(formkit::editLabel(QStringLiteral("标注类型")), markTypeCombo_); + // 异常类型行:下拉 + 「新增异常类型」按钮(对照原版 exceptionDialog 同行布局)。 exceptionTypeCombo_ = new QComboBox(this); 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_->setPlaceholderText(QStringLiteral("数据名称+异常类型代号+序号")); @@ -143,10 +158,20 @@ QJsonArray ExceptionDialog::manualCoordinates() const { } void ExceptionDialog::onTypeChanged() { - // 主路径为图上绘形 → 坐标表默认留空(不自动补行),仅刷新异常类型列表。 + // 对照原版 handleAnnotationTypeChange:标注类型变 → 清空名称(待重选类型后回填)+ + // 重拉对应几何形态的异常类型列表 + 刷新「新增类型」按钮可用性。 + nameEdit_->clear(); + updateAddTypeEnabled(); loadExceptionTypes(); } +void ExceptionDialog::updateAddTypeEnabled() { + if (!addTypeBtn_) return; + // 原版:文字类型(4) 或 未选标注类型时禁用「新增异常类型」。 + const QString mt = markTypeValue(); + addTypeBtn_->setEnabled(!mt.isEmpty() && mt != QStringLiteral("4")); +} + void ExceptionDialog::loadExceptionTypes() { if (!repo_) return; QPointer self(this); @@ -172,12 +197,9 @@ void ExceptionDialog::suggestName() { const QString typeId = exceptionTypeCombo_->currentData().toString(); if (typeId.isEmpty()) return; QPointer 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->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); }); } diff --git a/src/app/panels/chart/ExceptionDialog.hpp b/src/app/panels/chart/ExceptionDialog.hpp index b549394..4407d67 100644 --- a/src/app/panels/chart/ExceptionDialog.hpp +++ b/src/app/panels/chart/ExceptionDialog.hpp @@ -39,10 +39,11 @@ public: QJsonArray manualCoordinates() const; private: - void onTypeChanged(); // 标注类型变 → 重拉异常类型列表 + 调整坐标表最少行 + void onTypeChanged(); // 标注类型变 → 清名称 + 重拉异常类型列表 + 刷新「新增类型」可用性 void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType) - void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称建议 + void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称回填 void onConfirm(); // 校验 → 有手填坐标则直接 newException,否则 accept() 交给绘形 + void updateAddTypeEnabled(); // 「新增异常类型」可用性:文字类型/未选类型时禁用(对照原版) geopro::data::IDatasetCommandRepository* repo_ = nullptr; QString projectId_; @@ -50,6 +51,7 @@ private: QComboBox* markTypeCombo_ = nullptr; // userData = "1".."4" QComboBox* exceptionTypeCombo_ = nullptr; // userData = 异常类型 id + QPushButton* addTypeBtn_ = nullptr; // 新增异常类型(对照原版,文字/未选类型禁用) QLineEdit* nameEdit_ = nullptr; QPlainTextEdit* remarkEdit_ = nullptr; QTableWidget* coordTable_ = nullptr; diff --git a/src/app/panels/chart/ExceptionTextDialog.cpp b/src/app/panels/chart/ExceptionTextDialog.cpp new file mode 100644 index 0000000..07033ff --- /dev/null +++ b/src/app/panels/chart/ExceptionTextDialog.cpp @@ -0,0 +1,113 @@ +#include "panels/chart/ExceptionTextDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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_); + + // 不透明度(0–100%,默认 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 diff --git a/src/app/panels/chart/ExceptionTextDialog.hpp b/src/app/panels/chart/ExceptionTextDialog.hpp new file mode 100644 index 0000000..9f75ce4 --- /dev/null +++ b/src/app/panels/chart/ExceptionTextDialog.hpp @@ -0,0 +1,44 @@ +#pragma once +#include +#include + +class QComboBox; +class QSpinBox; +class QSlider; +class QPlainTextEdit; +class QPushButton; +class QLabel; + +namespace geopro::app { + +// 文字标注编辑对话框(I9,复刻原版 exceptionText.vue「文本编辑」): +// 字体(1宋体/2微软雅黑/3黑体/4楷体) / 大小(px) / 颜色 / 不透明度(0–100%) / 内容(必填)。 +// 确定 → accept(),调用方读取各字段组装 newException 的 customLegend。 +// 时序对照原版:文字类型绘制落点后弹此对话框,提交带 customLegend +// {text, content, color, size, font(CSS族), opacity(0–1)}。 +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; // 0–100 + 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 diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index 3621335..c14a5d8 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -40,6 +41,7 @@ #include "panels/chart/ContourPlotItem.hpp" #include "panels/chart/ExceptionDetailDialog.hpp" #include "panels/chart/ExceptionDialog.hpp" +#include "panels/chart/ExceptionTextDialog.hpp" #include "panels/chart/FilterDialog.hpp" #include "panels/chart/GridWizardDialog.hpp" #include "panels/chart/LivePanner.hpp" @@ -435,6 +437,28 @@ void GridDataChartView::openExceptionDialog() { QJsonArray coords; for (const QPointF& p : pts) 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 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}, // 0–1 + }; + self->submitDrawnException(markType, typeId, name, remark, coords, customLegend); + return; + } self->submitDrawnException(markType, typeId, name, remark, coords); }); drawTool_->setOnCancel([] {}); // 取消绘形:无操作(不提交) @@ -443,7 +467,8 @@ void GridDataChartView::openExceptionDialog() { void GridDataChartView::submitDrawnException(const QString& markType, const QString& typeId, const QString& name, const QString& remark, - const QJsonArray& coords) { + const QJsonArray& coords, + const QJsonObject& customLegend) { const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); if (!cmdRepo_ || dsId.isEmpty()) return; @@ -456,6 +481,8 @@ void GridDataChartView::submitDrawnException(const QString& markType, const QStr {QStringLiteral("projectId"), projectId}, {QStringLiteral("location"), QJsonObject{{QStringLiteral("coordinate"), coords}}}, }; + // 文字类型带 customLegend(对照原版:仅文字非空,其它形态不带此字段)。 + if (!customLegend.isEmpty()) body.insert(QStringLiteral("customLegend"), customLegend); QPointer self(this); cmdRepo_->newException(body, [self](bool ok, QString msg) { if (!self) return; @@ -472,7 +499,8 @@ void GridDataChartView::openAutoAnnotation() { const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } - AutoAnnotationDialog dlg(cmdRepo_, dsId, projectId, this); + // 透传网格标量用于右上数据统计(max/min/mean/median)。 + AutoAnnotationDialog dlg(cmdRepo_, dsId, projectId, grid_.values(), this); if (dlg.exec() == QDialog::Accepted) reloadGrid(); } diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp index 98b5ad3..b59cec4 100644 --- a/src/app/panels/chart/GridDataChartView.hpp +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -3,6 +3,7 @@ #include #include +#include // submitDrawnException 默认参数 const QJsonObject& = {} 需完整类型 #include #include @@ -81,10 +82,12 @@ private: void applySimplify(); // I8:把当前滑块容差透传给 ContourPlotItem 并重绘 void showNotImplemented(QWidget* anchor); // 占位提示(无仓储/无 dsId) - void openExceptionDialog(); // I9 异常创建(弹窗选类型 → 图上绘形 → 提交) + void openExceptionDialog(); // I9 异常创建(弹窗选类型 → 图上绘形 →[文字另弹文本编辑]→ 提交) // I9 图上绘形完成:组装 body 提交 newException(成功 reloadGrid)。 + // customLegend 仅文字类型非空(对照原版:文字 customLegend,其它形态留空 {})。 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 deleteAnomaly(int index); // I10 异常删除 void showAnomalyDetail(int index); // I11 异常详情/编辑 diff --git a/src/core/model/Anomaly.hpp b/src/core/model/Anomaly.hpp index 0c6ad32..da9f2b8 100644 --- a/src/core/model/Anomaly.hpp +++ b/src/core/model/Anomaly.hpp @@ -3,7 +3,7 @@ #include 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 Vec3 { double x, y, z; }; @@ -27,6 +27,13 @@ struct Anomaly { std::string lineColor = "#000000"; // legend.polylineColor double lineWidth = 1.0; // legend.polylineWidth 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 字体族 int(1宋体/2微软雅黑/3黑体/4楷体) + double textOpacity = 1.0; // customLegend.opacity(0–1) }; } // namespace geopro::core diff --git a/src/data/api/ApiDatasetCommandRepository.cpp b/src/data/api/ApiDatasetCommandRepository.cpp index d0cbde7..2f1988e 100644 --- a/src/data/api/ApiDatasetCommandRepository.cpp +++ b/src/data/api/ApiDatasetCommandRepository.cpp @@ -50,6 +50,24 @@ void wireArray(net::IApiCall* call, std::function"},故取 data.value。 +void wireString(net::IApiCall* call, std::function cb) { + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + cb(true, resp.data.value(QStringLiteral("value")).toString(), resp.msg); + }); +} + // 返回对象:(bool ok, QJsonObject data, QString msg)。 void wireObject(net::IApiCall* call, std::function cb) { if (call == nullptr) { @@ -368,10 +386,11 @@ void ApiDatasetCommandRepository::listExceptionTypes( void ApiDatasetCommandRepository::getExceptionName( const QString& exceptionTypeId, const QString& remarkSourceId, - std::function cb) { + std::function cb) { QJsonObject body{{QStringLiteral("exceptionTypeId"), exceptionTypeId}, {QStringLiteral("remarkSourceId"), remarkSourceId}}; - wireObject(api_.postJsonAsync(QStringLiteral("/business/exception/getExceptionName"), body), + // 原版 res.data 直接是名称字符串,回传纯字符串。 + wireString(api_.postJsonAsync(QStringLiteral("/business/exception/getExceptionName"), body), std::move(cb)); } diff --git a/src/data/api/ApiDatasetCommandRepository.hpp b/src/data/api/ApiDatasetCommandRepository.hpp index 94320a1..4697033 100644 --- a/src/data/api/ApiDatasetCommandRepository.hpp +++ b/src/data/api/ApiDatasetCommandRepository.hpp @@ -83,7 +83,7 @@ public: std::function cb) override; void getExceptionName( const QString& exceptionTypeId, const QString& remarkSourceId, - std::function cb) override; + std::function cb) override; void newException(const QJsonObject& body, std::function cb) override; void deleteException(const QString& id, diff --git a/src/data/dto/DatasetChartDto.cpp b/src/data/dto/DatasetChartDto.cpp index 7b26d7a..098189f 100644 --- a/src/data/dto/DatasetChartDto.cpp +++ b/src/data/dto/DatasetChartDto.cpp @@ -71,13 +71,23 @@ std::vector parseDatasetAnomalies(const QJsonArray& arr) { a.exceptionTypeId = o.value("exceptionTypeId").toString().toStdString(); a.remark = o.value("remark").toString().toStdString(); a.createTime = o.value("createTime").toString().toStdString(); - const int mt = o.value("exceptionMarkType").toInt(2); // 1=点 2=线 3=面 - a.markType = (mt >= 1 && mt <= 3) ? static_cast(mt) + const int mt = o.value("exceptionMarkType").toInt(2); // 1=点 2=线 3=面 4=文字 + a.markType = (mt >= 1 && mt <= 4) ? static_cast(mt) : AnomalyMarkType::Polyline; // 越界值兜底为线 const QJsonObject lg = o.value("legend").toObject(); a.lineColor = lg.value("polylineColor").toString("#000000").toStdString(); a.lineWidth = lg.value("polylineWidth").toDouble(1.0); 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()) { const QJsonObject p = c.toObject(); a.localPts.push_back(Vec2{p.value("x").toDouble(), p.value("y").toDouble()}); diff --git a/src/data/repo/IDatasetCommandRepository.hpp b/src/data/repo/IDatasetCommandRepository.hpp index 6b40aa1..a30f85f 100644 --- a/src/data/repo/IDatasetCommandRepository.hpp +++ b/src/data/repo/IDatasetCommandRepository.hpp @@ -169,10 +169,10 @@ public: // 获取异常名称:POST /business/exception/getExceptionName // body {exceptionTypeId, remarkSourceId}(对应原版 queryExceptionNameInProfileInversion)。 - // 回调 data = 响应 data 对象(含建议名称)。 + // 原版 res.data 是「纯字符串」(建议名称本身),故回调直接回传 name 字符串。 virtual void getExceptionName( const QString& exceptionTypeId, const QString& remarkSourceId, - std::function cb) = 0; + std::function cb) = 0; // 新增异常:POST /business/exception // body 含 {exceptionName, exceptionTypeId, location, projectId, remarkSourceId, remarkSourceType, remark} diff --git a/tests/app/test_quill_delta.cpp b/tests/app/test_quill_delta.cpp index 5c92592..c7c5418 100644 --- a/tests/app/test_quill_delta.cpp +++ b/tests/app/test_quill_delta.cpp @@ -146,6 +146,55 @@ TEST(QuillDelta, OrderedListBlockRoundTrip) { 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 降级(保留文本,不崩) ────────────────────── TEST(QuillDelta, UnknownAttributesDegradeGracefully) { const auto ops = opsFromJson(R"([