diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index 391e134..a4f04a5 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -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) diff --git a/docs/routes.md b/docs/routes.md index c89ed2b..0e7ea3b 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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`,不适配聊天响应或服务商私有结构。说明行明确「图片理解仅支持默认网关」。 diff --git a/docs/tasks/T-683.md b/docs/tasks/T-683.md index 616d9c1..b09010c 100644 --- a/docs/tasks/T-683.md +++ b/docs/tasks/T-683.md @@ -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`。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 23471b6..fa6035e 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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)