From 2d2c6c839d1cdeb623c20acbcd82202e6ec74e22 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 18 Jun 2026 11:09:29 +0800 Subject: [PATCH] feat: settings dialog for update config (gear entry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A low-key "⚙ 配置" button at the right end of the flow-tab row opens a settings dialog to edit update_source / update_user / update_pass, with a test-connection button that distinguishes "reachable & up to date" from "cannot connect". - settings_dialog.py: pure-UI QDialog; password mask + show; test runs off the UI thread via load_manifest and reports the result - update_service: add load_manifest() (raises on failure, unlike check_for_update) - main_window: gear button, _open_settings persists via save_config and re-runs the update check; _update_found connected once to avoid duplicate banners - tests: +2 for load_manifest Co-Authored-By: Claude Opus 4.8 --- src/app/main_window.py | 45 ++++++++- src/app/widgets/settings_dialog.py | 146 +++++++++++++++++++++++++++++ src/services/update_service.py | 12 +++ tasks.md | 19 ++-- tests/test_update_service.py | 11 +++ 5 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 src/app/widgets/settings_dialog.py diff --git a/src/app/main_window.py b/src/app/main_window.py index 3770378..79fa8ff 100644 --- a/src/app/main_window.py +++ b/src/app/main_window.py @@ -6,6 +6,7 @@ from pathlib import Path from PySide6.QtCore import Qt, QUrl, Signal from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import ( + QDialog, QHBoxLayout, QLabel, QMainWindow, @@ -26,6 +27,7 @@ from app.widgets.export_panel import ExportPanel from app.widgets.image_canvas import ImageCanvas from app.widgets.image_list_panel import ImageListPanel from app.widgets.queue_panel import QueuePanel +from app.widgets.settings_dialog import SettingsDialog from app.widgets.template_panel import TemplatePanel from app.widgets.transform_panel import TransformPanel @@ -108,8 +110,34 @@ class MainWindow(QMainWindow): layout.addWidget(self._tab_bar) layout.addStretch() + # Right-aligned utility entry — not a numbered workflow step (docs/07 §4.3) + self._settings_btn = QPushButton("⚙ 配置") + self._settings_btn.setObjectName("settingsBtn") + self._settings_btn.setCursor(Qt.PointingHandCursor) + self._settings_btn.clicked.connect(self._open_settings) + layout.addWidget(self._settings_btn) + return container + def _open_settings(self): + """Open the settings dialog; persist + re-check on save (docs/07 §4.4).""" + dlg = SettingsDialog( + self, + update_source=self._config.get("update_source", ""), + update_user=self._config.get("update_user", ""), + update_pass=self._config.get("update_pass", ""), + current_version=APP_VERSION, + ) + if dlg.exec() == QDialog.Accepted: + self._config.update(dlg.values()) + save_config(self._config) + self._refresh_update_check() + + def _refresh_update_check(self): + """Re-run the update check after the source/credentials changed.""" + self._update_banner.setVisible(False) + self._start_update_check() + # ------------------------------------------------------------------ # Update notification (stage 2: notify-only, see docs/10-lan-update.md) # ------------------------------------------------------------------ @@ -117,6 +145,7 @@ class MainWindow(QMainWindow): def _create_update_banner(self): """A thin info bar shown when a newer version is found. Hidden by default.""" self._update_info = None + self._update_found.connect(self._on_update_found) # connect once bar = QWidget() bar.setObjectName("updateBanner") @@ -149,13 +178,16 @@ class MainWindow(QMainWindow): return bar def _start_update_check(self): - """Check the configured update source for a newer version, off the UI thread.""" + """Check the configured update source for a newer version, off the UI thread. + + Safe to call again after the settings change; the result signal is + connected once in _create_update_banner, not here. + """ source = self._config.get("update_source", "") if not source: return user = self._config.get("update_user", "") password = self._config.get("update_pass", "") - self._update_found.connect(self._on_update_found) def worker(): try: @@ -636,6 +668,15 @@ class MainWindow(QMainWindow): QTabBar#flowTabBar::tab:disabled { color: #bbbbbb; } + #settingsBtn { + font-family: "Microsoft YaHei", "Segoe UI", sans-serif; + font-size: 12px; + color: #666666; + background: transparent; + border: none; + padding: 4px 14px; + } + #settingsBtn:hover { color: #0078d4; background: #e8f0fb; } /* Work splitter */ QSplitter#workSplitter::handle { diff --git a/src/app/widgets/settings_dialog.py b/src/app/widgets/settings_dialog.py new file mode 100644 index 0000000..9066138 --- /dev/null +++ b/src/app/widgets/settings_dialog.py @@ -0,0 +1,146 @@ +"""Settings dialog: configure the online-update source and credentials. + +Pure UI — it never reads or writes the config file. The caller (main_window) +injects the current values and persists the result via save_config +(docs/05 §4.12, docs/07 §4.4). +""" +import logging +import threading + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, +) + +from version import APP_VERSION +from services.update_service import is_newer, load_manifest + +logger = logging.getLogger(__name__) + + +class SettingsDialog(QDialog): + """Edit update_source / update_user / update_pass with a connection test.""" + + _test_done = Signal(str) # result message, delivered to the UI thread + + def __init__(self, parent=None, *, update_source="", update_user="", + update_pass="", current_version=APP_VERSION): + super().__init__(parent) + self.setWindowTitle("设置") + self.setMinimumWidth(420) + self._current_version = current_version + self._setup_ui(update_source, update_user, update_pass) + self._test_done.connect(self._on_test_done) + + # ── UI ─────────────────────────────────────────────────────────────────── + + def _setup_ui(self, source, user, password): + col = QVBoxLayout(self) + col.setSpacing(10) + + form = QFormLayout() + form.setLabelAlignment(Qt.AlignRight) + + self._source_edit = QLineEdit(source) + self._source_edit.setPlaceholderText("http://主机名/ 或 manifest.json 地址") + form.addRow("更新地址", self._source_edit) + + self._user_edit = QLineEdit(user) + form.addRow("账号", self._user_edit) + + self._pass_edit = QLineEdit(password) + self._pass_edit.setEchoMode(QLineEdit.Password) + show_btn = QPushButton("显示") + show_btn.setCheckable(True) + show_btn.setFixedWidth(48) + show_btn.toggled.connect(self._toggle_password) + pass_row = QHBoxLayout() + pass_row.setContentsMargins(0, 0, 0, 0) + pass_row.addWidget(self._pass_edit, 1) + pass_row.addWidget(show_btn) + form.addRow("密码", pass_row) + + col.addLayout(form) + + # version + test row + test_row = QHBoxLayout() + test_row.addWidget(QLabel("当前版本 v{}".format(self._current_version))) + test_row.addStretch() + self._test_btn = QPushButton("测试连接") + self._test_btn.clicked.connect(self._on_test) + test_row.addWidget(self._test_btn) + col.addLayout(test_row) + + self._result = QLabel("") + self._result.setObjectName("settingsResult") + self._result.setWordWrap(True) + col.addWidget(self._result) + + hint = QLabel("提示:建议使用只读账号;生产环境请走 HTTPS(凭据为明文存储)。") + hint.setObjectName("settingsHint") + hint.setWordWrap(True) + col.addWidget(hint) + + buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + buttons.button(QDialogButtonBox.Save).setText("保存") + buttons.button(QDialogButtonBox.Cancel).setText("取消") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + col.addWidget(buttons) + + self.setStyleSheet(""" + #settingsHint { font-size: 11px; color: #999999; } + #settingsResult { font-size: 12px; color: #555555; } + """) + + def _toggle_password(self, shown): + self._pass_edit.setEchoMode(QLineEdit.Normal if shown else QLineEdit.Password) + + # ── public API ─────────────────────────────────────────────────────────── + + def values(self): + """Return the edited config values (caller persists them).""" + return { + "update_source": self._source_edit.text().strip(), + "update_user": self._user_edit.text(), + "update_pass": self._pass_edit.text(), + } + + # ── connection test (off the UI thread) ────────────────────────────────── + + def _on_test(self): + source = self._source_edit.text().strip() + if not source: + self._result.setText("请先填写更新地址。") + return + user, password = self._user_edit.text(), self._pass_edit.text() + self._test_btn.setEnabled(False) + self._result.setText("正在测试…") + + def worker(): + try: + data = load_manifest(source, user, password) + remote = str((data or {}).get("version", "")).strip() + if not remote: + msg = "连接成功,但清单缺少版本号。" + elif is_newer(remote, self._current_version): + msg = "发现新版本 v{}(当前 v{})。".format(remote, self._current_version) + else: + msg = "连接成功,已是最新(远端 v{})。".format(remote) + except Exception as exc: # surface any reachability/auth/format error + logger.info("Settings test failed: %s", exc) + msg = "连接失败:{}".format(exc) + self._test_done.emit(msg) + + threading.Thread(target=worker, name="settings-test", daemon=True).start() + + def _on_test_done(self, msg): + self._result.setText(msg) + self._test_btn.setEnabled(True) diff --git a/src/services/update_service.py b/src/services/update_service.py index a553abb..75df039 100644 --- a/src/services/update_service.py +++ b/src/services/update_service.py @@ -119,6 +119,18 @@ def _load_local_manifest(update_source): return json.load(f) +def load_manifest(update_source, update_user="", update_pass=""): + """Fetch and parse the manifest dict, RAISING on failure. + + Unlike check_for_update (which swallows errors into None), this surfaces + reachability/auth/format errors — used by the settings "test connection" + button to distinguish "reachable & up to date" from "cannot connect". + """ + if _is_http_source(update_source): + return _load_http_manifest(update_source, update_user, update_pass) + return _load_local_manifest(update_source) + + def check_for_update( update_source, current_version, diff --git a/tasks.md b/tasks.md index 42f8155..9ebbbd7 100644 --- a/tasks.md +++ b/tasks.md @@ -953,18 +953,19 @@ 任务: - [x] 文档:`docs/07` §3 布局、§4.1 去掉「暂不提供设置入口」、新增 §4.3 入口 + §4.4 对话框 -- [ ] 页签栏右端加低调 `⚙ 配置` 按钮(`addStretch` 与编号页签隔开,非编号页签) -- [ ] `src/app/widgets/settings_dialog.py`(`QDialog`,纯 UI):更新地址 / 账号 / 密码(掩码+显示) / 测试连接 / 当前版本 + 安全提示 -- [ ] `测试连接`:调用 `update_service.check_for_update`,反馈最新/发现新版/连接失败 -- [ ] `main_window`:打开时注入当前 config,保存时集中 `save_config`,UI 不直接写配置 -- [ ] 保存后重新触发一次在线更新检查(横幅刷新) -- [ ] 取消不改动配置 +- [x] 页签栏右端加低调 `⚙ 配置` 按钮(`addStretch` 与编号页签隔开,非编号页签) +- [x] `src/app/widgets/settings_dialog.py`(`QDialog`,纯 UI):更新地址 / 账号 / 密码(掩码+显示) / 测试连接 / 当前版本 + 安全提示 +- [x] `测试连接`:后台线程调用 `update_service.load_manifest`(区分「连不上」与「已是最新」),反馈最新/发现新版/连接失败 +- [x] `main_window`:打开时注入当前 config,保存时集中 `save_config`,UI 不直接写配置 +- [x] 保存后重新触发一次在线更新检查(横幅刷新;`_update_found` 改为只连一次避免重复) +- [x] 取消不改动配置 验收: -- [ ] 可在界面上改更新地址/账号/密码并持久化到 `~/.cmbot/config/app_config.json` -- [ ] 测试连接能正确反馈三种结果 -- [ ] 改完无需重启 launcher 也能让 app 内横幅按新配置刷新 +- [x] 单测:`load_manifest` 返回 dict / 缺失抛错(2 个);对话框 Qt 符号导入校验通过 +- [ ] GUI 实测:改更新地址/账号/密码并持久化到 `~/.cmbot/config/app_config.json` +- [ ] GUI 实测:测试连接正确反馈三种结果 +- [ ] GUI 实测:改完无需重启 launcher,app 内横幅按新配置刷新 ## 18. 后续暂缓任务 diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 28739fe..5bf44f3 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -15,6 +15,7 @@ from services.update_service import ( UpdateInfo, check_for_update, is_newer, + load_manifest, parse_version, ) @@ -106,6 +107,16 @@ class TestCheckForUpdate(unittest.TestCase): self.assertIsInstance(info, UpdateInfo) self.assertEqual(info.version, "1.1.0") + def test_load_manifest_returns_dict(self): + self._write_manifest({"version": "1.2.0", "notes": "x"}) + data = load_manifest(str(self.tmp)) + self.assertEqual(data["version"], "1.2.0") + + def test_load_manifest_raises_when_missing(self): + # Unlike check_for_update, load_manifest surfaces the error for the test button. + with self.assertRaises(OSError): + load_manifest(str(self.tmp / "nope")) + def test_source_falls_back_to_update_source(self): self._write_manifest({"version": "2.0.0"}) # no "source" field info = check_for_update(str(self.tmp), "1.0.0")