feat(settings): prevent spinbox wheel changes
This commit is contained in:
+47
-12
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user