366 lines
19 KiB
C++
366 lines
19 KiB
C++
#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/§6):chevron + 标题(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 浮到树根平铺(用户实测 bug:ds 没挂在 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
|