feat(settings): prevent spinbox wheel changes

This commit is contained in:
chengma
2026-07-21 09:40:53 +08:00
parent 9c0007ca4c
commit b324e5d332
4 changed files with 136 additions and 17 deletions
+47 -12
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
from contextlib import contextmanager
import os
from PySide6.QtCore import Signal
from PySide6.QtCore import QPointF, Signal
from PySide6.QtGui import QWheelEvent
from ... import ai as ai_module
from ... import chrome
@@ -28,6 +29,39 @@ def AIModelTestWorker(*args, **kwargs):
def CMHubSettingsWorker(*args, **kwargs):
return _call_package_attr("CMHubSettingsWorker", _RealCMHubSettingsWorker, *args, **kwargs)
class SettingsSpinBox(QSpinBox):
"""Forward wheel input to the settings scroll area instead of changing a value."""
def wheelEvent(self, event):
target = 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
class SettingsTab(QWidget):
"""Tab 5: AI model definitions stored in data/config/ai_models.json."""
@@ -117,7 +151,7 @@ class SettingsTab(QWidget):
self.cmhub_image_alias_combo.setObjectName("cmhubImageAliasCombo")
self.cmhub_vision_alias_combo = QComboBox()
self.cmhub_vision_alias_combo.setObjectName("cmhubVisionAliasCombo")
self.cmhub_connect_timeout_spin = QSpinBox()
self.cmhub_connect_timeout_spin = SettingsSpinBox()
self.cmhub_connect_timeout_spin.setObjectName("cmhubConnectTimeoutSpin")
self.cmhub_connect_timeout_spin.setRange(1, 3600)
self.cmhub_connect_timeout_spin.setValue(appconfig.CMHUB_CONNECT_TIMEOUT_DEFAULT)
@@ -161,7 +195,7 @@ class SettingsTab(QWidget):
self.api_key_edit = QLineEdit()
self.api_key_edit.setObjectName("modelApiKeyEdit")
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.connect_timeout_spin = QSpinBox()
self.connect_timeout_spin = SettingsSpinBox()
self.connect_timeout_spin.setObjectName("connectTimeoutSpin")
self.connect_timeout_spin.setRange(1, 3600)
self.connect_timeout_spin.setValue(30)
@@ -173,19 +207,19 @@ class SettingsTab(QWidget):
self.default_text_model_combo.setObjectName("defaultTextModelCombo")
self.default_image_model_combo = QComboBox()
self.default_image_model_combo.setObjectName("defaultImageModelCombo")
self.title_concurrency_spin = QSpinBox()
self.title_concurrency_spin = SettingsSpinBox()
self.title_concurrency_spin.setObjectName("titleConcurrencySpin")
self.title_concurrency_spin.setRange(
appconfig.AI_CONCURRENCY_MIN,
appconfig.AI_CONCURRENCY_MAX,
)
self.image_concurrency_spin = QSpinBox()
self.image_concurrency_spin = SettingsSpinBox()
self.image_concurrency_spin.setObjectName("imageConcurrencySpin")
self.image_concurrency_spin.setRange(
appconfig.AI_CONCURRENCY_MIN,
appconfig.AI_CONCURRENCY_MAX,
)
self.retry_spin = QSpinBox()
self.retry_spin = SettingsSpinBox()
self.retry_spin.setObjectName("retrySpin")
self.retry_spin.setRange(appconfig.AI_RETRY_MIN, appconfig.AI_RETRY_MAX)
self.resolution_combo = QComboBox()
@@ -219,16 +253,16 @@ class SettingsTab(QWidget):
self.db_path_edit.setObjectName("dbPathEdit")
self.db_path_edit.setEnabled(False)
self.db_path_edit.setVisible(False)
self.default_debug_port_spin = QSpinBox()
self.default_debug_port_spin = SettingsSpinBox()
self.default_debug_port_spin.setObjectName("defaultDebugPortSpin")
self.default_debug_port_spin.setRange(1, 65535)
self.debug_port_start_spin = QSpinBox()
self.debug_port_start_spin = SettingsSpinBox()
self.debug_port_start_spin.setObjectName("debugPortStartSpin")
self.debug_port_start_spin.setRange(1, 65535)
self.debug_port_end_spin = QSpinBox()
self.debug_port_end_spin = SettingsSpinBox()
self.debug_port_end_spin.setObjectName("debugPortEndSpin")
self.debug_port_end_spin.setRange(1, 65535)
self.cdp_ready_timeout_spin = QSpinBox()
self.cdp_ready_timeout_spin = SettingsSpinBox()
self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin")
self.cdp_ready_timeout_spin.setRange(1, 3600)
self.save_config_button = QPushButton("保存设置")
@@ -236,11 +270,11 @@ class SettingsTab(QWidget):
self.unsaved_changes_label.setObjectName("settingsUnsavedChangesLabel")
self.unsaved_changes_label.setStyleSheet("color: #bc4c00; font-weight: 600;")
self.unsaved_changes_label.setVisible(False)
self.max_items_per_run_spin = QSpinBox()
self.max_items_per_run_spin = SettingsSpinBox()
self.max_items_per_run_spin.setObjectName("maxItemsPerRunSpin")
self.max_items_per_run_spin.setRange(1, 9999)
self.max_items_per_run_spin.setToolTip("作为每批最大更新条数;正式更新会分批处理当前筛选全部可更新记录。")
self.max_parallel_accounts_spin = QSpinBox()
self.max_parallel_accounts_spin = SettingsSpinBox()
self.max_parallel_accounts_spin.setObjectName("maxParallelAccountsSpin")
self.max_parallel_accounts_spin.setRange(
appconfig.SHOPEE_PARALLEL_ACCOUNTS_MIN,
@@ -409,6 +443,7 @@ class SettingsTab(QWidget):
panel_layout.addStretch(1)
scroll = QScrollArea()
self.settings_scroll_area = scroll
scroll.setWidgetResizable(True)
scroll_content = QWidget()
scroll_layout = QHBoxLayout(scroll_content)
+1 -1
View File
@@ -170,7 +170,7 @@
## 设置
- 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。
- 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。设置页所有数字微调框禁用滚轮改值,滚动输入交给外层设置页,仍可通过箭头、键盘和直接输入修改。
- 生成网关(T-677/T-680):在原「AI 模型配置」标题下使用同行标准单选框「默认网关 / 自定义网关」替代历史「AI 后端」下拉和不明显的分段按钮;启动/放弃未保存更改时按已保存的 `ai.backend=cmhub/direct` 自动勾选对应项,圆点与焦点状态表达当前选择。切换只改变待保存状态并立即切换原有面板,点击「保存设置」才写入配置;默认网关的 `data/config/cmhub.json` 与自定义模型的 `data/config/ai_models.json` 始终并存,模型新增、保存、删除仍立即写盘,不受来源待保存语义影响。除这两个选择控件外,不改变设置页其它组件、表单布局或业务逻辑。
- 默认网关面板展示 Base URL、API Key、生文/生图/图片理解别名、连接超时、批量生成前检查余额、刷新别名和测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前规整掉 `/api`、`/api/v1` 或其它路径。测试连接成功优先显示 `账号「<账号名>」连接默认网关成功`,邮箱脱敏;没有账号信息时显示「默认网关连接成功」。Key 只本地明文保存、UI 打码显示,不进入日志或导出。
- 自定义网关复用既有模型列表、其下紧凑显示的模型配置字段、默认文本模型与默认图像模型;不再显示重复的“模型详情”标题,模型选择区到配置表单保留 8px 间距。文本模型保留 `chat` / `auto`;图片模型固定使用「OpenAI 图片编辑接口」(`images_edits`),②和⑥通过 `/v1/images/edits` 以有序重复 `image[]` 上传,第一张是商品主体图、每次只请求 `n=1` 输出。历史 `chat` / `auto` 图片模型不会被改写或隐藏,但会标示「图片接口不支持」,生成前会被拦截。图片响应只接受 `data[].b64_json` / `data[].url`,不适配聊天响应或服务商私有结构。说明行明确「图片理解仅支持默认网关」。
+5 -2
View File
@@ -3,7 +3,7 @@ id: T-683
title: 禁用设置页数字微调框滚轮改值
phase: 5
deps: [T-682]
status: TODO
status: DONE
created: 2026-07-21
---
@@ -54,4 +54,7 @@ git diff --check
## 执行记录
- 待实现。
- 新增设置页专用 `SettingsSpinBox`,其滚轮事件不调用数值步进,而是按原始位置、增量和修饰键转发给最近的 `QScrollArea` 视口;设置页保留 `settings_scroll_area` 引用供明确的交互回归。
- 设置页全部 11 个数字微调框均替换为该组件:两类连接超时、标题/图片并发、重试、三个端口、Chrome 就绪超时、每批最大更新条数和同时更新蝦皮账号。箭头、键盘与直接输入未修改。
- 更新设置页交互说明和 GUI 回归:确认全部数字框都是专用组件,滚轮后数值及 dirty 状态不变、外层页面继续滚动,键盘上箭头仍会改值并进入未保存状态。
- 验证通过:`py -3.10 -m unittest tests.test_gui`(213 项)、`py -3.10 -m unittest discover -s tests`(654 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
+83 -2
View File
@@ -29,8 +29,8 @@ from app import (
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect, QSize, QTimer, Qt
from PySide6.QtGui import QImage, QKeyEvent, QTextCursor
from PySide6.QtCore import QItemSelectionModel, QModelIndex, QPoint, QPointF, QRect, QSize, QTimer, Qt
from PySide6.QtGui import QImage, QKeyEvent, QTextCursor, QWheelEvent
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QAbstractItemView,
@@ -43,6 +43,7 @@ from PySide6.QtWidgets import (
QProgressBar,
QPushButton,
QRadioButton,
QSpinBox,
QTableView,
)
@@ -74,6 +75,7 @@ from app.gui import file_manager
import app.gui.workers as gui_workers
from app.gui.tabs.collect import _format_collect_elapsed
from app.gui.tabs.generate import CoverGalleryDialog, OriginalImageDialog
from app.gui.tabs.settings import SettingsSpinBox
from app.gui.main_window import _fit_and_center_window
@@ -3189,6 +3191,85 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_settings_spin_boxes_forward_wheel_to_page_scroll(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
cfg["ai"] = appconfig.default_config()["ai"]
tab = SettingsTab(
config=cfg,
config_path=cfg["config_path"],
ai_models_path=cfg["ai_models_path"],
)
self.addCleanup(tab.close)
tab.resize(900, 360)
tab.show()
self.app.processEvents()
spin_boxes = (
tab.cmhub_connect_timeout_spin,
tab.connect_timeout_spin,
tab.title_concurrency_spin,
tab.image_concurrency_spin,
tab.retry_spin,
tab.default_debug_port_spin,
tab.debug_port_start_spin,
tab.debug_port_end_spin,
tab.cdp_ready_timeout_spin,
tab.max_items_per_run_spin,
tab.max_parallel_accounts_spin,
)
self.assertEqual(set(spin_boxes), set(tab.findChildren(QSpinBox)))
self.assertTrue(all(isinstance(spin, SettingsSpinBox) for spin in spin_boxes))
values = {spin: spin.value() for spin in spin_boxes}
tab._set_dirty(False)
for spin in spin_boxes:
local_position = spin.rect().center()
global_position = spin.mapToGlobal(local_position)
event = QWheelEvent(
QPointF(local_position),
QPointF(global_position),
QPoint(),
QPoint(0, -120),
Qt.NoButton,
Qt.NoModifier,
Qt.ScrollUpdate,
False,
)
QApplication.sendEvent(spin, event)
self.assertEqual(values[spin], spin.value())
self.assertFalse(tab.is_dirty())
scroll_bar = tab.settings_scroll_area.verticalScrollBar()
self.assertGreater(scroll_bar.maximum(), 0)
scroll_bar.setValue(0)
spin = tab.title_concurrency_spin
local_position = spin.rect().center()
global_position = spin.mapToGlobal(local_position)
QApplication.sendEvent(
spin,
QWheelEvent(
QPointF(local_position),
QPointF(global_position),
QPoint(),
QPoint(0, -120),
Qt.NoButton,
Qt.NoModifier,
Qt.ScrollUpdate,
False,
),
)
self.app.processEvents()
self.assertGreater(scroll_bar.value(), 0)
before = tab.title_concurrency_spin.value()
tab.title_concurrency_spin.setFocus()
QTest.keyClick(tab.title_concurrency_spin, Qt.Key_Up)
self.assertEqual(before + 1, tab.title_concurrency_spin.value())
self.assertTrue(tab.is_dirty())
self.assert_removed(temp_dir)
def test_settings_tab_cmhub_alias_refresh_filters_unpriced_and_keeps_saved(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)