feat: settings dialog for update config (gear entry)
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 <noreply@anthropic.com>
This commit is contained in:
+43
-2
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from PySide6.QtCore import Qt, QUrl, Signal
|
from PySide6.QtCore import Qt, QUrl, Signal
|
||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QDesktopServices
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
@@ -26,6 +27,7 @@ from app.widgets.export_panel import ExportPanel
|
|||||||
from app.widgets.image_canvas import ImageCanvas
|
from app.widgets.image_canvas import ImageCanvas
|
||||||
from app.widgets.image_list_panel import ImageListPanel
|
from app.widgets.image_list_panel import ImageListPanel
|
||||||
from app.widgets.queue_panel import QueuePanel
|
from app.widgets.queue_panel import QueuePanel
|
||||||
|
from app.widgets.settings_dialog import SettingsDialog
|
||||||
from app.widgets.template_panel import TemplatePanel
|
from app.widgets.template_panel import TemplatePanel
|
||||||
from app.widgets.transform_panel import TransformPanel
|
from app.widgets.transform_panel import TransformPanel
|
||||||
|
|
||||||
@@ -108,8 +110,34 @@ class MainWindow(QMainWindow):
|
|||||||
layout.addWidget(self._tab_bar)
|
layout.addWidget(self._tab_bar)
|
||||||
layout.addStretch()
|
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
|
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)
|
# Update notification (stage 2: notify-only, see docs/10-lan-update.md)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -117,6 +145,7 @@ class MainWindow(QMainWindow):
|
|||||||
def _create_update_banner(self):
|
def _create_update_banner(self):
|
||||||
"""A thin info bar shown when a newer version is found. Hidden by default."""
|
"""A thin info bar shown when a newer version is found. Hidden by default."""
|
||||||
self._update_info = None
|
self._update_info = None
|
||||||
|
self._update_found.connect(self._on_update_found) # connect once
|
||||||
|
|
||||||
bar = QWidget()
|
bar = QWidget()
|
||||||
bar.setObjectName("updateBanner")
|
bar.setObjectName("updateBanner")
|
||||||
@@ -149,13 +178,16 @@ class MainWindow(QMainWindow):
|
|||||||
return bar
|
return bar
|
||||||
|
|
||||||
def _start_update_check(self):
|
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", "")
|
source = self._config.get("update_source", "")
|
||||||
if not source:
|
if not source:
|
||||||
return
|
return
|
||||||
user = self._config.get("update_user", "")
|
user = self._config.get("update_user", "")
|
||||||
password = self._config.get("update_pass", "")
|
password = self._config.get("update_pass", "")
|
||||||
self._update_found.connect(self._on_update_found)
|
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
try:
|
try:
|
||||||
@@ -636,6 +668,15 @@ class MainWindow(QMainWindow):
|
|||||||
QTabBar#flowTabBar::tab:disabled {
|
QTabBar#flowTabBar::tab:disabled {
|
||||||
color: #bbbbbb;
|
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 */
|
/* Work splitter */
|
||||||
QSplitter#workSplitter::handle {
|
QSplitter#workSplitter::handle {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -119,6 +119,18 @@ def _load_local_manifest(update_source):
|
|||||||
return json.load(f)
|
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(
|
def check_for_update(
|
||||||
update_source,
|
update_source,
|
||||||
current_version,
|
current_version,
|
||||||
|
|||||||
@@ -953,18 +953,19 @@
|
|||||||
任务:
|
任务:
|
||||||
|
|
||||||
- [x] 文档:`docs/07` §3 布局、§4.1 去掉「暂不提供设置入口」、新增 §4.3 入口 + §4.4 对话框
|
- [x] 文档:`docs/07` §3 布局、§4.1 去掉「暂不提供设置入口」、新增 §4.3 入口 + §4.4 对话框
|
||||||
- [ ] 页签栏右端加低调 `⚙ 配置` 按钮(`addStretch` 与编号页签隔开,非编号页签)
|
- [x] 页签栏右端加低调 `⚙ 配置` 按钮(`addStretch` 与编号页签隔开,非编号页签)
|
||||||
- [ ] `src/app/widgets/settings_dialog.py`(`QDialog`,纯 UI):更新地址 / 账号 / 密码(掩码+显示) / 测试连接 / 当前版本 + 安全提示
|
- [x] `src/app/widgets/settings_dialog.py`(`QDialog`,纯 UI):更新地址 / 账号 / 密码(掩码+显示) / 测试连接 / 当前版本 + 安全提示
|
||||||
- [ ] `测试连接`:调用 `update_service.check_for_update`,反馈最新/发现新版/连接失败
|
- [x] `测试连接`:后台线程调用 `update_service.load_manifest`(区分「连不上」与「已是最新」),反馈最新/发现新版/连接失败
|
||||||
- [ ] `main_window`:打开时注入当前 config,保存时集中 `save_config`,UI 不直接写配置
|
- [x] `main_window`:打开时注入当前 config,保存时集中 `save_config`,UI 不直接写配置
|
||||||
- [ ] 保存后重新触发一次在线更新检查(横幅刷新)
|
- [x] 保存后重新触发一次在线更新检查(横幅刷新;`_update_found` 改为只连一次避免重复)
|
||||||
- [ ] 取消不改动配置
|
- [x] 取消不改动配置
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- [ ] 可在界面上改更新地址/账号/密码并持久化到 `~/.cmbot/config/app_config.json`
|
- [x] 单测:`load_manifest` 返回 dict / 缺失抛错(2 个);对话框 Qt 符号导入校验通过
|
||||||
- [ ] 测试连接能正确反馈三种结果
|
- [ ] GUI 实测:改更新地址/账号/密码并持久化到 `~/.cmbot/config/app_config.json`
|
||||||
- [ ] 改完无需重启 launcher 也能让 app 内横幅按新配置刷新
|
- [ ] GUI 实测:测试连接正确反馈三种结果
|
||||||
|
- [ ] GUI 实测:改完无需重启 launcher,app 内横幅按新配置刷新
|
||||||
|
|
||||||
## 18. 后续暂缓任务
|
## 18. 后续暂缓任务
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from services.update_service import (
|
|||||||
UpdateInfo,
|
UpdateInfo,
|
||||||
check_for_update,
|
check_for_update,
|
||||||
is_newer,
|
is_newer,
|
||||||
|
load_manifest,
|
||||||
parse_version,
|
parse_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,6 +107,16 @@ class TestCheckForUpdate(unittest.TestCase):
|
|||||||
self.assertIsInstance(info, UpdateInfo)
|
self.assertIsInstance(info, UpdateInfo)
|
||||||
self.assertEqual(info.version, "1.1.0")
|
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):
|
def test_source_falls_back_to_update_source(self):
|
||||||
self._write_manifest({"version": "2.0.0"}) # no "source" field
|
self._write_manifest({"version": "2.0.0"}) # no "source" field
|
||||||
info = check_for_update(str(self.tmp), "1.0.0")
|
info = check_for_update(str(self.tmp), "1.0.0")
|
||||||
|
|||||||
Reference in New Issue
Block a user