#include "panels/columns/CategorySection.hpp" #include #include #include #include #include "panels/columns/DateRangeEdit.hpp" #include #include #include #include #include #include #include #include #include #include #include #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(&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& nodes) { structure_ = nodes; // 容器分层(项目根/GS/TM→ds)在 Task 12 接入真实结构后据此构建。 } void CategorySection::setDatasets(const std::vector& 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 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 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 filtered; filtered.reserve(rows_.size()); for (const auto& r : rows_) if (passesFilters(r)) filtered.push_back(r); // 从项目根的层级树:容器节点(结构,剪枝仅留有 ds 的路径)+ ds(挂 parentId 或 structParentId)。 std::vector display; if (!structure_.empty()) { std::map byId; for (const auto& n : structure_) byId[n.id] = &n; std::set 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 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