geopro/src/app/panels/columns/CategorySection.cpp

366 lines
19 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "panels/columns/CategorySection.hpp"
#include <QComboBox>
#include <QDate>
#include <map>
#include <set>
#include "panels/columns/DateRangeEdit.hpp"
#include <QHBoxLayout>
#include <QLabel>
#include <QMenu>
#include <QPoint>
#include <QPushButton>
#include <QSet>
#include <QSignalBlocker>
#include <QToolButton>
#include <QTreeWidget>
#include <QTreeWidgetItemIterator>
#include <QVBoxLayout>
#include "Theme.hpp"
#include "panels/DatasetListPanel.hpp"
#include "repo/DatasetFieldDictionary.hpp"
namespace geopro::app {
using geopro::data::DsRow;
using geopro::data::DsTypeFields;
CategorySection::CategorySection(const CategorySpec& spec, geopro::data::DatasetFieldDictionary* dict,
QWidget* parent)
: QWidget(parent), spec_(spec), dict_(dict) {
auto* root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
// 数据类型段头可折叠规范§4.3/§6chevron + 标题(title 字号·半粗) |「+ 新增三维体」(右,仅反演类)。
// 段头加浅底 + 底分隔线作视觉分段;去原生小三角(难看)→chevron 文本前缀,随主题/hover 变色。
auto* headerRow = new QWidget(this);
headerRow->setObjectName(QStringLiteral("secHeader"));
applyTokenizedStyleSheet(headerRow,
QStringLiteral("QWidget#secHeader{background:{{bg/panel-subtle}};"
"border-bottom:1px solid {{divider}};}"));
auto* hl = new QHBoxLayout(headerRow);
hl->setContentsMargins(space::kMd, space::kSm, space::kSm, space::kSm);
hl->setSpacing(space::kSm);
header_ = new QToolButton(headerRow);
header_->setCheckable(true);
header_->setChecked(true);
header_->setArrowType(Qt::NoArrow);
header_->setToolButtonStyle(Qt::ToolButtonTextOnly);
header_->setCursor(Qt::PointingHandCursor);
applyTokenizedStyleSheet(
header_, QStringLiteral("QToolButton{border:none;background:transparent;padding:0;"
"font-size:%1px;font-weight:%2;color:{{text/primary}};}"
"QToolButton:hover{color:{{accent/primary}};}")
.arg(scaledPx(type::kTitle))
.arg(type::kWeightSemibold));
auto syncHeader = [this] {
header_->setText((header_->isChecked() ? QStringLiteral("") : QStringLiteral(""))
+ QString::fromStdString(spec_.title));
};
syncHeader();
hl->addWidget(header_);
hl->addStretch(1);
if (spec_.canGenerateVolume) {
auto* gen = new QToolButton(headerRow);
gen->setText(QStringLiteral("+ 新增三维体"));
gen->setCursor(Qt::PointingHandCursor);
// 次级强调按钮(规范§6.7):描边 accent + accent 文字hover 浅强调底;非裸文字。
applyTokenizedStyleSheet(
gen, QStringLiteral(
"QToolButton{border:1px solid {{accent/primary}};border-radius:%1px;"
"color:{{accent/primary}};background:transparent;padding:%2px %3px;font-size:%4px;}"
"QToolButton:hover{background:{{bg/selected}};}"
"QToolButton:pressed{background:{{bg/hover}};}")
.arg(radius::kSm)
.arg(scaledPx(space::kXxs))
.arg(scaledPx(space::kMd))
.arg(scaledPx(type::kCaption)));
connect(gen, &QToolButton::clicked, this, [this] {
emit generateVolumeRequested(QString::fromStdString(spec_.dsTypeCode), checkedDsIds());
});
hl->addWidget(gen);
}
root->addWidget(headerRow);
body_ = new QWidget(this);
auto* body = new QVBoxLayout(body_);
body->setContentsMargins(space::kMd, space::kSm, space::kMd, space::kMd);
body->setSpacing(space::kSm);
// 筛选行:采集时间范围(在前)+ 装置类型(在后,仅 ERT 类)。
auto* filterRow = new QHBoxLayout();
filterRow->setSpacing(space::kSm);
dateRange_ = new DateRangeEdit(body_);
connect(dateRange_, &DateRangeEdit::rangeChanged, this, [this] { rebuildList(); });
filterRow->addWidget(dateRange_, 1);
if (spec_.hasArrayTypeFilter) {
arrayCombo_ = new QComboBox(body_);
arrayCombo_->addItem(QStringLiteral("全部装置"), QString());
connect(arrayCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int) { rebuildList(); });
filterRow->addWidget(arrayCombo_);
}
body->addLayout(filterRow);
// 段体:可勾选数据树(勾选=渲染)。复用数据列表卡片委托与 populateDatasetList。
list_ = new QTreeWidget(body_);
list_->setHeaderHidden(true);
list_->setRootIsDecorated(true);
list_->setIndentation(14); // 紧凑父子缩进(默认 20 太宽)
// #7段体不出内层滚动条——内容超出时整段拉长由 CategoryAnalysisTab 外层 QScrollArea 统一滚动。
list_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
list_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
applyDatasetCardDelegate(list_);
connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* it, int) {
// 异常行复选框 = 该异常显隐(异常不进渲染勾选集,单独走 anomalyVisibilityChanged → setAnomalyVisible
if (it && it->data(0, kDsDdCodeRole).toString() == QStringLiteral("dd_anomaly"))
emit anomalyVisibilityChanged(it->data(0, kDsIdRole).toString(),
it->checkState(0) == Qt::Checked);
emitChecked();
});
connect(list_, &QTreeWidget::itemDoubleClicked, this, [this](QTreeWidgetItem* it, int) {
const QString id = it->data(0, kDsIdRole).toString();
if (id.isEmpty()) return;
emit detailRequested(id, it->data(0, kDsDdCodeRole).toString(), it->data(0, kDsNameRole).toString());
});
if (spec_.id == "voxel") { // 仅三维体段提供右键操作菜单(体/切片/异常)
list_->setContextMenuPolicy(Qt::CustomContextMenu);
connect(list_, &QTreeWidget::customContextMenuRequested, this, &CategorySection::showContextMenu);
// 树选中切片/异常 → VTK 高亮联动(正向 list→VTK
connect(list_, &QTreeWidget::itemSelectionChanged, this, [this] {
const auto items = list_->selectedItems();
if (items.isEmpty()) return;
QTreeWidgetItem* it = items.first();
const QString id = it->data(0, kDsIdRole).toString();
const QString dd = it->data(0, kDsDdCodeRole).toString();
if (!id.isEmpty() && dd != QStringLiteral("container")) emit datasetSelected(id, dd);
});
}
body->addWidget(list_, 1);
root->addWidget(body_, 1);
connect(header_, &QToolButton::toggled, this, [this, syncHeader](bool on) {
body_->setVisible(on);
syncHeader(); // ▾(展开)/▸(折叠) 切换
emit collapsedChanged(); // 外层据此把折叠段 stretch 归 0、展开段吸收余量 → 折叠向上收
});
}
bool CategorySection::isExpanded() const { return header_ && header_->isChecked(); }
void CategorySection::setStructure(const std::vector<geopro::data::StructNode>& nodes) {
structure_ = nodes; // 容器分层(项目根/GS/TM→ds在 Task 12 接入真实结构后据此构建。
}
void CategorySection::setDatasets(const std::vector<DsRow>& rows) {
rows_ = rows;
refreshArrayCombo();
rebuildList();
}
void CategorySection::selectItem(const QString& dsId) {
const QSignalBlocker block(list_); // 程序化选中(VTK→list)不回发 datasetSelected避免环路
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if (!dsId.isEmpty() && (*it)->data(0, kDsIdRole).toString() == dsId) {
list_->setCurrentItem(*it);
return;
}
list_->setCurrentItem(nullptr); // 空 dsId / 未找到 → 清选中
}
void CategorySection::setChecked(const QString& dsId, bool on) {
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->data(0, kDsIdRole).toString() == dsId &&
((*it)->flags() & Qt::ItemIsUserCheckable)) {
(*it)->setCheckState(0, on ? Qt::Checked : Qt::Unchecked); // 触发 itemChanged→emitChecked→渲染
return;
}
}
void CategorySection::refreshArrayCombo() {
if (!spec_.hasArrayTypeFilter || !arrayCombo_) return;
const QString prev = arrayCombo_->currentData().toString();
const QSignalBlocker block(arrayCombo_);
arrayCombo_->clear();
arrayCombo_->addItem(QStringLiteral("全部装置"), QString());
// 按「加载到本段的数据自身携带的类型值」的范围筛选(用户口径)。装置/arrayType 不在 ds 行数据上
// (实测 data/page 列表无该字段),故用 ds 已带的类型名 typeName多为中文作筛选维度
// typeName 缺失时回退 dsTypeCode + 全局枚举翻译。列出组内出现过的类型值。
QSet<QString> seen;
for (const auto& r : rows_) {
QString display = QString::fromStdString(r.typeName);
QString code = display;
if (display.isEmpty() && !r.dsTypeCode.empty()) { // 无类型名 → 用 dsTypeCode必要时枚举翻译
code = QString::fromStdString(r.dsTypeCode);
display = code;
if (dict_) {
const auto& en = dict_->arrayTypeEnum();
const auto it = en.find(r.dsTypeCode);
if (it != en.end()) display = QString::fromStdString(it->second);
}
}
if (code.isEmpty() || seen.contains(code)) continue;
seen.insert(code);
arrayCombo_->addItem(display, code); // itemData=类型值passesFilters 据此比对 typeName/dsTypeCode
}
const int idx = arrayCombo_->findData(prev); // 尽量保留上次选择
arrayCombo_->setCurrentIndex(idx >= 0 ? idx : 0);
}
bool CategorySection::passesFilters(const DsRow& row) const {
// 类型筛选("全部"=空不筛):按 ds 自身类型值typeName回退 dsTypeCode命中选中项。
if (spec_.hasArrayTypeFilter && arrayCombo_) {
const QString sel = arrayCombo_->currentData().toString();
if (!sel.isEmpty()) {
const QString t = !row.typeName.empty() ? QString::fromStdString(row.typeName)
: QString::fromStdString(row.dsTypeCode);
if (t != sel) return false;
}
}
// 采集时间范围collectTime 经 dict 取;缺则回退 createTime空范围不约束
const QDate from = dateRange_ ? dateRange_->from() : QDate();
const QDate to = dateRange_ ? dateRange_->to() : QDate();
if (from.isValid() || to.isValid()) {
const DsTypeFields* f = dict_ ? dict_->fields(spec_.dsTypeCode) : nullptr;
std::string ts = f ? collectTimeOf(row, *f) : std::string();
if (ts.empty()) ts = row.createTime;
const QDate d = QDate::fromString(QString::fromStdString(ts).left(10), QStringLiteral("yyyy-MM-dd"));
if (d.isValid()) {
if (from.isValid() && d < from) return false;
if (to.isValid() && d > to) return false;
}
}
return true;
}
void CategorySection::rebuildList() {
// 增量保留:记住当前已勾选的 ds重建后复原仍存在的项保持勾选。否则每次刷新(勾选对象/建体/
// 存切片/建异常都会触发)清空全部勾选 → 渲染被重置,体验极差(用户反馈:必须增量更新)。
std::set<std::string> wasChecked, wasSeen; // wasSeen=重建前所有可勾选项(区分"新项" vs "曾取消")
for (QTreeWidgetItemIterator it(list_); *it; ++it) {
if (!((*it)->flags() & Qt::ItemIsUserCheckable)) continue;
const std::string id = (*it)->data(0, kDsIdRole).toString().toStdString();
wasSeen.insert(id);
if ((*it)->checkState(0) == Qt::Checked) wasChecked.insert(id);
}
std::vector<DsRow> filtered;
filtered.reserve(rows_.size());
for (const auto& r : rows_)
if (passesFilters(r)) filtered.push_back(r);
// 从项目根的层级树:容器节点(结构,剪枝仅留有 ds 的路径)+ ds挂 parentId 或 structParentId
std::vector<DsRow> display;
if (!structure_.empty()) {
std::map<std::string, const geopro::data::StructNode*> byId;
for (const auto& n : structure_) byId[n.id] = &n;
std::set<std::string> keep; // 收集每个 ds 的结构归属向上的祖先链
for (const auto& d : filtered) {
std::string p = d.structParentId;
while (!p.empty() && byId.count(p) && !keep.count(p)) {
keep.insert(p);
p = byId[p]->parentId;
}
}
for (const auto& n : structure_)
if (keep.count(n.id)) {
DsRow c;
c.id = n.id;
c.dsName = n.name;
c.ddCode = "container";
c.parentId = n.parentId;
display.push_back(std::move(c));
}
}
// 分段树是「项目/GS/TM」组织树ds 应挂到其结构容器(TM)下。但派生 ds(如反演剖面)带派生父
// parentId指向原始 ds若该原始 ds 不在本段(被筛掉/属别类型段),按 parentId 挂载会失败 →
// ds 浮到树根平铺(用户实测 bugds 没挂在 tm 下)。故:派生父在本段则保留派生嵌套(如 体>切片)
// 否则回退挂到结构容器 structParentId(TM)。
std::set<std::string> presentIds;
for (const auto& d : filtered) presentIds.insert(d.id);
for (const auto& d : filtered) {
DsRow x = d;
if (x.parentId.empty() || !presentIds.count(x.parentId)) x.parentId = x.structParentId;
display.push_back(std::move(x));
}
{
const QSignalBlocker block(list_);
populateDatasetList(list_, display, /*append=*/false);
for (QTreeWidgetItemIterator it(list_); *it; ++it) {
// 容器节点(项目根/GS/TM只作层级骨架——明确去掉复选框、不可勾选。
if ((*it)->data(0, kDsDdCodeRole).toString() == QStringLiteral("container")) {
(*it)->setFlags((*it)->flags() & ~Qt::ItemIsUserCheckable);
continue;
}
(*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable);
const std::string id = (*it)->data(0, kDsIdRole).toString().toStdString();
const bool isAnomaly = (*it)->data(0, kDsDdCodeRole).toString() == QStringLiteral("dd_anomaly");
// 复原勾选:曾勾选→勾;曾出现但未勾→不勾;新项→异常默认勾(显示),其余默认不勾。
const Qt::CheckState st = wasChecked.count(id) ? Qt::Checked
: wasSeen.count(id) ? Qt::Unchecked
: (isAnomaly ? Qt::Checked : Qt::Unchecked);
(*it)->setCheckState(0, st);
}
}
list_->expandAll(); // 展开容器层级(项目根/GS/TM让体/切片/异常可见
// #7段体无内层滚动条 → list 最小高度 = 可见项内容总高(与 DatasetCardDelegate::sizeHint 一致:
// 有副标题 52否则 30。内容多则撑大本段、超视口由外层滚动内容少则被 stretch 拉到平分高度。
int contentH = 2 * list_->frameWidth() + 4;
for (QTreeWidgetItemIterator hit(list_); *hit; ++hit)
if (!(*hit)->isHidden())
contentH += (*hit)->text(0).contains(QLatin1Char('\n')) ? 52 : 30;
list_->setMinimumHeight(contentH);
emitChecked(); // 上抛复原后的勾选集(保持渲染,不再清空 → 控制器据 diff 增量保留已渲染图元)
}
QStringList CategorySection::checkedDsIds() const {
QStringList ids;
for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->checkState(0) == Qt::Checked) ids << (*it)->data(0, kDsIdRole).toString();
return ids;
}
void CategorySection::emitChecked() { emit checkedDatasetsChanged(checkedDsIds()); }
void CategorySection::showContextMenu(const QPoint& pos) {
using geopro::render::interact::SliceAxis;
QTreeWidgetItem* it = list_->itemAt(pos);
if (!it) return;
const QString id = it->data(0, kDsIdRole).toString();
const QString ddCode = it->data(0, kDsDdCodeRole).toString();
if (id.isEmpty() || ddCode == QStringLiteral("container")) return; // 容器节点无操作菜单
const QString name = it->data(0, kDsNameRole).toString();
QMenu menu(this);
menu.addAction(QStringLiteral("详情"), this,
[this, id, ddCode, name] { emit detailRequested(id, ddCode, name); });
if (ddCode == QStringLiteral("dd_voxel")) { // 三维体
QMenu* sl = menu.addMenu(QStringLiteral("生成切片")); // id=被右键的三维体 dsId切片建到该体上
sl->addAction(QStringLiteral("上下"), this, [this, id] { emit sliceRequested(SliceAxis::UpDown, id); });
sl->addAction(QStringLiteral("前后"), this, [this, id] { emit sliceRequested(SliceAxis::FrontBack, id); });
sl->addAction(QStringLiteral("左右"), this, [this, id] { emit sliceRequested(SliceAxis::LeftRight, id); });
sl->addAction(QStringLiteral("任意"), this, [this, id] { emit sliceRequested(SliceAxis::Oblique, id); });
menu.addAction(QStringLiteral("色阶…"), this, [this, id] { emit colorScaleRequested(id); });
} else if (ddCode == QStringLiteral("dd_slice")) { // 切片
menu.addAction(QStringLiteral("保存位姿"), this, [this, id] { emit sliceSaveRequested(id); });
menu.addAction(QStringLiteral("另存为…"), this, [this, id] { emit sliceSaveAsRequested(id); });
QMenu* ex = menu.addMenu(QStringLiteral("导出"));
ex->addAction(QStringLiteral("图片"), this, [this, id] { emit sliceExportImageRequested(id); });
ex->addAction(QStringLiteral("dat"), this, [this, id] { emit sliceExportDatRequested(id); });
menu.addAction(QStringLiteral("色阶…"), this, [this, id] { emit colorScaleRequested(id); });
menu.addSeparator();
menu.addAction(QStringLiteral("删除"), this, [this, id, ddCode] { emit deleteDatasetRequested(id, ddCode); });
} else if (ddCode == QStringLiteral("dd_anomaly")) { // 异常
menu.addAction(QStringLiteral("显示"), this, [this, id] { emit anomalyVisibilityChanged(id, true); });
menu.addAction(QStringLiteral("隐藏"), this, [this, id] { emit anomalyVisibilityChanged(id, false); });
menu.addSeparator();
menu.addAction(QStringLiteral("删除"), this, [this, id, ddCode] { emit deleteDatasetRequested(id, ddCode); });
}
menu.exec(list_->viewport()->mapToGlobal(pos));
}
} // namespace geopro::app