fix(detail): inversion 异常/自动标注/描述交互返工对齐原版 + 修 getExceptionName

- 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 未碰。
This commit is contained in:
gaozheng 2026-06-23 12:26:16 +08:00
parent 5dbbb2576c
commit 6bc7c23a8c
20 changed files with 863 additions and 190 deletions

View File

@ -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

View File

@ -6,7 +6,9 @@
#include <QToolButton>
#include <QVBoxLayout>
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<geopro::core::Anomaly>& 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);

View File

@ -6,7 +6,6 @@
#include <QPushButton>
#include <QTextCharFormat>
#include <QTextEdit>
#include <QTextListFormat>
#include <QToolBar>
#include <QToolButton>
#include <QVBoxLayout>
@ -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);
// 字号下拉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);
});
};
addListBtn(QStringLiteral("1."), QTextListFormat::ListDecimal);
addListBtn(QStringLiteral(""), QTextListFormat::ListDisc);
}
void DescriptionPanel::setDelta(const QJsonArray& ops) {

View File

@ -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 名。
// 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
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 应用到块格式 / 列表(作用于换行前的当前块)。

View File

@ -1,8 +1,12 @@
#include "panels/chart/AutoAnnotationDialog.hpp"
#include <algorithm>
#include <cmath>
#include <utility>
#include <QButtonGroup>
#include <QComboBox>
#include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout>
#include <QHeaderView>
@ -12,8 +16,10 @@
#include <QMessageBox>
#include <QPointer>
#include <QPushButton>
#include <QRadioButton>
#include <QSpinBox>
#include <QTableWidget>
#include <QToolButton>
#include <QVBoxLayout>
#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<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
AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo,
QString dsObjectId, QString projectId, QWidget* parent)
QString dsObjectId, QString projectId,
std::vector<double> 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<AutoAnnotationDialog> 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(),
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<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 {
@ -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("请至少添加一条规则"));
// 校验:每条规则 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<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());
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<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() {
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();

View File

@ -4,12 +4,16 @@
#include <QString>
#include <vector>
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复刻原版 AutoAnnotationDialog1400×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<double> 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
};
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<double> gridValues_;
QVBoxLayout* ruleHost_ = nullptr;
std::vector<RuleRow> rules_;
QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则复用
std::vector<RuleCard> rules_;
QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则卡片复用
QTableWidget* previewTable_ = nullptr;
QJsonArray previewExceptions_; // execute 返回的预览异常confirm 时批量存)
QLabel* detectedLabel_ = nullptr; // 「共识别到 N 个异常」
QPushButton* saveBtn_ = nullptr;
};

View File

@ -1,9 +1,10 @@
#include "panels/chart/ExceptionDetailDialog.hpp"
#include <QColorDialog>
#include <QComboBox>
#include <QDoubleSpinBox>
#include <QFile>
#include <QFileDialog>
#include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QJsonObject>
@ -13,7 +14,9 @@
#include <QPlainTextEdit>
#include <QPointer>
#include <QPushButton>
#include <QTabWidget>
#include <QTableWidget>
#include <QTextStream>
#include <QVBoxLayout>
#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<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);
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<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() {
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},

View File

@ -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(); // 导出当前坐标系坐标为 txt7位小数
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;
};

View File

@ -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<ExceptionDialog> self(this);
@ -172,12 +197,9 @@ void ExceptionDialog::suggestName() {
const QString typeId = exceptionTypeCombo_->currentData().toString();
if (typeId.isEmpty()) return;
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->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);
});
}

View File

@ -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;

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 <QCursor>
#include <QHash>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QJsonObject>
@ -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<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);
});
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<GridDataChartView> 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();
}

View File

@ -3,6 +3,7 @@
#include <utility>
#include <vector>
#include <QJsonObject> // submitDrawnException 默认参数 const QJsonObject& = {} 需完整类型
#include <QString>
#include <QWidget>
@ -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 异常详情/编辑

View File

@ -3,7 +3,7 @@
#include <vector>
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 字体族 int1宋体/2微软雅黑/3黑体/4楷体
double textOpacity = 1.0; // customLegend.opacity01
};
} // 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)。
void wireObject(net::IApiCall* call, std::function<void(bool, QJsonObject, QString)> cb) {
if (call == nullptr) {
@ -368,10 +386,11 @@ void ApiDatasetCommandRepository::listExceptionTypes(
void ApiDatasetCommandRepository::getExceptionName(
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},
{QStringLiteral("remarkSourceId"), remarkSourceId}};
wireObject(api_.postJsonAsync(QStringLiteral("/business/exception/getExceptionName"), body),
// 原版 res.data 直接是名称字符串,回传纯字符串。
wireString(api_.postJsonAsync(QStringLiteral("/business/exception/getExceptionName"), body),
std::move(cb));
}

View File

@ -83,7 +83,7 @@ public:
std::function<void(bool ok, QJsonArray list, QString msg)> cb) override;
void getExceptionName(
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,
std::function<void(bool ok, QString msg)> cb) override;
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.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<AnomalyMarkType>(mt)
const int mt = o.value("exceptionMarkType").toInt(2); // 1=点 2=线 3=面 4=文字
a.markType = (mt >= 1 && mt <= 4) ? static_cast<AnomalyMarkType>(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()});

View File

@ -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<void(bool ok, QJsonObject data, QString msg)> cb) = 0;
std::function<void(bool ok, QString name, QString msg)> cb) = 0;
// 新增异常POST /business/exception
// body 含 {exceptionName, exceptionTypeId, location, projectId, remarkSourceId, remarkSourceType, remark}

View File

@ -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"([