diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp index b452627..64c2593 100644 --- a/src/app/panels/ObjectTreePanel.cpp +++ b/src/app/panels/ObjectTreePanel.cpp @@ -24,6 +24,7 @@ #include "Glyphs.hpp" #include "Theme.hpp" #include "dto/NavDto.hpp" +#include "panels/ObjectTreeSelection.hpp" namespace geopro::app { @@ -33,6 +34,7 @@ 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 kRoleChildCount = Qt::UserRole + 6; // §6.1 计数徽标:GS 直接子节点数(>0 才显示) +constexpr int kRoleGsDsOn = Qt::UserRole + 7; // GS 自身直挂 ds 开关(bool;三态聚合用,spec §6) constexpr int kConfTypeGs = 1; // GS(工区) constexpr int kConfTypeTm = 2; // TM 叶子 @@ -120,7 +122,9 @@ void addNodes(QTreeWidgetItem* parent, const std::vectorsetIcon(0, makeGlyph(Glyph::SurveyLine, iconColor, iconPx)); } else { item->setData(0, kRoleConfType, kConfTypeGs); // GS - item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); + // 停用 Qt::ItemIsAutoTristate:GS 三态由 recomputeGsState 手动聚合(自身 ds 开关 + 子 TM)。 + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setData(0, kRoleGsDsOn, false); // GS 自身直挂 ds 开关初始关 item->setCheckState(0, Qt::Unchecked); item->setIcon(0, makeGlyph(Glyph::WorkArea, iconColor, iconPx)); } @@ -158,6 +162,23 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) { if (pressOnCheckbox_) { // 点的是复选框:只切换勾选态,不当作「选中」(不重载数据集列表) pressOnCheckbox_ = false; + // GS 复选框点击:基于点击前聚合(dsOn + 子 TM 均未被本次点击改写)翻转全开/全关(spec §6)。 + // Qt 因 ItemIsUserCheckable 已临时 toggle 了 GS 自身 checkState,但随后 recomputeGsState 会覆盖回正确三态。 + if (item->data(0, kRoleConfType).toInt() == kConfTypeGs && !item->data(0, kRoleIsRoot).toBool()) { + const bool dsOn = item->data(0, kRoleGsDsOn).toBool(); + int total = 0, checked = 0; + for (int i = 0; i < item->childCount(); ++i) { + QTreeWidgetItem* c = item->child(i); + if (c->data(0, kRoleConfType).toInt() != kConfTypeTm) continue; + ++total; + if (c->checkState(0) == Qt::Checked) ++checked; + } + const bool turnOn = (aggregateGsState(dsOn, checked, total) == GsCheck::Unchecked); + item->setData(0, kRoleGsDsOn, turnOn); + setAllChildTmChecked(item, turnOn); + recomputeGsState(item); + emitCheckedSources(); + } return; } const bool isRoot = item->data(0, kRoleIsRoot).toBool(); @@ -170,23 +191,24 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { if (isRoot) return; // 项目根:不联动数据列表/异常(仅右键操作 + 属性占位) emit objectClicked(id, confType); }); - // 勾选变化:GS 级联会触发多次 itemChanged,用 0ms 单发合并成一次「收集勾选叶子并发射」。 + // 勾选变化:TM 勾选/GS 级联触发多次 itemChanged,用 0ms 单发合并; + // 合并里先按子 TM 重算每个 GS 三态(手动聚合),再收集勾选源并集发射。 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 walk = [&](QTreeWidgetItem* node) { + std::function recompAll = [&](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); + if (c->data(0, kRoleConfType).toInt() == kConfTypeGs && + !c->data(0, kRoleIsRoot).toBool()) + recomputeGsState(c); // 内部 SignalBlocker,不会再触发 itemChanged + recompAll(c); } }; - walk(tree_->invisibleRootItem()); - emit checkedTmsChanged(tmIds); + recompAll(tree_->invisibleRootItem()); + emitCheckedSources(); }); }); @@ -228,6 +250,29 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { menu.addSeparator(); // 「编辑」弹窗通道已移除:GS/TM 统一走「属性」面板(右上对象属性)就地编辑,避免双编辑入口。 if (isGs) { + // GS「选择」子菜单:分别开关「自身直挂 ds」与「全部子 TM」(spec §6 三态手动维护)。 + QMenu* sel = menu.addMenu(QStringLiteral("选择")); + int tmCount = 0; + for (int i = 0; i < item->childCount(); ++i) + if (item->child(i)->data(0, kRoleConfType).toInt() == kConfTypeTm) ++tmCount; + QAction* dsAct = sel->addAction(QStringLiteral("ds")); + dsAct->setCheckable(true); + dsAct->setChecked(item->data(0, kRoleGsDsOn).toBool()); + QObject::connect(dsAct, &QAction::triggered, this, [this, item](bool on) { + item->setData(0, kRoleGsDsOn, on); + recomputeGsState(item); + emitCheckedSources(); + }); + QAction* tmAct = sel->addAction(QStringLiteral("tm")); + tmAct->setCheckable(true); + tmAct->setChecked(allTmChecked(item)); + tmAct->setEnabled(tmCount > 0); + QObject::connect(tmAct, &QAction::triggered, this, [this, item](bool on) { + setAllChildTmChecked(item, on); + recomputeGsState(item); + emitCheckedSources(); + }); + menu.addSeparator(); // GS 节点:新建检测对象 / 新建方法对象。(TM 节点上不显示「新建检测对象」——xlsx:tm 上新建GS 无效。) add(QStringLiteral("新建检测对象"), QStringLiteral("newGs")); add(QStringLiteral("新建方法对象"), QStringLiteral("newTm")); @@ -262,6 +307,81 @@ bool ObjectTreePanel::eventFilter(QObject* watched, QEvent* event) { return QWidget::eventFilter(watched, event); } +// ── GS 三态手动维护(spec §6):停用 AutoTristate 后,GS 复选框状态完全由此派生。── +void ObjectTreePanel::recomputeGsState(QTreeWidgetItem* gs) { + if (!gs || gs->data(0, kRoleConfType).toInt() != kConfTypeGs || gs->data(0, kRoleIsRoot).toBool()) + return; + const bool dsOn = gs->data(0, kRoleGsDsOn).toBool(); + int total = 0, checked = 0; + for (int i = 0; i < gs->childCount(); ++i) { + QTreeWidgetItem* c = gs->child(i); + if (c->data(0, kRoleConfType).toInt() != kConfTypeTm) continue; + ++total; + if (c->checkState(0) == Qt::Checked) ++checked; + } + const GsCheck s = aggregateGsState(dsOn, checked, total); + const QSignalBlocker block(tree_); // setCheckState 不再触发 itemChanged 递归 + gs->setCheckState(0, s == GsCheck::Checked ? Qt::Checked + : s == GsCheck::Partial ? Qt::PartiallyChecked + : Qt::Unchecked); +} + +void ObjectTreePanel::setAllChildTmChecked(QTreeWidgetItem* gs, bool checked) { + if (!gs) return; + const Qt::CheckState st = checked ? Qt::Checked : Qt::Unchecked; + const QSignalBlocker block(tree_); + for (int i = 0; i < gs->childCount(); ++i) { + QTreeWidgetItem* c = gs->child(i); + if (c->data(0, kRoleConfType).toInt() == kConfTypeTm) c->setCheckState(0, st); + } +} + +bool ObjectTreePanel::allTmChecked(QTreeWidgetItem* gs) const { + if (!gs) return false; + int total = 0, checked = 0; + for (int i = 0; i < gs->childCount(); ++i) { + QTreeWidgetItem* c = gs->child(i); + if (c->data(0, kRoleConfType).toInt() != kConfTypeTm) continue; + ++total; + if (c->checkState(0) == Qt::Checked) ++checked; + } + return total > 0 && checked == total; +} + +// 收集勾选源并集:勾选的 TM(confType=2) + ds 开的 GS(confType=1) + 项目根直挂 ds(固定纳入), +// 按 {id,confType} 去重,发 checkedSourcesChanged;兼发旧 checkedTmsChanged(Task 12 删)。 +void ObjectTreePanel::emitCheckedSources() { + if (!tree_) return; + std::vector src; + QStringList tmIds; + std::function walk = [&](QTreeWidgetItem* node) { + for (int i = 0; i < node->childCount(); ++i) { + QTreeWidgetItem* c = node->child(i); + const int ct = c->data(0, kRoleConfType).toInt(); + if (ct == kConfTypeTm && c->checkState(0) == Qt::Checked) { + const QString id = c->data(0, kRoleObjId).toString(); + src.push_back({id.toStdString(), kConfTypeTm}); + tmIds << id; + } + if (ct == kConfTypeGs && !c->data(0, kRoleIsRoot).toBool() && c->data(0, kRoleGsDsOn).toBool()) + src.push_back({c->data(0, kRoleObjId).toString().toStdString(), kConfTypeGs}); + walk(c); + } + }; + walk(tree_->invisibleRootItem()); + // 项目根直挂 ds 固定纳入(spec §6:项目根无复选框、直挂 ds 固定显示)。 + if (tree_->topLevelItemCount() > 0) { + QTreeWidgetItem* root = tree_->topLevelItem(0); + if (root->data(0, kRoleIsRoot).toBool()) + src.push_back({root->data(0, kRoleObjId).toString().toStdString(), kConfTypeGs}); + } + const auto deduped = dedupeSources(std::move(src)); + QList list; + for (const auto& s : deduped) list.push_back(s); + emit checkedSourcesChanged(list); + emit checkedTmsChanged(tmIds); +} + // ── 快速筛选:遍历所有 TM 叶子,对其 setCheckState。批量改用 SignalBlocker 屏蔽逐项 itemChanged, // 末尾手动触发一次 itemChanged 让既有 0ms 合并逻辑收集并发射 checkedTmsChanged。── void ObjectTreePanel::setAllTmsChecked(bool checked) { diff --git a/src/app/panels/ObjectTreePanel.hpp b/src/app/panels/ObjectTreePanel.hpp index e94ea59..1675f49 100644 --- a/src/app/panels/ObjectTreePanel.hpp +++ b/src/app/panels/ObjectTreePanel.hpp @@ -1,10 +1,12 @@ #pragma once +#include #include #include #include #include "repo/RepoTypes.hpp" class QTreeWidget; +class QTreeWidgetItem; class QLabel; namespace geopro::app { @@ -46,13 +48,22 @@ signals: // 单击行(含项目根,带 typeId/name/isRoot):驱动对象属性面板的可编辑表单。 void objectSelectedForEdit(const QString& objectId, int confType, const QString& typeId, const QString& name, bool isRoot); - // 当前全部被勾选的 TM 叶子 id(已合并发射)。 + // 当前全部被勾选的 TM 叶子 id(已合并发射)。【旧信号,Task 12 接线切换后删除】 void checkedTmsChanged(const QStringList& tmObjectIds); + // 当前全部被勾选的数据源并集(TM + GS 自身 ds 开 + 项目根直挂 ds),按 {id,confType} 去重(spec §6)。 + void checkedSourcesChanged(const QList& sources); // 右键菜单动作(action 取值见 .cpp;objectId/confType/typeId 为右键命中项,name 用于确认框/标题)。 void contextActionRequested(const QString& action, const QString& objectId, int confType, const QString& typeId, const QString& name); private: + // GS 三态(停用 Qt::ItemIsAutoTristate,手动维护):据自身 ds 开关 + 子 TM 勾选聚合(spec §6)。 + void recomputeGsState(QTreeWidgetItem* gs); + void setAllChildTmChecked(QTreeWidgetItem* gs, bool checked); // 批量设子 TM(内部 SignalBlocker) + bool allTmChecked(QTreeWidgetItem* gs) const; // GS 下子 TM 是否全勾(无子 TM=false) + // 收集勾选源并集(TM + GS 自身 ds + 项目根直挂 ds),去重后发 checkedSourcesChanged(兼发旧 checkedTmsChanged)。 + void emitCheckedSources(); + QTreeWidget* tree_ = nullptr; // Qt 原生标准树(复选框/箭头由 Fusion 绘制,清晰可控) QLabel* hint_ = nullptr; bool checkPending_ = false; // 勾选合并发射防重入 diff --git a/src/app/panels/ObjectTreeSelection.hpp b/src/app/panels/ObjectTreeSelection.hpp new file mode 100644 index 0000000..56a687a --- /dev/null +++ b/src/app/panels/ObjectTreeSelection.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::app { + +enum class GsCheck { Unchecked, Partial, Checked }; + +// GS 复选框三态 = [自身 ds 开关] ∨ [子 TM 勾选] 的聚合(spec §6)。 +// 无子 TM(totalTmCount==0)时退化为仅看 dsOn。 +inline GsCheck aggregateGsState(bool dsOn, int checkedTmCount, int totalTmCount) { + const bool anyOn = dsOn || checkedTmCount > 0; + if (!anyOn) return GsCheck::Unchecked; + const bool tmAll = (totalTmCount == 0) || (checkedTmCount == totalTmCount); + if (dsOn && tmAll) return GsCheck::Checked; + return GsCheck::Partial; +} + +// 对象树勾选产出的数据源并集按 {id,confType} 去重保序(spec §6)。 +inline std::vector dedupeSources(std::vector in) { + std::vector out; + for (const auto& s : in) { + bool dup = false; + for (const auto& o : out) + if (o.id == s.id && o.confType == s.confType) { dup = true; break; } + if (!dup) out.push_back(s); + } + return out; +} + +} // namespace geopro::app diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index ea4c366..872f983 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -21,6 +21,9 @@ struct DsRow { int structParentConfType = 0; // 1=GS/项目根 2=TM }; struct DsPage { std::vector rows; int total = 0; }; + +// 对象树勾选产出的数据源(spec §6)。confType: 1=GS/项目根, 2=TM。 +struct DataSource { std::string id; int confType = 0; }; struct TmNode { std::string id, name, confCode; std::vector dss; }; struct GsNode { std::string id, name; std::vector tms; }; struct Project { std::string id, name; std::vector gss; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d94d7f7..0a5198d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -174,6 +174,8 @@ target_sources(geopro_tests PRIVATE app/test_dataset_category.cpp ${CMAKE_SOURCE_DIR}/src/app/DatasetCategory.cpp ) +# 对象树勾选纯逻辑(GS 三态聚合 aggregateGsState + 勾选源去重 dedupeSources,header-only)。 +target_sources(geopro_tests PRIVATE app/test_object_tree_selection.cpp) # measurement 散点纯逻辑(值类型变换 / 显隐 id 收集 / 过滤体 / 另存体,Qt6::Core JSON + core model)。 target_sources(geopro_tests PRIVATE app/test_scatter_data_ops.cpp diff --git a/tests/app/test_object_tree_selection.cpp b/tests/app/test_object_tree_selection.cpp new file mode 100644 index 0000000..32372ad --- /dev/null +++ b/tests/app/test_object_tree_selection.cpp @@ -0,0 +1,37 @@ +#include +#include "panels/ObjectTreeSelection.hpp" +using namespace geopro::app; +using geopro::data::DataSource; + +TEST(AggregateGsState, AllOnIsChecked) { + EXPECT_EQ(aggregateGsState(true, 3, 3), GsCheck::Checked); +} +TEST(AggregateGsState, AllOffIsUnchecked) { + EXPECT_EQ(aggregateGsState(false, 0, 3), GsCheck::Unchecked); +} +TEST(AggregateGsState, DsOnTmNoneIsPartial) { + EXPECT_EQ(aggregateGsState(true, 0, 3), GsCheck::Partial); // 只 GS 自身 ds +} +TEST(AggregateGsState, DsOffSomeTmIsPartial) { + EXPECT_EQ(aggregateGsState(false, 1, 3), GsCheck::Partial); // 部分子 TM +} +TEST(AggregateGsState, DsOnSomeTmIsPartial) { + EXPECT_EQ(aggregateGsState(true, 2, 3), GsCheck::Partial); // ds 开但 TM 未满 +} +TEST(AggregateGsState, NoTmFallsBackToDsOnly) { + EXPECT_EQ(aggregateGsState(true, 0, 0), GsCheck::Checked); // 无子 TM → 仅看 ds 开关 + EXPECT_EQ(aggregateGsState(false, 0, 0), GsCheck::Unchecked); +} + +TEST(DedupeSources, RemovesDuplicateByIdAndConfType) { + std::vector in = {{"t1", 2}, {"g1", 1}, {"t1", 2}, {"g1", 1}, {"t2", 2}}; + const auto out = dedupeSources(in); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[0].id, "t1"); EXPECT_EQ(out[0].confType, 2); + EXPECT_EQ(out[1].id, "g1"); EXPECT_EQ(out[1].confType, 1); + EXPECT_EQ(out[2].id, "t2"); +} +TEST(DedupeSources, SameIdDifferentConfTypeKept) { + std::vector in = {{"x", 1}, {"x", 2}}; + EXPECT_EQ(dedupeSources(in).size(), 2u); +}