304 lines
14 KiB
C++
304 lines
14 KiB
C++
#include "panels/ObjectTreePanel.hpp"
|
||
|
||
#include <QEvent>
|
||
#include <QLabel>
|
||
#include <QMenu>
|
||
#include <QModelIndex>
|
||
#include <QMouseEvent>
|
||
#include <QPoint>
|
||
#include <QSignalBlocker>
|
||
#include <QStringList>
|
||
#include <QStyle>
|
||
#include <QStyleOptionViewItem>
|
||
#include <QTimer>
|
||
#include <QTreeWidget>
|
||
#include <QTreeWidgetItem>
|
||
#include <QVBoxLayout>
|
||
#include <functional>
|
||
|
||
#include "Glyphs.hpp"
|
||
#include "Theme.hpp"
|
||
#include "dto/NavDto.hpp"
|
||
|
||
namespace geopro::app {
|
||
|
||
namespace {
|
||
constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 id(GS/TM 都存)
|
||
constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM
|
||
constexpr int kRoleTypeId = Qt::UserRole + 4; // 类型 id(编辑调 getDynamicForm 用)
|
||
constexpr int kRoleIsRoot = Qt::UserRole + 5; // 项目根标记(按 GS 处理,但仅 新建GS/TM/属性)
|
||
constexpr int kConfTypeGs = 1; // GS(工区)
|
||
constexpr int kConfTypeTm = 2; // TM 叶子
|
||
|
||
// topLevel=true 仅用于项目根:按 GS 处理(xlsx 第32行 + 真实数据 TM 挂根),
|
||
// 携带其 id/typeId,可右键 新建GS/TM/属性;勾选随 2D/3D 批次暂不开放。
|
||
void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes,
|
||
bool topLevel) {
|
||
for (const auto& n : nodes) {
|
||
auto* item = new QTreeWidgetItem(parent);
|
||
item->setText(0, QString::fromStdString(n.node.name));
|
||
item->setData(0, kRoleObjId, QString::fromStdString(n.node.id));
|
||
item->setData(0, kRoleTypeId, QString::fromStdString(n.node.typeId));
|
||
if (topLevel) {
|
||
// 项目根:作为 GS 承载(id 携带),不可勾选;菜单仅 新建GS/TM/属性。
|
||
item->setData(0, kRoleConfType, kConfTypeGs);
|
||
item->setData(0, kRoleIsRoot, true);
|
||
} else if (n.isTm) {
|
||
item->setData(0, kRoleConfType, kConfTypeTm);
|
||
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
|
||
item->setCheckState(0, Qt::Unchecked);
|
||
} else {
|
||
item->setData(0, kRoleConfType, kConfTypeGs); // GS
|
||
item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate);
|
||
item->setCheckState(0, Qt::Unchecked);
|
||
}
|
||
addNodes(item, n.children, false); // 子层永远非顶层
|
||
}
|
||
}
|
||
} // namespace
|
||
|
||
ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) {
|
||
auto* lay = new QVBoxLayout(this);
|
||
lay->setContentsMargins(0, 0, 0, 0);
|
||
lay->setSpacing(0);
|
||
|
||
// Qt 原生标准树:复选框/展开箭头由 Fusion 绘制(明暗都清晰);配色取自全局主题 QSS。
|
||
tree_ = new QTreeWidget(this);
|
||
tree_->setHeaderHidden(true);
|
||
tree_->setIndentation(14); // 收紧缩进
|
||
|
||
lay->addWidget(tree_, 1);
|
||
|
||
hint_ = new QLabel(QStringLiteral("正在加载对象…"), this);
|
||
hint_->setAlignment(Qt::AlignCenter);
|
||
geopro::app::applyTokenizedStyleSheet(hint_, QStringLiteral("color:{{text/disabled}}; padding:16px;"));
|
||
hint_->setVisible(false);
|
||
lay->addWidget(hint_);
|
||
|
||
// viewport 事件过滤:记录鼠标按下是否落在复选框区,用于区分「选中」与「勾选」手势。
|
||
tree_->viewport()->installEventFilter(this);
|
||
|
||
QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) {
|
||
if (pressOnCheckbox_) { // 点的是复选框:只切换勾选态,不当作「选中」(不重载数据集列表)
|
||
pressOnCheckbox_ = false;
|
||
return;
|
||
}
|
||
const bool isRoot = item->data(0, kRoleIsRoot).toBool();
|
||
const QString id = item->data(0, kRoleObjId).toString();
|
||
const int confType = item->data(0, kRoleConfType).toInt();
|
||
if (id.isEmpty() || confType == 0) return;
|
||
const QString typeId = item->data(0, kRoleTypeId).toString();
|
||
// 对象属性面板:项目根也发(携 isRoot=true,面板据此显只读占位)。
|
||
emit objectSelectedForEdit(id, confType, typeId, item->text(0), isRoot);
|
||
if (isRoot) return; // 项目根:不联动数据列表/异常(仅右键操作 + 属性占位)
|
||
emit objectClicked(id, confType);
|
||
});
|
||
// 勾选变化:GS 级联会触发多次 itemChanged,用 0ms 单发合并成一次「收集勾选叶子并发射」。
|
||
QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) {
|
||
if (checkPending_) return;
|
||
checkPending_ = true;
|
||
QTimer::singleShot(0, this, [this]() {
|
||
checkPending_ = false;
|
||
QStringList tmIds;
|
||
std::function<void(QTreeWidgetItem*)> walk = [&](QTreeWidgetItem* node) {
|
||
for (int i = 0; i < node->childCount(); ++i) {
|
||
QTreeWidgetItem* c = node->child(i);
|
||
if (c->data(0, kRoleConfType).toInt() == 2 && c->checkState(0) == Qt::Checked)
|
||
tmIds << c->data(0, kRoleObjId).toString();
|
||
walk(c);
|
||
}
|
||
};
|
||
walk(tree_->invisibleRootItem());
|
||
emit checkedTmsChanged(tmIds);
|
||
});
|
||
});
|
||
|
||
// 右键菜单(对齐菜单文档:显示/隐藏、定位、属性、异常详情、编辑、新建GS/TM、导入DS、删除)。
|
||
// 项目根=新建GS/TM/属性;GS=全项(+新建GS/TM);TM=全项(+导入DS,无新建GS)。
|
||
tree_->setContextMenuPolicy(Qt::CustomContextMenu);
|
||
QObject::connect(tree_, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) {
|
||
QTreeWidgetItem* item = tree_->itemAt(pos);
|
||
if (!item) return;
|
||
const QString id = item->data(0, kRoleObjId).toString();
|
||
const int confType = item->data(0, kRoleConfType).toInt();
|
||
if (id.isEmpty() || confType == 0) return;
|
||
const QString typeId = item->data(0, kRoleTypeId).toString();
|
||
const QString name = item->text(0);
|
||
const bool isRoot = item->data(0, kRoleIsRoot).toBool();
|
||
const bool isGs = (confType == kConfTypeGs);
|
||
const bool isTm = (confType == kConfTypeTm);
|
||
QMenu menu(this);
|
||
auto add = [&](const QString& text, const QString& action) {
|
||
menu.addAction(text, this,
|
||
[this, action, id, confType, typeId, name]() {
|
||
emit contextActionRequested(action, id, confType, typeId, name);
|
||
});
|
||
};
|
||
if (isRoot) {
|
||
// 项目根(按 GS):仅 新建检测对象(GS) / 新建方法对象(TM) / 属性。
|
||
add(QStringLiteral("新建检测对象"), QStringLiteral("newGs"));
|
||
add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
|
||
menu.addSeparator();
|
||
add(QStringLiteral("属性"), QStringLiteral("properties"));
|
||
menu.exec(tree_->viewport()->mapToGlobal(pos));
|
||
return;
|
||
}
|
||
add(QStringLiteral("显示 / 隐藏"), QStringLiteral("showHide"));
|
||
add(QStringLiteral("定位"), QStringLiteral("locate"));
|
||
menu.addSeparator();
|
||
add(QStringLiteral("属性"), QStringLiteral("properties"));
|
||
add(QStringLiteral("异常详情"), QStringLiteral("exceptionDetail"));
|
||
menu.addSeparator();
|
||
add(QStringLiteral("编辑"), QStringLiteral("edit"));
|
||
if (isGs) {
|
||
// GS 节点:新建检测对象 / 新建方法对象。(TM 节点上不显示「新建检测对象」——xlsx:tm 上新建GS 无效。)
|
||
add(QStringLiteral("新建检测对象"), QStringLiteral("newGs"));
|
||
add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
|
||
}
|
||
if (isTm) {
|
||
// TM 节点:仅「新建方法对象」(同级,父=该 TM 的父 GS/根)+ 导入 DS。
|
||
// (xlsx:tm 上新建GS 无效,故不显示「新建检测对象」。)
|
||
add(QStringLiteral("新建方法对象"), QStringLiteral("newTm"));
|
||
add(QStringLiteral("导入数据集…"), QStringLiteral("importDs"));
|
||
}
|
||
menu.addSeparator();
|
||
add(QStringLiteral("删除"), QStringLiteral("delete"));
|
||
menu.exec(tree_->viewport()->mapToGlobal(pos));
|
||
});
|
||
}
|
||
|
||
bool ObjectTreePanel::eventFilter(QObject* watched, QEvent* event) {
|
||
if (tree_ && watched == tree_->viewport() && event->type() == QEvent::MouseButtonPress) {
|
||
auto* me = static_cast<QMouseEvent*>(event);
|
||
const QPoint pos = me->position().toPoint();
|
||
pressOnCheckbox_ = false;
|
||
const QModelIndex idx = tree_->indexAt(pos);
|
||
if (idx.isValid() && (idx.flags() & Qt::ItemIsUserCheckable)) {
|
||
// 用样式计算该项复选框指示区的精确矩形(含缩进偏移由 visualRect 给出)。
|
||
QStyleOptionViewItem opt;
|
||
opt.initFrom(tree_);
|
||
opt.rect = tree_->visualRect(idx);
|
||
opt.features |= QStyleOptionViewItem::HasCheckIndicator;
|
||
const QRect cb =
|
||
tree_->style()->subElementRect(QStyle::SE_ItemViewItemCheckIndicator, &opt, tree_);
|
||
if (cb.contains(pos)) pressOnCheckbox_ = true;
|
||
}
|
||
}
|
||
return QWidget::eventFilter(watched, event);
|
||
}
|
||
|
||
// ── 快速筛选:遍历所有 TM 叶子,对其 setCheckState。批量改用 SignalBlocker 屏蔽逐项 itemChanged,
|
||
// 末尾手动触发一次 itemChanged 让既有 0ms 合并逻辑收集并发射 checkedTmsChanged。──
|
||
void ObjectTreePanel::setAllTmsChecked(bool checked) {
|
||
if (!tree_) return;
|
||
const Qt::CheckState st = checked ? Qt::Checked : Qt::Unchecked;
|
||
std::function<void(QTreeWidgetItem*)> walk = [&](QTreeWidgetItem* node) {
|
||
for (int i = 0; i < node->childCount(); ++i) {
|
||
QTreeWidgetItem* c = node->child(i);
|
||
if (c->data(0, kRoleConfType).toInt() == kConfTypeTm) c->setCheckState(0, st);
|
||
walk(c);
|
||
}
|
||
};
|
||
{
|
||
const QSignalBlocker block(tree_);
|
||
walk(tree_->invisibleRootItem());
|
||
}
|
||
emit tree_->itemChanged(nullptr, 0); // 触发既有合并发射
|
||
}
|
||
|
||
void ObjectTreePanel::invertTmChecks() {
|
||
if (!tree_) return;
|
||
std::function<void(QTreeWidgetItem*)> walk = [&](QTreeWidgetItem* node) {
|
||
for (int i = 0; i < node->childCount(); ++i) {
|
||
QTreeWidgetItem* c = node->child(i);
|
||
if (c->data(0, kRoleConfType).toInt() == kConfTypeTm)
|
||
c->setCheckState(0, c->checkState(0) == Qt::Checked ? Qt::Unchecked : Qt::Checked);
|
||
walk(c);
|
||
}
|
||
};
|
||
{
|
||
const QSignalBlocker block(tree_);
|
||
walk(tree_->invisibleRootItem());
|
||
}
|
||
emit tree_->itemChanged(nullptr, 0);
|
||
}
|
||
|
||
QString ObjectTreePanel::currentParentForNew() const {
|
||
if (!tree_) return {};
|
||
// 新建对象的父必须是 GS/项目根(GS 可挂 GS/根;TM 父恒为 GS/根)。
|
||
// 选中 TM 时不能以 TM 作父,上溯到其最近的 GS/根 祖先。
|
||
if (QTreeWidgetItem* cur = tree_->currentItem()) {
|
||
if (cur->data(0, kRoleConfType).toInt() == kConfTypeTm) {
|
||
for (QTreeWidgetItem* p = cur->parent(); p; p = p->parent()) {
|
||
const QString pid = p->data(0, kRoleObjId).toString();
|
||
if (!pid.isEmpty() && p->data(0, kRoleConfType).toInt() == kConfTypeGs) return pid;
|
||
}
|
||
// TM 无 GS/根祖先(异常)→ 回落项目根
|
||
} else {
|
||
const QString id = cur->data(0, kRoleObjId).toString();
|
||
if (!id.isEmpty()) return id; // GS 或 项目根
|
||
}
|
||
}
|
||
// 未选中或上溯失败:回落到项目根节点(顶层首个,setStructure 保证结构含项目根)。
|
||
if (tree_->topLevelItemCount() > 0)
|
||
return tree_->topLevelItem(0)->data(0, kRoleObjId).toString();
|
||
return {};
|
||
}
|
||
|
||
int ObjectTreePanel::currentSelectedConfType() const {
|
||
if (!tree_) return 0;
|
||
QTreeWidgetItem* cur = tree_->currentItem();
|
||
if (!cur) return 0;
|
||
// 项目根 confType 也存为 GS(1),故 root/GS 同归为 1;TM=2。
|
||
return cur->data(0, kRoleConfType).toInt();
|
||
}
|
||
|
||
QString ObjectTreePanel::parentObjectId(const QString& objectId) const {
|
||
if (!tree_ || objectId.isEmpty()) return {};
|
||
// 按 id 定位树项。
|
||
QTreeWidgetItem* found = nullptr;
|
||
std::function<void(QTreeWidgetItem*)> find = [&](QTreeWidgetItem* node) {
|
||
for (int i = 0; i < node->childCount() && !found; ++i) {
|
||
QTreeWidgetItem* c = node->child(i);
|
||
if (c->data(0, kRoleObjId).toString() == objectId) {
|
||
found = c;
|
||
return;
|
||
}
|
||
find(c);
|
||
}
|
||
};
|
||
find(tree_->invisibleRootItem());
|
||
if (!found) return {};
|
||
// 上溯到最近的 GS/根祖先(口径同 currentParentForNew 的 TM 上溯)。
|
||
for (QTreeWidgetItem* p = found->parent(); p; p = p->parent()) {
|
||
const QString pid = p->data(0, kRoleObjId).toString();
|
||
if (!pid.isEmpty() && p->data(0, kRoleConfType).toInt() == kConfTypeGs) return pid;
|
||
}
|
||
return {};
|
||
}
|
||
|
||
void ObjectTreePanel::setStructure(const QString& projectName,
|
||
const std::vector<data::StructNode>& nodes) {
|
||
const QSignalBlocker block(tree_); // 重建触发 itemChanged,先屏蔽
|
||
tree_->clear();
|
||
const auto roots = data::dto::buildStructTree(nodes);
|
||
if (roots.empty()) {
|
||
showMessage(projectName.isEmpty() ? QStringLiteral("(暂无项目)")
|
||
: QStringLiteral("(该项目暂无结构)"));
|
||
return;
|
||
}
|
||
hint_->setVisible(false);
|
||
tree_->setVisible(true);
|
||
addNodes(tree_->invisibleRootItem(), roots, true); // 结构已含项目根节点,根为非交互容器
|
||
tree_->expandAll();
|
||
}
|
||
|
||
void ObjectTreePanel::showMessage(const QString& message) {
|
||
tree_->clear();
|
||
tree_->setVisible(false);
|
||
hint_->setText(message);
|
||
hint_->setVisible(true);
|
||
}
|
||
|
||
} // namespace geopro::app
|