From 01697759fc6c45eb1e7591eeb13d774077c28444 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 17 Jul 2026 12:05:57 +0800 Subject: [PATCH] fix(suite): prevent wheel changes in generation settings --- app/gui/tabs/product_suite.py | 46 +++++++++++++++++- docs/routes.md | 1 + docs/tasks/T-652.md | 4 +- tests/test_product_suite_gui.py | 83 +++++++++++++++++++++++++++++++-- 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index 216ed2a..2aabd42 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -12,6 +12,7 @@ from PySide6.QtCore import ( QByteArray, QBuffer, QIODevice, + QPointF, QRect, QSize, Qt, @@ -19,7 +20,16 @@ from PySide6.QtCore import ( Signal, Slot, ) -from PySide6.QtGui import QColor, QIcon, QImage, QImageReader, QKeySequence, QPainter, QPixmap +from PySide6.QtGui import ( + QColor, + QIcon, + QImage, + QImageReader, + QKeySequence, + QPainter, + QPixmap, + QWheelEvent, +) from PySide6.QtWidgets import ( QApplication, QCheckBox, @@ -146,6 +156,38 @@ class AutoHeightPlainTextEdit(QPlainTextEdit): self.schedule_height_update() +class SuiteSettingsComboBox(QComboBox): + """Keep wheel scrolling from silently changing a suite generation setting.""" + + def wheelEvent(self, event): + target = self.view().viewport() if self.view().isVisible() else self._outer_scroll_viewport() + if target is None: + event.ignore() + return + global_position = event.globalPosition() + local_position = target.mapFromGlobal(global_position.toPoint()) + forwarded = QWheelEvent( + QPointF(local_position), + global_position, + event.pixelDelta(), + event.angleDelta(), + event.buttons(), + event.modifiers(), + event.phase(), + event.inverted(), + ) + QApplication.sendEvent(target, forwarded) + event.accept() + + def _outer_scroll_viewport(self): + parent = self.parentWidget() + while parent is not None: + if isinstance(parent, QScrollArea): + return parent.viewport() + parent = parent.parentWidget() + return None + + def _asset_usable(asset): path = str(getattr(asset, "local_path", "") or "") return ( @@ -2173,7 +2215,7 @@ class ProductSuiteTab(QWidget): return frame def _value_combo(self, object_name, label, values): - combo = QComboBox() + combo = SuiteSettingsComboBox() combo.setObjectName(object_name) combo.setToolTip("%s设置" % label) combo.setAccessibleName("%s设置" % label) diff --git a/docs/routes.md b/docs/routes.md index ce7a40c..04b4e57 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -205,6 +205,7 @@ - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。 - 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 +- 平台、站点、语言和比例四个生成设置下拉仅在闭合时拦截自身滚轮改值,并把滚动交给 `suiteConfigScroll`;展开时滚轮只浏览候选列表,不静默提交当前值。鼠标点击、方向键、回车和 `Alt+↓` 保持原有显式选择语义;账号、历史筛选和设置页等其它下拉不受影响。 - 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 - T-648 后,常规「生成套图」在原图、卖点和数量校验通过后、费用确认前,若 SQLite 记录显示当前商品已有成功套图,会出现「已有套图生成记录」确认框:用户可查看仅当前商品的全局历史、继续生成新一轮或取消,默认取消;查看历史和取消都不提交任务,继续仍须通过原费用确认后才创建新轮次。失败图片重试、恢复未完成任务和仅失败/已取消历史不出现该确认。 - T-646 后「历史生成」打开全局非模态「套图历史生成记录」窗口,默认显示所有未删除商品项目最近创建的生成轮次,主结果区不切换。T-649 将店铺筛选固定为首项「全部店铺」的下拉:当前账号显示「账号名(账号别名)」,已删除但仍有历史项目的账号显示「历史店铺:别名(账号已删除)」,选择值使用 `account_alias` 精确查询;商品 ID 关键字和“仅当前商品”可与其叠加,默认不限制当前任务。每一行就是一次正常生成轮次,单张失败重试仍归入原行。行内固定显示时间、店铺/账号、商品 ID、成功/失败/停止/重试统计、最多5张缩略图及余量 `+N`,当前轮标记“当前”,NULL 轮次标记“旧版历史记录”,临时项目显示“临时草稿”。双击缩略图或整行从对应图片打开该轮所有可用图的自适应原尺寸浏览;“导出本轮”后台复制该轮成功且本地存在的图片到用户选择目录下的新安全子目录,不覆盖或修改内部图片。旧版记录、全失败轮和本地文件缺失项保留中文说明;不提供批量导出、删除、重试、切换当前轮或再次生成。重复点击复用同一窗口;关闭任务页不关闭全局窗口,应用退出时正常释放。 diff --git a/docs/tasks/T-652.md b/docs/tasks/T-652.md index 3e523a9..6fc6804 100644 --- a/docs/tasks/T-652.md +++ b/docs/tasks/T-652.md @@ -3,7 +3,7 @@ id: T-652 title: 商品套图生成设置下拉框防滚轮误改 phase: 7 deps: [] -status: TODO +status: DONE created: 2026-07-17 --- @@ -62,3 +62,5 @@ git diff --check ## 执行记录 - 2026-07-17:根据生成设置下拉框滚轮误改问题创建任务。待实现。 +- 2026-07-17:开始实现仅限商品套图四个生成设置下拉框的防滚轮误改交互。 +- 2026-07-17:已新增局部 `SuiteSettingsComboBox`。平台、站点、语言、比例在闭合时把滚轮转交给 `suiteConfigScroll`,展开时转交候选列表;鼠标和键盘选择保持不变。验证:`py -3.10 -m unittest discover -s tests`(578 项通过)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 通过。 diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index d7d54a2..2ece1b9 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -23,10 +23,10 @@ from app import gui if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") -from PySide6.QtCore import QMimeData, Qt, QUrl -from PySide6.QtGui import QIcon, QImage, QPixmap, QTextCursor +from PySide6.QtCore import QMimeData, QPoint, QPointF, Qt, QUrl +from PySide6.QtGui import QIcon, QImage, QPixmap, QTextCursor, QWheelEvent from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton +from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton, QScrollArea from app.gui.tabs.product_suite import ( AutoHeightPlainTextEdit, @@ -73,6 +73,21 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): image.fill(0xFF336699) self.assertTrue(image.save(path)) + def _send_wheel(self, widget, delta=-120): + local_position = widget.rect().center() + global_position = widget.mapToGlobal(local_position) + event = QWheelEvent( + QPointF(local_position), + QPointF(global_position), + QPoint(), + QPoint(0, int(delta)), + Qt.NoButton, + Qt.NoModifier, + Qt.ScrollUpdate, + False, + ) + QApplication.sendEvent(widget, event) + def _create_project_with_assets(self, temp_dir, config, count, item_id="51100639510"): account = accounts.create_account( "主店", @@ -236,6 +251,68 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_suite_setting_combos_forward_closed_wheel_to_config_scroll(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=config) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + tab.resize(620, 280) + tab.show() + self.app.processEvents() + scroll = tab.findChild(QScrollArea, "suiteConfigScroll") + self.assertIsNotNone(scroll) + scroll.widget().setMinimumHeight(scroll.viewport().height() + 400) + self.app.processEvents() + scroll.verticalScrollBar().setValue(0) + original_values = { + combo.objectName(): combo.currentData() + for combo in ( + tab.platform_combo, + tab.country_combo, + tab.language_combo, + tab.ratio_combo, + ) + } + + for combo in ( + tab.platform_combo, + tab.country_combo, + tab.language_combo, + tab.ratio_combo, + ): + self._send_wheel(combo) + self.app.processEvents() + self.assertEqual(original_values[combo.objectName()], combo.currentData()) + + self.assertGreater(scroll.verticalScrollBar().value(), 0) + + combo = tab.country_combo + combo.setCurrentIndex(0) + combo.showPopup() + self.app.processEvents() + self._send_wheel(combo.view()) + self.app.processEvents() + self.assertEqual(0, combo.currentIndex()) + combo.hidePopup() + + combo.showPopup() + self.app.processEvents() + target_index = combo.model().index(1, 0) + target_rect = combo.view().visualRect(target_index) + QTest.mouseClick(combo.view().viewport(), Qt.LeftButton, Qt.NoModifier, target_rect.center()) + self.app.processEvents() + self.assertEqual("新加坡", combo.currentData()) + + ratio_before = tab.ratio_combo.currentIndex() + tab.ratio_combo.setFocus() + QTest.keyClick(tab.ratio_combo, Qt.Key_Down) + self.app.processEvents() + self.assertNotEqual(ratio_before, tab.ratio_combo.currentIndex()) + self.assertEqual(tab.ratio_combo.currentData(), tab._displayed_state.settings["ratio"]) + + self.assert_removed(temp_dir) + def test_prompt_settings_dialog_previews_validates_saves_and_restores(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir)