From 959f030c98f879c4b145d875417b2fff41c7ec2e Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 23 Jun 2026 07:28:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E8=A1=A8=E5=8D=95=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=8D=95=E4=B8=80=E7=9C=9F=E7=9B=B8=E5=B1=82(formkit)?= =?UTF-8?q?+=E5=8F=AA=E8=AF=BB=E6=B8=B2=E6=9F=93=E5=99=A8(KeyValueView),?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=BC=96=E8=BE=91/=E5=8F=AA=E8=AF=BB/?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E8=A1=A8=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把分散在各文件手搭的表单收敛到唯一实现,杜绝"同类控件各处不一"的漂移: - 新增 FormKit(DetailForm/buildDetailDialog + makeEditForm/editLabel/capField/addSection) - 新增 KeyValueView 为唯一只读键值渲染器(§6.4);DynamicFormView 改为内嵌它 - 三维体/切片/异常详情对话框由裸 QFormLayout 迁到 DetailForm+KeyValueView(随内容自适应) - 生成三维体对话框 + DynamicFormEditor 改用 formkit 可编辑套件(标签列/行距/分组逐像素一致) - 分组标题统一为 formkit::addSection(编辑态与只读态共用同一段代码) 控件一致性: - 下拉框/日期框扁平 chevron 箭头(qrc 内嵌 SVG),去 Fusion 原生斜角;日期框补齐输入框同款 box - 无候选项的"选择"字段退化为 QLineEdit,不再用几何异类的可编辑下拉框 - QComboBox/QDateEdit/QTimeEdit min-height/padding 与 QLineEdit 对齐 → 同高 规范:§7.0.2 标签列宽由区间改精确常量(可编辑100/只读72);新增 §7.0.10 实现纪律 (唯一实现、禁手搭 QFormLayout、精确常量、控件构造一致性、新表单并排验收) --- docs/Geopro3.0_视觉设计规范.md | 14 +- src/app/AnomalyPropertiesDialog.cpp | 60 +++--- src/app/CMakeLists.txt | 3 + src/app/FormKit.cpp | 110 +++++++++++ src/app/FormKit.hpp | 49 +++++ src/app/SlicePropertiesDialog.cpp | 44 ++--- src/app/Theme.cpp | 41 +++- src/app/Theme.hpp | 9 +- src/app/VolumeParamsDialog.cpp | 29 ++- src/app/VolumePropertiesDialog.cpp | 69 +++---- src/app/panels/DynamicFormEditor.cpp | 51 ++--- src/app/panels/DynamicFormView.cpp | 227 ++++------------------- src/app/panels/DynamicFormView.hpp | 18 +- src/app/panels/KeyValueView.cpp | 119 ++++++++++++ src/app/panels/KeyValueView.hpp | 25 +++ src/app/resources/icons.qrc | 8 + src/app/resources/icons/chevron-down.svg | 4 + 17 files changed, 531 insertions(+), 349 deletions(-) create mode 100644 src/app/FormKit.cpp create mode 100644 src/app/FormKit.hpp create mode 100644 src/app/panels/KeyValueView.cpp create mode 100644 src/app/panels/KeyValueView.hpp create mode 100644 src/app/resources/icons.qrc create mode 100644 src/app/resources/icons/chevron-down.svg diff --git a/docs/Geopro3.0_视觉设计规范.md b/docs/Geopro3.0_视觉设计规范.md index 52cf520..6693612 100644 --- a/docs/Geopro3.0_视觉设计规范.md +++ b/docs/Geopro3.0_视觉设计规范.md @@ -414,7 +414,7 @@ | 元素 | 规范 | |---|---| | 标签位置 | **左侧标签列**(默认,密集专业风),标签文字**右对齐**贴近字段;字段名过长或窄单列对话框可改顶部标签 | -| 标签列宽 | 默认 `96–120px`,同一表单内**等宽对齐**(纯只读键值表用 §6.4 的 `72px`) | +| 标签列宽 | 可编辑表单**固定 `100px`**(`space::kFormLabelCol`,右对齐,跨表单等宽);纯只读键值表**固定 `72px`**(`space::kDetailKeyCol`,§6.4)。**不取区间**——区间会让不同实现各选一值而漂移 | | 标签 ↔ 字段间距 | `space/md`(12) | | 字段控件高 | `28px`(§7.1);行与行垂直间距 `space/sm`(8) | | 字段宽度 | 宽面板/对话框中**不要拉满**,单字段最大宽约 `360px`(多行/长文本可更宽);窄属性面板中填充可用宽度 | @@ -479,6 +479,18 @@ - **不以颜色为唯一信息**:必填除 `*` 外,校验失败有文字说明;错误态有文字。 - 可点控件最小命中区 ≥ `24×24px`(§12)。 +#### 7.0.10 实现纪律(单一实现 · 禁止手搭) + +> **本节是 §7.0 能落地的关键。** 文档约束管不住代码——同类表单分散在各文件里手搭 `QFormLayout`/`QLabel`、各填各的边距与列宽,必然漂移(曾出现:三维体/切片/异常详情用裸 `QFormLayout`,与属性面板天差地别;同为下拉框,设置页无箭头、对象属性偏矮带箭头)。一致性**只能由代码复用强制**。 + +- **唯一实现(必须经此产出,不得另起炉灶)**: + - 只读键值详情 → `DynamicFormView`(§6.4 渲染器)。对话框用 `formkit::DetailForm`(链式 `group()/row()`)构模型,`formkit::buildDetailDialog()` 铺骨架。 + - 可编辑表单 → `formkit::makeEditForm()` + `formkit::editLabel()` + `formkit::capField()` + `formkit::addSection()`。`DynamicFormEditor` 与各参数对话框(如「生成三维体」)**都走同一组**,确保标签列宽/行距/分组/字段上限逐像素一致。 +- **禁止**:业务表单/对话框直接 `new QFormLayout` + `new QLabel("名",值)` 手搭键值;禁止在表单里逐处写死边距/列宽/字号(用 `space::*`/`type::*` 令牌)。 +- **精确常量(单一来源 `Theme.hpp`,禁止区间/魔数)**:可编辑标签列 `space::kFormLabelCol=100`;只读键列 `space::kDetailKeyCol=72`;字段最大宽 `space::kFormFieldMax=360`;行距 `space::kMd`;分组上间距 `space::kLg`。 +- **控件构造一致性**:下拉框统一用不可编辑 `QComboBox`(无候选项的「选择」字段退化为 `QLineEdit` 自由文本,**不得**用「可编辑下拉框」——其几何/高度与不可编辑款不一致)。全局 QSS **不覆写** `QComboBox::drop-down`/`::down-arrow`,保留 Fusion 原生箭头随调色板自适应(覆写却不提供箭头图会致箭头消失)。`QComboBox` 的 `min-height`/`padding` 与 `QLineEdit` 完全对齐 → 同高。 +- **新增表单的验收**:截图与既有「对象属性 / 数据详情」并排,标签列、行高、分组标题、下拉框外观应**无法区分**;做不到即说明绕开了上述唯一实现。 + --- ### 7.1 输入框(Text Input) diff --git a/src/app/AnomalyPropertiesDialog.cpp b/src/app/AnomalyPropertiesDialog.cpp index 9d18a87..54622f5 100644 --- a/src/app/AnomalyPropertiesDialog.cpp +++ b/src/app/AnomalyPropertiesDialog.cpp @@ -1,10 +1,11 @@ #include "AnomalyPropertiesDialog.hpp" -#include -#include -#include #include #include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" namespace geopro::app { @@ -26,25 +27,27 @@ QString orDash(const std::string& s) { AnomalyPropertiesDialog::AnomalyPropertiesDialog(const geopro::core::Anomaly& a, QWidget* parent) : QDialog(parent) { setWindowTitle(QStringLiteral("异常属性")); - setModal(true); - auto* root = new QVBoxLayout(this); + formkit::DetailForm form; + form.group(QStringLiteral("异常")) + .row(QStringLiteral("名称"), orDash(a.name)) + .row(QStringLiteral("类型"), orDash(a.typeName)) + .row(QStringLiteral("标记类型"), markTypeLabel(a.markType)) + .row(QStringLiteral("归属三维体"), orDash(a.volumeDsId)) + .row(QStringLiteral("异常体"), a.consortiumId.empty() + ? QStringLiteral("(未分组)") + : QString::fromStdString(a.consortiumId)); - auto* form = new QFormLayout(); - form->addRow(QStringLiteral("名称"), new QLabel(orDash(a.name))); - form->addRow(QStringLiteral("类型"), new QLabel(orDash(a.typeName))); - form->addRow(QStringLiteral("标记类型"), new QLabel(markTypeLabel(a.markType))); - form->addRow(QStringLiteral("归属三维体"), new QLabel(orDash(a.volumeDsId))); - form->addRow(QStringLiteral("异常体"), - new QLabel(a.consortiumId.empty() ? QStringLiteral("(未分组)") - : QString::fromStdString(a.consortiumId))); - root->addLayout(form); - - // 顶点世界坐标(只读列表,x/y/z 每行一个点)。 - root->addWidget(new QLabel(QStringLiteral("顶点坐标(%1 个)").arg(a.worldPts.size()))); - auto* pts = new QPlainTextEdit(); + // 顶点世界坐标(只读列表,x/y/z 每行一个点)—— 作为附加只读区,复用统一分组标题样式。 + auto* vertexBox = new QWidget(this); + auto* vlay = new QVBoxLayout(vertexBox); + vlay->setContentsMargins(0, 0, 0, 0); + vlay->setSpacing(geopro::app::space::kSm); + formkit::addSection(vlay, QStringLiteral("顶点坐标(%1 个)").arg(a.worldPts.size()), vertexBox, + false); + auto* pts = new QPlainTextEdit(vertexBox); pts->setReadOnly(true); - pts->setFixedHeight(120); + pts->setFixedHeight(geopro::app::scaledPx(120)); QString text; for (std::size_t i = 0; i < a.worldPts.size(); ++i) { const auto& p = a.worldPts[i]; @@ -55,20 +58,21 @@ AnomalyPropertiesDialog::AnomalyPropertiesDialog(const geopro::core::Anomaly& a, .arg(p.z, 0, 'f', 2); } pts->setPlainText(text); - root->addWidget(pts); + vlay->addWidget(pts); // 备注(只读)。 - root->addWidget(new QLabel(QStringLiteral("备注"))); - auto* remark = new QPlainTextEdit(); + auto* remarkBox = new QWidget(this); + auto* rlay = new QVBoxLayout(remarkBox); + rlay->setContentsMargins(0, 0, 0, 0); + rlay->setSpacing(geopro::app::space::kSm); + formkit::addSection(rlay, QStringLiteral("备注"), remarkBox, false); + auto* remark = new QPlainTextEdit(remarkBox); remark->setReadOnly(true); - remark->setFixedHeight(60); + remark->setFixedHeight(geopro::app::scaledPx(60)); remark->setPlainText(QString::fromStdString(a.remark)); - root->addWidget(remark); + rlay->addWidget(remark); - auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close); - connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); - connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); - root->addWidget(buttons); + formkit::buildDetailDialog(this, form.build(), {vertexBox, remarkBox}); } } // namespace geopro::app diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 6faa7eb..bb76077 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -21,6 +21,7 @@ find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets WebEngineQuick) add_executable(geopro_desktop WIN32 main.cpp Theme.cpp + FormKit.cpp TopBar.cpp ToastOverlay.cpp Glyphs.cpp @@ -30,6 +31,7 @@ add_executable(geopro_desktop WIN32 panels/AnomalyListPanel.cpp panels/DatasetListPanel.cpp panels/ObjectTreePanel.cpp + panels/KeyValueView.cpp panels/DynamicFormView.cpp panels/DynamicFormEditor.cpp panels/ObjectAttrPanel.cpp @@ -46,6 +48,7 @@ add_executable(geopro_desktop WIN32 panels/chart/DetailViewFactory.cpp resources/map/map.qrc resources/keys.qrc + resources/icons.qrc panels/chart/ChartTheme.cpp panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp diff --git a/src/app/FormKit.cpp b/src/app/FormKit.cpp new file mode 100644 index 0000000..9ffd676 --- /dev/null +++ b/src/app/FormKit.cpp @@ -0,0 +1,110 @@ +#include "FormKit.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "Theme.hpp" +#include "panels/KeyValueView.hpp" + +namespace geopro::app::formkit { + +DetailForm& DetailForm::group(const QString& name) { + geopro::data::DynamicFormGroup g; + g.name = name.toStdString(); + form_.groups.push_back(std::move(g)); + return *this; +} + +DetailForm& DetailForm::row(const QString& key, const QString& value) { + if (form_.groups.empty()) group(QString()); // 无显式分组时落入匿名组 + geopro::data::DynamicFormField f; + f.name = key.toStdString(); + f.value = value.toStdString(); + form_.groups.back().fields.push_back(std::move(f)); + return *this; +} + +void buildDetailDialog(QDialog* dlg, const geopro::data::DynamicForm& form, + const QList& extras) { + dlg->setModal(true); + auto* root = new QVBoxLayout(dlg); + root->setContentsMargins(space::kLg, space::kLg, space::kLg, space::kLg); + root->setSpacing(space::kMd); + + // 卡片:与属性面板同款(bg/panel-subtle + 1px 边框 + 中圆角),内嵌唯一只读渲染器 + // KeyValueView——对话框随内容自适应大小,无嵌套滚动/留空。 + auto* card = new QFrame(dlg); + card->setObjectName(QStringLiteral("detailCard")); + applyTokenizedStyleSheet( + card, QStringLiteral("#detailCard { background:{{bg/panel-subtle}};" + "border:1px solid {{border/default}}; border-radius:%1px; }") + .arg(radius::kMd)); + auto* cardLay = new QVBoxLayout(card); + cardLay->setContentsMargins(space::kLg, space::kLg, space::kLg, space::kLg); + cardLay->setSpacing(space::kMd); + + auto* kv = new KeyValueView(card); + kv->setForm(form); + cardLay->addWidget(kv); + + for (QWidget* w : extras) + if (w) cardLay->addWidget(w); + + root->addWidget(card); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, dlg); + QObject::connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::reject); + QObject::connect(buttons, &QDialogButtonBox::accepted, dlg, &QDialog::accept); + root->addWidget(buttons); + + const int minW = scaledPx(440); + if (dlg->minimumWidth() < minW) dlg->setMinimumWidth(minW); +} + +QFormLayout* makeEditForm() { + auto* fl = new QFormLayout(); + fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); + fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + fl->setHorizontalSpacing(space::kLg); // 标签↔字段 + fl->setVerticalSpacing(space::kMd); // 行距 + return fl; +} + +QLabel* editLabel(const QString& text, QWidget* parent, bool richText) { + auto* lbl = new QLabel(text, parent); + if (richText) lbl->setTextFormat(Qt::RichText); // 允许必填星号红色 span + lbl->setFixedWidth(scaledPx(space::kFormLabelCol)); // 定宽右标签列,跨表单对齐 + return lbl; +} + +void capField(QWidget* field) { + if (field) field->setMaximumWidth(scaledPx(space::kFormFieldMax)); // §7.0.2「不要拉满」 +} + +void addSection(QBoxLayout* into, const QString& title, QWidget* parent, bool topGap) { + if (topGap) into->addSpacing(space::kLg); + // 唯一的分组标题样式(编辑态与只读态共用):kTitle 半粗 + 次级色 + 标题下 1px divider。 + // 字号走 scaledPx 以随系统字号缩放。 + auto* sec = new QLabel(title, parent); + applyTokenizedStyleSheet( + sec, QStringLiteral("color:{{text/secondary}};font-size:%1px;font-weight:%2;" + "padding-bottom:%3px;") + .arg(scaledPx(type::kTitle)) + .arg(type::kWeightSemibold) + .arg(space::kXs)); + into->addWidget(sec); + auto* rule = new QFrame(parent); + rule->setFrameShape(QFrame::HLine); + rule->setFixedHeight(1); + applyTokenizedStyleSheet(rule, QStringLiteral("background:{{divider}};border:none;")); + into->addWidget(rule); +} + +} // namespace geopro::app::formkit diff --git a/src/app/FormKit.hpp b/src/app/FormKit.hpp new file mode 100644 index 0000000..5e87956 --- /dev/null +++ b/src/app/FormKit.hpp @@ -0,0 +1,49 @@ +#pragma once + +// FormKit —— 表单渲染的「单一真相」层。 +// +// 历史问题:每个表单/对话框各自手搭布局(裸 QFormLayout + 裸 QLabel、各写边距/标签列宽), +// 规范文档(§6.4/§7.0)只是文字约束、无法强制,于是同类控件在不同位置长得不一样。 +// 本模块把「只读键值详情」与「可编辑表单」的视觉度量收敛到唯一实现,所有表单必须经此产出, +// 一致性由「代码复用」强制,而非「人工遵守文档」。 + +#include +#include + +#include "repo/RepoTypes.hpp" + +class QBoxLayout; +class QDialog; +class QFormLayout; +class QLabel; +class QWidget; + +namespace geopro::app::formkit { + +// ── 只读详情:唯一键值模型构建器 ───────────────────────────────────────────── +// 链式 group()/row() 产出 data::DynamicForm,喂给唯一的 §6.4 渲染器 DynamicFormView。 +// 三维体/切片/异常等「数据详情」对话框共用,杜绝裸 QFormLayout 漂移。 +class DetailForm { +public: + DetailForm& group(const QString& name); // 开新分组 + DetailForm& row(const QString& key, const QString& value); // 向当前组追加键值行 + const geopro::data::DynamicForm& build() const { return form_; } + +private: + geopro::data::DynamicForm form_; +}; + +// 给只读详情对话框铺设标准骨架:DynamicFormView(§6.4 卡片)+ 底部 Close + 统一边距。 +// extras 追加在卡片下方、按钮上方(异常详情的顶点坐标/备注走此口),与主键值表共享节奏。 +void buildDetailDialog(QDialog* dlg, const geopro::data::DynamicForm& form, + const QList& extras = {}); + +// ── 可编辑表单:§7.0 统一度量(DynamicFormEditor 与各参数对话框共用,单一真相)────── +QFormLayout* makeEditForm(); // 右对齐标签 + 标准行距/列距 +QLabel* editLabel(const QString& text, QWidget* parent = nullptr, + bool richText = false); // 定宽右标签列(kFormLabelCol) +void capField(QWidget* field); // 字段最大宽上限(kFormFieldMax) +// 分组标题(heading 字号 + 半粗 + 次级色)+ 标题下 1px divider;topGap=true 时上方留 space/lg。 +void addSection(QBoxLayout* into, const QString& title, QWidget* parent, bool topGap); + +} // namespace geopro::app::formkit diff --git a/src/app/SlicePropertiesDialog.cpp b/src/app/SlicePropertiesDialog.cpp index 3991808..958e63d 100644 --- a/src/app/SlicePropertiesDialog.cpp +++ b/src/app/SlicePropertiesDialog.cpp @@ -1,12 +1,9 @@ #include "SlicePropertiesDialog.hpp" -#include -#include -#include -#include - #include +#include "FormKit.hpp" + namespace geopro::app { namespace { @@ -34,31 +31,22 @@ SlicePropertiesDialog::SlicePropertiesDialog(const QString& name, const SliceSpe QWidget* parent) : QDialog(parent) { setWindowTitle(QStringLiteral("切片属性")); - setModal(true); - auto* root = new QVBoxLayout(this); - auto* form = new QFormLayout(); + formkit::DetailForm form; + form.group(QStringLiteral("切片")) + .row(QStringLiteral("名称"), name.isEmpty() ? QStringLiteral("—") : name) + .row(QStringLiteral("所属三维体"), spec.volumeDsId.empty() + ? QStringLiteral("—") + : QString::fromStdString(spec.volumeDsId)) + .row(QStringLiteral("轴向"), axisLabel(spec.axis)) + .row(QStringLiteral("Origin"), pointLabel(spec.origin)) + .row(QStringLiteral("Point1"), pointLabel(spec.point1)) + .row(QStringLiteral("Point2"), pointLabel(spec.point2)) + .row(QStringLiteral("色阶来源"), + spec.colorScaleId.empty() ? QStringLiteral("首个源数据集") + : QString::fromStdString(spec.colorScaleId)); - form->addRow(QStringLiteral("名称"), - new QLabel(name.isEmpty() ? QStringLiteral("—") : name)); - form->addRow(QStringLiteral("所属三维体"), - new QLabel(spec.volumeDsId.empty() ? QStringLiteral("—") - : QString::fromStdString(spec.volumeDsId))); - form->addRow(QStringLiteral("轴向"), new QLabel(axisLabel(spec.axis))); - form->addRow(QStringLiteral("Origin"), new QLabel(pointLabel(spec.origin))); - form->addRow(QStringLiteral("Point1"), new QLabel(pointLabel(spec.point1))); - form->addRow(QStringLiteral("Point2"), new QLabel(pointLabel(spec.point2))); - form->addRow(QStringLiteral("色阶来源"), - new QLabel(spec.colorScaleId.empty() - ? QStringLiteral("首个源数据集") - : QString::fromStdString(spec.colorScaleId))); - - root->addLayout(form); - - auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close); - connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); - connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); - root->addWidget(buttons); + formkit::buildDetailDialog(this, form.build()); } } // namespace geopro::app diff --git a/src/app/Theme.cpp b/src/app/Theme.cpp index ee59d9c..168f92b 100644 --- a/src/app/Theme.cpp +++ b/src/app/Theme.cpp @@ -375,8 +375,8 @@ QComboBox { color: {{text/primary}}; border: 1px solid {{border/default}}; border-radius: 4px; /* radius/sm */ - padding: 6px 10px; - min-height: 16px; + padding: 6px 8px; /* 与 QLineEdit 完全一致 */ + min-height: 16px; /* 与 QLineEdit 完全一致 → 同高(可编辑/不可编辑均如此)*/ } QComboBox:hover { border-color: {{border/strong}}; @@ -384,9 +384,42 @@ QComboBox:hover { QComboBox:focus { border-color: {{border/focus}}; } -QComboBox::drop-down { +QComboBox:disabled { + background: {{bg/app}}; + color: {{text/disabled}}; +} +/* 日期/时间编辑器:与输入框/下拉框同款外观(box 一致,避免「布设日期」与下拉框不一致)。 */ +QDateEdit, QTimeEdit, QDateTimeEdit { + background: {{bg/panel}}; + color: {{text/primary}}; + border: 1px solid {{border/default}}; + border-radius: 4px; /* radius/sm */ + padding: 6px 8px; + min-height: 16px; +} +QDateEdit:hover, QTimeEdit:hover, QDateTimeEdit:hover { + border-color: {{border/strong}}; +} +QDateEdit:focus, QTimeEdit:focus, QDateTimeEdit:focus { + border-color: {{border/focus}}; +} +QDateEdit:disabled, QTimeEdit:disabled, QDateTimeEdit:disabled { + background: {{bg/app}}; + color: {{text/disabled}}; +} +/* 下拉按钮平面化(去 Fusion 原生斜角/分隔)+ 统一的扁平 chevron 箭头(qrc 内嵌 SVG)。 + 覆写 ::drop-down 必须同时提供 ::down-arrow 图,否则箭头消失(历史坑)。 */ +QComboBox::drop-down, QDateEdit::drop-down, QTimeEdit::drop-down, QDateTimeEdit::drop-down { + subcontrol-origin: padding; + subcontrol-position: center right; + width: 20px; border: none; - width: 22px; + background: transparent; +} +QComboBox::down-arrow, QDateEdit::down-arrow, QTimeEdit::down-arrow, QDateTimeEdit::down-arrow { + image: url(:/icons/chevron-down.svg); + width: 12px; + height: 12px; } QComboBox QAbstractItemView { background: {{bg/panel}}; diff --git a/src/app/Theme.hpp b/src/app/Theme.hpp index a798d48..23da2a0 100644 --- a/src/app/Theme.hpp +++ b/src/app/Theme.hpp @@ -77,11 +77,14 @@ inline constexpr int kXl = 16; // 区块内边距 inline constexpr int kXxl = 24; // 区块间距、表单纵向边距 inline constexpr int kXxxl = 32; // 页面级留白(登录窗左右边距) -// 可编辑表单标签列宽(规范 §7.0.2:默认 96–120px,同表单内等宽对齐右标签)。 -// 唯一事实来源——DynamicFormEditor / ObjectAttrPanel / ObjectFormDialog 的左标签列统一引此值。 -// 注:纯只读键值表(§6.4)另用 72px(见 DynamicFormView::kKeyColWidth),不复用此档。 +// 可编辑表单标签列宽(规范 §7.0.2:固定 100px,同表单内等宽对齐右标签)。 +// 唯一事实来源——DynamicFormEditor / 各参数对话框经 formkit::editLabel 统一引此值。 inline constexpr int kFormLabelCol = 100; +// 只读键值表(§6.4)键列定宽。与可编辑标签列分档(只读两列布局更紧凑)—— +// 唯一事实来源,DynamicFormView 引此值;规范 §7.0.10 列为精确常量,禁止区间/魔数。 +inline constexpr int kDetailKeyCol = 72; + // 可编辑字段最大宽(规范 §7.0.2:宽对话框中「不要拉满」,单字段最大约 360px)。 // 窄属性面板里该上限大于面板宽,故字段仍填满——符合规范。多行/长文本不受此限。 inline constexpr int kFormFieldMax = 360; diff --git a/src/app/VolumeParamsDialog.cpp b/src/app/VolumeParamsDialog.cpp index 8560a45..ce14888 100644 --- a/src/app/VolumeParamsDialog.cpp +++ b/src/app/VolumeParamsDialog.cpp @@ -9,6 +9,9 @@ #include #include +#include "FormKit.hpp" +#include "Theme.hpp" + namespace geopro::app { namespace { @@ -24,13 +27,19 @@ VolumeParamsDialog::VolumeParamsDialog(int sourceCount, QWidget* parent) : QDial setModal(true); auto* root = new QVBoxLayout(this); - root->addWidget(new QLabel( - QStringLiteral("由 %1 个源数据集插值生成三维体").arg(sourceCount))); + root->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, + geopro::app::space::kLg, geopro::app::space::kLg); + root->setSpacing(geopro::app::space::kMd); - auto* form = new QFormLayout(); + auto* intro = new QLabel(QStringLiteral("由 %1 个源数据集插值生成三维体").arg(sourceCount)); + geopro::app::applyTokenizedStyleSheet(intro, QStringLiteral("color:{{text/secondary}};")); + root->addWidget(intro); + + auto* form = formkit::makeEditForm(); name_ = new QLineEdit(QStringLiteral("三维体")); - form->addRow(QStringLiteral("名称"), name_); + formkit::capField(name_); + form->addRow(formkit::editLabel(QStringLiteral("名称")), name_); model_ = new QComboBox(); model_->addItem(QStringLiteral("反距离加权 (IDW)"), @@ -42,7 +51,8 @@ VolumeParamsDialog::VolumeParamsDialog(int sourceCount, QWidget* parent) : QDial if (auto* it = m->item(1)) it->setEnabled(false); } model_->setCurrentIndex(0); - form->addRow(QStringLiteral("插值模型"), model_); + formkit::capField(model_); + form->addRow(formkit::editLabel(QStringLiteral("插值模型")), model_); auto makeSpin = [this](double val, double min, double max, double step, int decimals) { auto* s = new QDoubleSpinBox(); @@ -50,16 +60,17 @@ VolumeParamsDialog::VolumeParamsDialog(int sourceCount, QWidget* parent) : QDial s->setSingleStep(step); s->setDecimals(decimals); s->setValue(val); + formkit::capField(s); return s; }; cellXY_ = makeSpin(kDefCellXY, 0.01, 1000.0, 0.5, 2); cellZ_ = makeSpin(kDefCellZ, 0.01, 1000.0, 0.5, 2); power_ = makeSpin(kDefPower, 0.5, 6.0, 0.5, 1); maxDist_ = makeSpin(kDefMaxDist, 0.1, 10000.0, 1.0, 2); - form->addRow(QStringLiteral("水平间距 (米)"), cellXY_); - form->addRow(QStringLiteral("竖向间距 (米)"), cellZ_); - form->addRow(QStringLiteral("IDW 幂次"), power_); - form->addRow(QStringLiteral("最大影响距离 (米)"), maxDist_); + form->addRow(formkit::editLabel(QStringLiteral("水平间距 (米)")), cellXY_); + form->addRow(formkit::editLabel(QStringLiteral("竖向间距 (米)")), cellZ_); + form->addRow(formkit::editLabel(QStringLiteral("IDW 幂次")), power_); + form->addRow(formkit::editLabel(QStringLiteral("最大影响距离 (米)")), maxDist_); root->addLayout(form); diff --git a/src/app/VolumePropertiesDialog.cpp b/src/app/VolumePropertiesDialog.cpp index 148b127..3818caf 100644 --- a/src/app/VolumePropertiesDialog.cpp +++ b/src/app/VolumePropertiesDialog.cpp @@ -1,10 +1,8 @@ #include "VolumePropertiesDialog.hpp" -#include -#include -#include #include -#include + +#include "FormKit.hpp" namespace geopro::app { @@ -32,53 +30,38 @@ VolumePropertiesDialog::VolumePropertiesDialog(const QString& name, const Volume QWidget* parent) : QDialog(parent) { setWindowTitle(QStringLiteral("三维体属性")); - setModal(true); - - auto* root = new QVBoxLayout(this); - auto* form = new QFormLayout(); + formkit::DetailForm form; // ── 参数(随时可取)───────────────────────────────────────────── - form->addRow(QStringLiteral("名称"), - new QLabel(name.isEmpty() ? QStringLiteral("—") : name)); - form->addRow(QStringLiteral("源数据集"), new QLabel(joinSources(info.params.sourceDatasetIds))); - form->addRow(QStringLiteral("插值模型"), new QLabel(modelLabel(info.params))); - form->addRow(QStringLiteral("网格间距"), - new QLabel(QStringLiteral("XY=%1 m Z=%2 m") - .arg(info.params.cellXY, 0, 'f', 2) - .arg(info.params.cellZ, 0, 'f', 2))); - form->addRow(QStringLiteral("超距"), - new QLabel(QStringLiteral("%1 m").arg(info.params.maxDist, 0, 'f', 2))); - form->addRow(QStringLiteral("色阶来源"), - new QLabel(info.params.colorScaleId.empty() - ? QStringLiteral("首个源数据集") - : QString::fromStdString(info.params.colorScaleId))); + form.group(QStringLiteral("参数")) + .row(QStringLiteral("名称"), name.isEmpty() ? QStringLiteral("—") : name) + .row(QStringLiteral("源数据集"), joinSources(info.params.sourceDatasetIds)) + .row(QStringLiteral("插值模型"), modelLabel(info.params)) + .row(QStringLiteral("网格间距"), QStringLiteral("XY=%1 m Z=%2 m") + .arg(info.params.cellXY, 0, 'f', 2) + .arg(info.params.cellZ, 0, 'f', 2)) + .row(QStringLiteral("超距"), QStringLiteral("%1 m").arg(info.params.maxDist, 0, 'f', 2)) + .row(QStringLiteral("色阶来源"), + info.params.colorScaleId.empty() ? QStringLiteral("首个源数据集") + : QString::fromStdString(info.params.colorScaleId)); // ── 统计(仅 loaded 时有效)────────────────────────────────────── if (info.loaded) { - form->addRow(QStringLiteral("值域"), new QLabel(QStringLiteral("%1 ~ %2") - .arg(info.vmin, 0, 'f', 2) - .arg(info.vmax, 0, 'f', 2))); - form->addRow(QStringLiteral("网格"), new QLabel(QStringLiteral("%1 × %2 × %3") - .arg(info.nx) - .arg(info.ny) - .arg(info.nz))); - form->addRow(QStringLiteral("测点数"), - new QLabel(QString::number(static_cast(info.pointCount)))); - form->addRow(QStringLiteral("范围"), - new QLabel(QStringLiteral("%1 × %2 × %3 m") - .arg(info.nx * info.dx, 0, 'f', 1) - .arg(info.ny * info.dy, 0, 'f', 1) - .arg(info.nz * info.dz, 0, 'f', 1))); + form.group(QStringLiteral("统计")) + .row(QStringLiteral("值域"), + QStringLiteral("%1 ~ %2").arg(info.vmin, 0, 'f', 2).arg(info.vmax, 0, 'f', 2)) + .row(QStringLiteral("网格"), + QStringLiteral("%1 × %2 × %3").arg(info.nx).arg(info.ny).arg(info.nz)) + .row(QStringLiteral("测点数"), QString::number(static_cast(info.pointCount))) + .row(QStringLiteral("范围"), QStringLiteral("%1 × %2 × %3 m") + .arg(info.nx * info.dx, 0, 'f', 1) + .arg(info.ny * info.dy, 0, 'f', 1) + .arg(info.nz * info.dz, 0, 'f', 1)); } else { - form->addRow(QStringLiteral("统计"), new QLabel(QString::fromUtf8(kPending))); + form.group(QStringLiteral("统计")).row(QStringLiteral("统计"), QString::fromUtf8(kPending)); } - root->addLayout(form); - - auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close); - connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); - connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); - root->addWidget(buttons); + formkit::buildDetailDialog(this, form.build()); } } // namespace geopro::app diff --git a/src/app/panels/DynamicFormEditor.cpp b/src/app/panels/DynamicFormEditor.cpp index 8cabb14..c4e7cb5 100644 --- a/src/app/panels/DynamicFormEditor.cpp +++ b/src/app/panels/DynamicFormEditor.cpp @@ -18,6 +18,7 @@ #include #include +#include "FormKit.hpp" #include "Theme.hpp" namespace geopro::app { @@ -92,15 +93,18 @@ QWidget* buildWidget(const data::EditField& f) { } case kCompSelect: case kCompTreeSelect: { - auto* cb = new QComboBox(); + // 无候选项:退化为自由文本输入(QLineEdit),而非「可编辑下拉框」。 + // 可编辑下拉框与不可编辑下拉框在 QSS 下几何/高度不一致,是表单观感分裂的来源之一。 if (f.options.empty()) { - cb->setEditable(true); - cb->setCurrentText(val); - } else { - flattenOptions(f.options, cb); - const int idx = cb->findData(val); - if (idx >= 0) cb->setCurrentIndex(idx); + auto* le = new QLineEdit(); + le->setText(val); + if (ro) le->setEnabled(false); + return le; } + auto* cb = new QComboBox(); + flattenOptions(f.options, cb); + const int idx = cb->findData(val); + if (idx >= 0) cb->setCurrentIndex(idx); if (ro) cb->setEnabled(false); return cb; } @@ -178,6 +182,7 @@ QString readWidget(int comp, QWidget* w) { const QVariant d = cb->currentData(); return d.isValid() ? d.toString() : cb->currentText(); } + if (auto* le = qobject_cast(w)) return le->text(); // 无候选项时的自由文本退化 return {}; case kCompDate: if (auto* de = qobject_cast(w)) @@ -247,6 +252,8 @@ void DynamicFormEditor::setForm(const data::EditableForm& form, case kCompTreeSelect: if (auto* x = qobject_cast(w)) connect(x, &QComboBox::currentTextChanged, this, [this] { emit changed(); }); + else if (auto* le = qobject_cast(w)) // 无候选项时的自由文本退化 + connect(le, &QLineEdit::textEdited, this, [this] { emit changed(); }); break; case kCompDate: if (auto* x = qobject_cast(w)) @@ -292,36 +299,16 @@ void DynamicFormEditor::setForm(const data::EditableForm& form, if (visible.empty()) continue; // 整组被隐藏 → 不渲染空标题 if (form.groups.size() > 1 || !g.name.empty()) { - // 分组标题(规范 §7.0.3 / §6.4):text/heading 字号 + 加粗 + 次级色;非首组上留 space/lg。 - if (renderedGroup) outer->addSpacing(geopro::app::space::kLg); - auto* sec = new QLabel(QString::fromStdString(g.name), body_); - geopro::app::applyTokenizedStyleSheet( - sec, QStringLiteral("color:{{text/secondary}};font-size:%1px;font-weight:%2;") - .arg(geopro::app::type::kHeading) - .arg(geopro::app::type::kWeightSemibold)); - outer->addWidget(sec); - // 标题下 1px divider 贯通(规范 §7.0.3)。 - auto* rule = new QFrame(body_); - rule->setFrameShape(QFrame::HLine); - rule->setFixedHeight(1); - geopro::app::applyTokenizedStyleSheet( - rule, QStringLiteral("background:{{divider}};border:none;")); - outer->addWidget(rule); + // 分组标题 + 标题下 1px divider(规范 §7.0.3 / §6.4),统一走 formkit(单一真相)。 + formkit::addSection(outer, QString::fromStdString(g.name), body_, renderedGroup); } renderedGroup = true; - auto* fl = new QFormLayout(); - fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); - fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - fl->setHorizontalSpacing(geopro::app::space::kLg); // 标签↔字段 space/md(12)=项目 kLg - fl->setVerticalSpacing(geopro::app::space::kMd); // 行距 ≈8px(项目 kMd) + auto* fl = formkit::makeEditForm(); for (const data::EditField* fp : visible) { const data::EditField& f = *fp; QWidget* w = buildWidget(f); - capFieldWidth(f.comp, w); // 字段最大宽上限(§7.0.2 不要拉满) - auto* lbl = new QLabel(labelText(f), body_); - lbl->setTextFormat(Qt::RichText); // 允许 * 的红色 span - lbl->setFixedWidth(geopro::app::scaledPx(geopro::app::space::kFormLabelCol)); // 等宽右标签列 - fl->addRow(lbl, w); + capFieldWidth(f.comp, w); // 字段最大宽上限(§7.0.2 不要拉满;多行除外,故仍用本地版) + fl->addRow(formkit::editLabel(labelText(f), body_, true), w); Entry e; e.code = QString::fromStdString(f.code); e.name = QString::fromStdString(f.name); diff --git a/src/app/panels/DynamicFormView.cpp b/src/app/panels/DynamicFormView.cpp index 2c11133..39c298b 100644 --- a/src/app/panels/DynamicFormView.cpp +++ b/src/app/panels/DynamicFormView.cpp @@ -1,114 +1,16 @@ #include "panels/DynamicFormView.hpp" -#include #include -#include #include #include #include #include "Theme.hpp" +#include "panels/KeyValueView.hpp" namespace geopro::app { -namespace { - -// 两列字段网格的逻辑列:label/value 各两份,value 列吸收伸展、label 列贴合内容。 -constexpr int kColLabelA = 0; -constexpr int kColValueA = 1; -constexpr int kColLabelB = 2; -constexpr int kColValueB = 3; -constexpr int kColSpanAll = 4; // 分组标题带横跨全部 4 列 - -constexpr int kKeyColWidth = 72; // 键列定宽(§6.4「定宽约 72px」),保证多组键列对齐 -constexpr int kRowMinHeight = 28; // 字段行高(§6.4「行高 28px」) - -// 判断值是否为「数值类」(坐标/数值/带单位/编号/日期):去掉常见数字标点、单位与 -// 坐标符号后,剩余字符多为数字则判定为数值类——这类值改用等宽字族逐列对齐(§2.1/§6.4)。 -// 纯文字(如名称、说明)不命中,保持默认字族。 -bool isNumericValue(const QString& text) -{ - const QString trimmed = text.trimmed(); - if (trimmed.isEmpty()) return false; - int digits = 0; - int letters = 0; - for (const QChar c : trimmed) { - if (c.isDigit()) { - ++digits; - } else if (c.isLetter()) { - // 允许少量单位/坐标字母(E/N/W/S/m/z/Ω 等),但成片字母视为文字。 - ++letters; - } - } - // 至少含一位数字,且数字不少于字母:坐标「103.85°E·36.72°N」「140m」「z=1.0x」命中, - // 「华亭测区」「正常」这类纯文字不命中。 - return digits > 0 && digits >= letters; -} - -// 字段标签(次要色,右侧留点呼吸,顶对齐以配合值换行)。键列定宽以与各组对齐(§6.4)。 -QLabel* makeLabel(const QString& text) -{ - auto* k = new QLabel(text); - k->setAlignment(Qt::AlignLeft | Qt::AlignTop); - k->setFixedWidth(geopro::app::scaledPx(kKeyColWidth)); // 定宽 72px,跨组对齐 - k->setMinimumHeight(geopro::app::scaledPx(kRowMinHeight)); // 行高 28px - geopro::app::applyTokenizedStyleSheet( - k, QStringLiteral("color:{{text/secondary}}; background:transparent; padding:2px 0;")); - return k; -} - -// 字段值(主色、可换行、可选中复制)。数值/坐标/带单位值改用等宽字族(§6.4)。 -QLabel* makeValue(const QString& text) -{ - auto* v = new QLabel(text); - v->setWordWrap(true); - v->setAlignment(Qt::AlignLeft | Qt::AlignTop); - v->setTextInteractionFlags(Qt::TextSelectableByMouse); - v->setMinimumHeight(geopro::app::scaledPx(kRowMinHeight)); // 行高 28px - if (isNumericValue(text)) { - // 仅切字族(保留 QSS 给的颜色/字号):等宽保证逐列对齐。 - QFont f = v->font(); - // 必须用 setFamilies(列表):setFamily 把整串逗号名当成单一字族找不到→静默回退。 - f.setFamilies(QString::fromLatin1(geopro::app::type::kMonoFamily).split(QStringLiteral(", "))); - v->setFont(f); - } - geopro::app::applyTokenizedStyleSheet( - v, QStringLiteral("color:{{text/primary}}; background:transparent; padding:2px 0;")); - return v; -} - -// 行间横向分隔线(1px,divider 令牌,随主题重着色)。 -QFrame* makeRowDivider() -{ - auto* line = new QFrame(); - line->setFrameShape(QFrame::HLine); - line->setFrameShadow(QFrame::Plain); - line->setFixedHeight(1); - geopro::app::applyTokenizedStyleSheet( - line, QStringLiteral("background:{{divider}}; border:none;")); - return line; -} - -// 分组标题带:横跨整行的淡底强调条,标题级字号 + 半粗,给表单清晰的层级(§6.4 -// 「分组标题 text/heading,上留 space/md」)。上外边距用 space/md 与上一组拉开。 -QLabel* makeGroupHeader(const QString& name) -{ - auto* title = new QLabel(name); - geopro::app::applyTokenizedStyleSheet( - title, QStringLiteral("color:{{text/secondary}}; background:{{bg/hover}};" - "font-weight:%1; font-size:%2px;" - "border-radius:%3px; padding:5px 10px; margin-top:%4px;") - .arg(geopro::app::type::kWeightSemibold) - .arg(geopro::app::scaledPx(geopro::app::type::kHeading)) - .arg(geopro::app::radius::kSm) - .arg(geopro::app::space::kMd)); - return title; -} - -} // namespace - -DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) -{ +DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) { auto* outer = new QVBoxLayout(this); outer->setContentsMargins(0, 0, 0, 0); outer->setSpacing(0); @@ -125,18 +27,31 @@ DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) hostLayout->setSpacing(0); // 表单卡片:浅一档底色 + 1px 边框 + 中圆角,从面板底上读出独立「表单」面。 - card_ = new QFrame(); - card_->setObjectName(QStringLiteral("attrForm")); + auto* card = new QFrame(); + card->setObjectName(QStringLiteral("attrForm")); geopro::app::applyTokenizedStyleSheet( - card_, QStringLiteral("#attrForm { background:{{bg/panel-subtle}};" - "border:1px solid {{border/default}}; border-radius:%1px; }") - .arg(geopro::app::radius::kMd)); - cardLayout_ = new QVBoxLayout(card_); - cardLayout_->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, - geopro::app::space::kLg, geopro::app::space::kLg); - cardLayout_->setSpacing(geopro::app::space::kMd); + card, QStringLiteral("#attrForm { background:{{bg/panel-subtle}};" + "border:1px solid {{border/default}}; border-radius:%1px; }") + .arg(geopro::app::radius::kMd)); + auto* cardLayout = new QVBoxLayout(card); + cardLayout->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, + geopro::app::space::kLg, geopro::app::space::kLg); + cardLayout->setSpacing(geopro::app::space::kMd); - hostLayout->addWidget(card_); + // 空/错占位(与键值视图互斥显示)。 + placeholder_ = new QLabel(card); + placeholder_->setAlignment(Qt::AlignCenter); + geopro::app::applyTokenizedStyleSheet( + placeholder_, + QStringLiteral("color:{{text/disabled}}; background:transparent; padding:%1px;") + .arg(geopro::app::space::kXl)); + cardLayout->addWidget(placeholder_); + + // 唯一只读渲染器(§6.4)。详情对话框直接复用 KeyValueView,保证处处一致。 + kv_ = new KeyValueView(card); + cardLayout->addWidget(kv_); + + hostLayout->addWidget(card); hostLayout->addStretch(); scroll->setWidget(host); outer->addWidget(scroll); @@ -144,91 +59,21 @@ DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) showMessage(QStringLiteral("(选中后显示属性详情)")); } -void DynamicFormView::clear() -{ - while (cardLayout_->count() > 0) { - QLayoutItem* it = cardLayout_->takeAt(0); - if (it->widget()) it->widget()->deleteLater(); - if (QLayout* sub = it->layout()) { - // 嵌套网格:先回收其子控件(控件归属 card_,删布局不会连带删),再让下方 - // delete it 释放该嵌套布局本身——注意 it == it->layout()(QLayout 即 QLayoutItem), - // 故此处不可再 delete sub,否则与 delete it 重复释放导致崩溃。 - while (sub->count() > 0) { - QLayoutItem* sit = sub->takeAt(0); - if (sit->widget()) sit->widget()->deleteLater(); - delete sit; - } - } - delete it; - } -} - -void DynamicFormView::showCardMessage(const QString& message) -{ - auto* hint = new QLabel(message); - hint->setAlignment(Qt::AlignCenter); - geopro::app::applyTokenizedStyleSheet( - hint, QStringLiteral("color:{{text/disabled}}; background:transparent; padding:%1px;") - .arg(geopro::app::space::kXl)); - cardLayout_->addWidget(hint); -} - -void DynamicFormView::showMessage(const QString& message) -{ - clear(); - showCardMessage(message); - cardLayout_->addStretch(); -} - -void DynamicFormView::setForm(const geopro::data::DynamicForm& form) -{ - clear(); +void DynamicFormView::setForm(const geopro::data::DynamicForm& form) { if (form.groups.empty()) { - showCardMessage(QStringLiteral("(暂无属性)")); - cardLayout_->addStretch(); + showMessage(QStringLiteral("(暂无属性)")); return; } + placeholder_->hide(); + kv_->show(); + kv_->setForm(form); +} - for (const auto& group : form.groups) { - // 每组一个独立网格:分组标题带横跨 4 列,字段两列自上而下、自左而右铺排。 - auto* grid = new QGridLayout(); - grid->setContentsMargins(0, 0, 0, 0); - grid->setHorizontalSpacing(geopro::app::space::kLg); - grid->setVerticalSpacing(geopro::app::space::kXs); - grid->setColumnStretch(kColValueA, 1); - grid->setColumnStretch(kColValueB, 1); - - // gridRow 线性递增:标题占一行,之后「分隔线行 + 字段对行」交替。 - int gridRow = 0; - grid->addWidget(makeGroupHeader(QString::fromStdString(group.name)), gridRow, 0, 1, - kColSpanAll); - ++gridRow; - - const int n = static_cast(group.fields.size()); - if (n == 0) { - grid->addWidget(makeLabel(QStringLiteral("(本组暂无字段)")), gridRow, 0, 1, - kColSpanAll); - } - // 字段两两成对,每对一行:偶数下标落左对(labelA|valueA),奇数落右对(labelB|valueB)。 - for (int i = 0; i < n; ++i) { - const auto& f = group.fields[static_cast(i)]; - const bool left = (i % 2 == 0); - if (left) { - // 每新起一行前先放一条横向分隔线(铺满 4 列),再让字段对落到下一行。 - grid->addWidget(makeRowDivider(), gridRow, 0, 1, kColSpanAll); - ++gridRow; - } - const int labelCol = left ? kColLabelA : kColLabelB; - const int valueCol = left ? kColValueA : kColValueB; - grid->addWidget(makeLabel(QString::fromStdString(f.name)), gridRow, labelCol); - grid->addWidget(makeValue(QString::fromStdString(f.value)), gridRow, valueCol); - if (!left) ++gridRow; // 右对填满,行完结;左对则等右对或循环结束 - } - - cardLayout_->addLayout(grid); - } - - cardLayout_->addStretch(); +void DynamicFormView::showMessage(const QString& message) { + kv_->clear(); + kv_->hide(); + placeholder_->setText(message); + placeholder_->show(); } } // namespace geopro::app diff --git a/src/app/panels/DynamicFormView.hpp b/src/app/panels/DynamicFormView.hpp index 31ea617..0470dad 100644 --- a/src/app/panels/DynamicFormView.hpp +++ b/src/app/panels/DynamicFormView.hpp @@ -2,14 +2,15 @@ #include #include "repo/RepoTypes.hpp" -class QVBoxLayout; -class QFrame; +class QLabel; namespace geopro::app { -// 被动:渲染 DynamicForm(分组键值)为「两列卡片式属性表单」。对象属性 / 数据集属性两面板共用。 -// 视觉:外层滚动区内嵌一张带边框/底色/圆角的表单卡片;每组一个分组标题带(横跨整行), -// 组内字段两列排布(labelA|valueA labelB|valueB),行间细分隔线。颜色全走主题令牌。 +class KeyValueView; + +// 属性面板用的只读表单容器:滚动区 + 卡片,内嵌唯一渲染器 KeyValueView(§6.4)。 +// 对象属性 / 数据集属性面板共用。数据详情对话框不经本类,直接用 KeyValueView +// (见 formkit::buildDetailDialog),但二者共享同一渲染器,外观一致。 class DynamicFormView : public QWidget { public: explicit DynamicFormView(QWidget* parent = nullptr); @@ -17,11 +18,8 @@ public: void showMessage(const QString& message); // 空/错占位 private: - void clear(); // 拆掉卡片内全部内容(含分隔线/标题/字段) - void showCardMessage(const QString& message); // 卡片内居中淡色提示 - - QFrame* card_ = nullptr; // 表单卡片(objectName=attrForm) - QVBoxLayout* cardLayout_ = nullptr; // 卡片内纵向布局(容纳各分组网格 / 占位提示) + KeyValueView* kv_ = nullptr; + QLabel* placeholder_ = nullptr; }; } // namespace geopro::app diff --git a/src/app/panels/KeyValueView.cpp b/src/app/panels/KeyValueView.cpp new file mode 100644 index 0000000..08c129c --- /dev/null +++ b/src/app/panels/KeyValueView.cpp @@ -0,0 +1,119 @@ +#include "panels/KeyValueView.hpp" + +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" + +namespace geopro::app { + +namespace { + +constexpr int kColLabelA = 0; +constexpr int kColValueA = 1; +constexpr int kColLabelB = 2; +constexpr int kColValueB = 3; + +constexpr int kRowMinHeight = 28; // 字段行高(§6.4) + +// 数值/坐标类值用等宽字族逐列对齐(§2.1/§6.4);纯文字保持默认字族。 +bool isNumericValue(const QString& text) { + const QString trimmed = text.trimmed(); + if (trimmed.isEmpty()) return false; + int digits = 0; + int letters = 0; + for (const QChar c : trimmed) { + if (c.isDigit()) + ++digits; + else if (c.isLetter()) + ++letters; + } + return digits > 0 && digits >= letters; +} + +QLabel* makeKey(const QString& text) { + auto* k = new QLabel(text); + k->setAlignment(Qt::AlignLeft | Qt::AlignTop); + k->setFixedWidth(geopro::app::scaledPx(geopro::app::space::kDetailKeyCol)); // 定宽,跨组对齐 + k->setMinimumHeight(geopro::app::scaledPx(kRowMinHeight)); + geopro::app::applyTokenizedStyleSheet( + k, QStringLiteral("color:{{text/secondary}}; background:transparent; padding:2px 0;")); + return k; +} + +QLabel* makeValue(const QString& text) { + auto* v = new QLabel(text); + v->setWordWrap(true); + v->setAlignment(Qt::AlignLeft | Qt::AlignTop); + v->setTextInteractionFlags(Qt::TextSelectableByMouse); + v->setMinimumHeight(geopro::app::scaledPx(kRowMinHeight)); + if (isNumericValue(text)) { + QFont f = v->font(); + // 必须 setFamilies(列表):setFamily 把整串逗号名当单一字族找不到→静默回退。 + f.setFamilies(QString::fromLatin1(geopro::app::type::kMonoFamily).split(QStringLiteral(", "))); + v->setFont(f); + } + geopro::app::applyTokenizedStyleSheet( + v, QStringLiteral("color:{{text/primary}}; background:transparent; padding:2px 0;")); + return v; +} + +} // namespace + +KeyValueView::KeyValueView(QWidget* parent) : QWidget(parent) { + lay_ = new QVBoxLayout(this); + lay_->setContentsMargins(0, 0, 0, 0); + lay_->setSpacing(geopro::app::space::kMd); +} + +void KeyValueView::clear() { + QLayoutItem* it = nullptr; + while ((it = lay_->takeAt(0)) != nullptr) { + if (it->widget()) it->widget()->deleteLater(); + if (QLayout* sub = it->layout()) { + QLayoutItem* sit = nullptr; + while ((sit = sub->takeAt(0)) != nullptr) { + if (sit->widget()) sit->widget()->deleteLater(); + delete sit; + } + } + delete it; + } +} + +void KeyValueView::setForm(const geopro::data::DynamicForm& form) { + clear(); + bool renderedGroup = false; + for (const auto& group : form.groups) { + const QString gname = QString::fromStdString(group.name); + if (!gname.isEmpty() || form.groups.size() > 1) { + formkit::addSection(lay_, gname, this, renderedGroup); + renderedGroup = true; + } + + // 两列网格:label/value 各两份,value 列吸收伸展、label 列贴合内容。 + auto* grid = new QGridLayout(); + grid->setContentsMargins(0, 0, 0, 0); + grid->setHorizontalSpacing(geopro::app::space::kLg); + grid->setVerticalSpacing(geopro::app::space::kXs); + grid->setColumnStretch(kColValueA, 1); + grid->setColumnStretch(kColValueB, 1); + + const int n = static_cast(group.fields.size()); + for (int i = 0; i < n; ++i) { + const auto& f = group.fields[static_cast(i)]; + const bool left = (i % 2 == 0); + const int gridRow = i / 2; + const int labelCol = left ? kColLabelA : kColLabelB; + const int valueCol = left ? kColValueA : kColValueB; + grid->addWidget(makeKey(QString::fromStdString(f.name)), gridRow, labelCol); + grid->addWidget(makeValue(QString::fromStdString(f.value)), gridRow, valueCol); + } + lay_->addLayout(grid); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/KeyValueView.hpp b/src/app/panels/KeyValueView.hpp new file mode 100644 index 0000000..cd274d8 --- /dev/null +++ b/src/app/panels/KeyValueView.hpp @@ -0,0 +1,25 @@ +#pragma once +#include + +#include "repo/RepoTypes.hpp" + +class QVBoxLayout; + +namespace geopro::app { + +// 唯一的「只读键值表」渲染器(规范 §6.4 / §7.0.10):分组标题(formkit::addSection)+ +// 两列键值(数值/坐标等宽对齐)。**不含**滚动区/卡片外壳——容器由调用方决定: +// · 属性面板:DynamicFormView 在滚动卡片内嵌本视图; +// · 数据详情对话框:formkit::buildDetailDialog 直接内嵌本视图(随内容自适应大小)。 +// 属性面板与各详情对话框共用本类,保证只读表单处处一致。 +class KeyValueView : public QWidget { +public: + explicit KeyValueView(QWidget* parent = nullptr); + void setForm(const geopro::data::DynamicForm& form); + void clear(); + +private: + QVBoxLayout* lay_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/resources/icons.qrc b/src/app/resources/icons.qrc new file mode 100644 index 0000000..6023a48 --- /dev/null +++ b/src/app/resources/icons.qrc @@ -0,0 +1,8 @@ + + + + + icons/chevron-down.svg + + diff --git a/src/app/resources/icons/chevron-down.svg b/src/app/resources/icons/chevron-down.svg new file mode 100644 index 0000000..82cd35f --- /dev/null +++ b/src/app/resources/icons/chevron-down.svg @@ -0,0 +1,4 @@ + + +