feat/vtk-3d-view #7

Merged
gaozheng merged 301 commits from feat/vtk-3d-view into main 2026-06-27 18:43:52 +08:00
17 changed files with 531 additions and 349 deletions
Showing only changes of commit 959f030c98 - Show all commits

View File

@ -414,7 +414,7 @@
| 元素 | 规范 |
|---|---|
| 标签位置 | **左侧标签列**(默认,密集专业风),标签文字**右对齐**贴近字段;字段名过长或窄单列对话框可改顶部标签 |
| 标签列宽 | 默认 `96120px`,同一表单内**等宽对齐**(纯只读键值表用 §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

View File

@ -1,10 +1,11 @@
#include "AnomalyPropertiesDialog.hpp"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QPlainTextEdit>
#include <QVBoxLayout>
#include <QWidget>
#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

View File

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

110
src/app/FormKit.cpp Normal file
View File

@ -0,0 +1,110 @@
#include "FormKit.hpp"
#include <QBoxLayout>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QFrame>
#include <QLabel>
#include <QVBoxLayout>
#include <utility>
#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<QWidget*>& 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

49
src/app/FormKit.hpp Normal file
View File

@ -0,0 +1,49 @@
#pragma once
// FormKit —— 表单渲染的「单一真相」层。
//
// 历史问题:每个表单/对话框各自手搭布局(裸 QFormLayout + 裸 QLabel、各写边距/标签列宽),
// 规范文档§6.4/§7.0)只是文字约束、无法强制,于是同类控件在不同位置长得不一样。
// 本模块把「只读键值详情」与「可编辑表单」的视觉度量收敛到唯一实现,所有表单必须经此产出,
// 一致性由「代码复用」强制,而非「人工遵守文档」。
#include <QList>
#include <QString>
#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<QWidget*>& 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 dividertopGap=true 时上方留 space/lg。
void addSection(QBoxLayout* into, const QString& title, QWidget* parent, bool topGap);
} // namespace geopro::app::formkit

View File

@ -1,12 +1,9 @@
#include "SlicePropertiesDialog.hpp"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QVBoxLayout>
#include <array>
#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

View File

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

View File

@ -77,11 +77,14 @@ inline constexpr int kXl = 16; // 区块内边距
inline constexpr int kXxl = 24; // 区块间距、表单纵向边距
inline constexpr int kXxxl = 32; // 页面级留白(登录窗左右边距)
// 可编辑表单标签列宽(规范 §7.0.2:默认 96120px同表单内等宽对齐右标签
// 唯一事实来源——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;

View File

@ -9,6 +9,9 @@
#include <QStandardItemModel>
#include <QVBoxLayout>
#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);

View File

@ -1,10 +1,8 @@
#include "VolumePropertiesDialog.hpp"
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QStringList>
#include <QVBoxLayout>
#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<qulonglong>(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<qulonglong>(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

View File

@ -18,6 +18,7 @@
#include <QTimeEdit>
#include <QVBoxLayout>
#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<QLineEdit*>(w)) return le->text(); // 无候选项时的自由文本退化
return {};
case kCompDate:
if (auto* de = qobject_cast<QDateEdit*>(w))
@ -247,6 +252,8 @@ void DynamicFormEditor::setForm(const data::EditableForm& form,
case kCompTreeSelect:
if (auto* x = qobject_cast<QComboBox*>(w))
connect(x, &QComboBox::currentTextChanged, this, [this] { emit changed(); });
else if (auto* le = qobject_cast<QLineEdit*>(w)) // 无候选项时的自由文本退化
connect(le, &QLineEdit::textEdited, this, [this] { emit changed(); });
break;
case kCompDate:
if (auto* x = qobject_cast<QDateEdit*>(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.4text/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);

View File

@ -1,114 +1,16 @@
#include "panels/DynamicFormView.hpp"
#include <QFont>
#include <QFrame>
#include <QGridLayout>
#include <QLabel>
#include <QScrollArea>
#include <QVBoxLayout>
#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;
}
// 行间横向分隔线1pxdivider 令牌,随主题重着色)。
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<int>(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<size_t>(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

View File

@ -2,14 +2,15 @@
#include <QWidget>
#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

View File

@ -0,0 +1,119 @@
#include "panels/KeyValueView.hpp"
#include <QFont>
#include <QGridLayout>
#include <QLabel>
#include <QVBoxLayout>
#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<int>(group.fields.size());
for (int i = 0; i < n; ++i) {
const auto& f = group.fields[static_cast<size_t>(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

View File

@ -0,0 +1,25 @@
#pragma once
#include <QWidget>
#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

View File

@ -0,0 +1,8 @@
<!DOCTYPE RCC>
<RCC version="1.0">
<!-- QSS 用的内嵌图标(下拉箭头等)。运行时经 :/icons/... 读取,平面现代风,
跨明暗主题用中性灰,避免覆写 ::drop-down 后 Fusion 原生斜角箭头的复古观感。 -->
<qresource prefix="/icons">
<file alias="chevron-down.svg">icons/chevron-down.svg</file>
</qresource>
</RCC>

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<path d="M3 4.5 L6 7.5 L9 4.5" fill="none" stroke="#6B7785" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B