From cd2889d76c209a062392e88121de63c80fc42af5 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 09:24:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E5=B9=B6=E6=94=AF=E6=8C=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E5=9C=B0=E5=9D=80=E5=88=87=E6=8D=A2=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/current_client_service.py | 22 +++-- client/src/http_admin_gateway.py | 9 ++ client/src/pdd_ui_event.py | 35 ++++++++ client/src/settings_ui.py | 81 +++++++++++------ client/src/settings_ui_event.py | 86 +++++++++++++++--- client/src/ui_main.py | 5 ++ client/test/test_pdd_ui_event.py | 25 ++++++ client/test/test_settings_ui_event.py | 120 ++++++++++++++++++++++++++ docs/client/05-ui-specification.md | 11 ++- 9 files changed, 347 insertions(+), 47 deletions(-) diff --git a/client/src/current_client_service.py b/client/src/current_client_service.py index d24987b..3bd20fe 100644 --- a/client/src/current_client_service.py +++ b/client/src/current_client_service.py @@ -11,6 +11,7 @@ from .settings_repository import SettingsRepository CLIENT_ID_KEY = "admin.client_id" CLIENT_NAME_KEY = "admin.client_name" +ADMIN_BASE_URL_KEY = "admin.base_url" def generate_client_device_id() -> str: @@ -56,8 +57,12 @@ class CurrentClientService: ) return CurrentClientSettings(client_id, client_name) - def save(self, client_name: str) -> CurrentClientSettings: - """保存名称;设备号不存在时生成,存在时保持不变。""" + def save( + self, + client_name: str, + admin_base_url: Optional[str] = None, + ) -> CurrentClientSettings: + """原子保存身份;传入管理端地址时一并保存。""" normalized_name = self._clean_text(client_name) if len(normalized_name) > 50: @@ -70,12 +75,13 @@ class CurrentClientService: if not client_id: raise ValueError("生成的设备号不能为空") - self._repository.set_many( - { - CLIENT_ID_KEY: client_id, - CLIENT_NAME_KEY: normalized_name, - } - ) + values = { + CLIENT_ID_KEY: client_id, + CLIENT_NAME_KEY: normalized_name, + } + if admin_base_url is not None: + values[ADMIN_BASE_URL_KEY] = self._clean_text(admin_base_url) + self._repository.set_many(values) return CurrentClientSettings(client_id, normalized_name) @staticmethod diff --git a/client/src/http_admin_gateway.py b/client/src/http_admin_gateway.py index 867b827..bed6b04 100644 --- a/client/src/http_admin_gateway.py +++ b/client/src/http_admin_gateway.py @@ -40,6 +40,15 @@ class HttpAdminGateway(AdminGateway): parsed = urlparse(normalized_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError("Admin 服务地址必须是有效的 http 或 https 地址") + if ( + parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError( + "Admin 服务地址不能包含账号、密码、查询参数或片段" + ) if timeout_seconds <= 0: raise ValueError("请求超时必须大于 0 秒") diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index 329edde..afc1995 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -173,6 +173,12 @@ class ClaimTaskWorker(QObject): self._cancelled = True + @pyqtSlot(object) + def replace_gateway(self, gateway: AdminGateway) -> None: + """当前命令结束后,为后续领取和提交切换 Admin Gateway。""" + + self._gateway = gateway + @pyqtSlot() def run(self) -> None: """兼容旧调用;新代码通过 ``run_selected`` 提交命令。""" @@ -416,6 +422,7 @@ class PDDTaskPageEvent(QObject): _claimRunRequested = pyqtSignal(str) _claimReleaseRequested = pyqtSignal() _claimShutdownRequested = pyqtSignal() + _claimGatewayChanged = pyqtSignal(object) def __init__( self, @@ -486,11 +493,13 @@ class PDDTaskPageEvent(QObject): pass settings = settings_repository or SettingsRepository() + self._settings_repository = settings self._client_service = CurrentClientService(settings) self._selected_android_device_service = SelectedAndroidDeviceService( settings ) self._claim_gateway = claim_gateway + self._owns_claim_gateway = claim_gateway is None self._claim_gateway_error = "" if self._claim_gateway is None: base_url = settings.get("admin.base_url", DEFAULT_ADMIN_BASE_URL) @@ -520,6 +529,31 @@ class PDDTaskPageEvent(QObject): if application is not None: application.aboutToQuit.connect(self.shutdown) + @pyqtSlot(str) + def update_admin_base_url(self, base_url: str) -> None: + """让下一次领取、执行和上报统一使用新的 Admin 地址。""" + + if self._closing or not self._owns_claim_gateway: + return + timeout_value = self._settings_repository.get( + "admin.request_timeout_seconds", 3.0 + ) + try: + timeout_seconds = float(timeout_value) + gateway = HttpAdminGateway( + base_url, + timeout_seconds=timeout_seconds, + client_id=self._client_service.load().client_id, + ) + except (TypeError, ValueError) as exc: + self._claim_gateway_error = str(exc) + return + + self._claim_gateway = gateway + self._claim_gateway_error = "" + if self._claim_worker is not None: + self._claimGatewayChanged.emit(gateway) + def load_initial_tasks(self) -> None: """应用启动后读取第一页本地任务。""" @@ -1018,6 +1052,7 @@ class PDDTaskPageEvent(QObject): self._claimRunRequested.connect(worker.run_selected) self._claimReleaseRequested.connect(worker.release_device) self._claimShutdownRequested.connect(worker.shutdown_worker) + self._claimGatewayChanged.connect(worker.replace_gateway) worker.taskSaved.connect(self._on_claimed_task_saved) worker.retryableFailed.connect(self._route_claim_retryable_failed) worker.failed.connect(self._route_claim_failed) diff --git a/client/src/settings_ui.py b/client/src/settings_ui.py index b45ff82..da68e69 100644 --- a/client/src/settings_ui.py +++ b/client/src/settings_ui.py @@ -13,7 +13,7 @@ from typing import Iterable, Optional from PyQt5.QtCore import QAbstractTableModel, QEvent, QModelIndex, Qt, pyqtSignal from PyQt5.QtWidgets import ( QAbstractItemView, - QFormLayout, + QGridLayout, QHeaderView, QHBoxLayout, QLineEdit, @@ -243,6 +243,11 @@ class SettingsPage(QWidget): self.deviceNameInput.setMaxLength(50) self.deviceNameInput.setAccessibleName("当前客户端设备名") + self.adminBaseUrlInput = LineEdit(self) + self.adminBaseUrlInput.setPlaceholderText("http://127.0.0.1:8080") + self.adminBaseUrlInput.setClearButtonEnabled(True) + self.adminBaseUrlInput.setAccessibleName("Admin 管理端地址") + self.currentDeviceSaveButton = PushButton(FIF.SAVE, "保存", self) self.currentDeviceSaveButton.setAccessibleName("保存当前设备信息") self.currentDeviceStatusLabel = CaptionLabel( @@ -373,16 +378,26 @@ class SettingsPage(QWidget): titleLayout.addWidget(self.currentDeviceStatusLabel, 1) layout.addLayout(titleLayout) - form = QFormLayout() - form.setHorizontalSpacing(16) - form.setVerticalSpacing(12) - deviceIdLabel = CaptionLabel("设备号", card) - deviceIdLabel.setBuddy(self.deviceIdInput) - deviceNameLabel = CaptionLabel("设备名", card) - deviceNameLabel.setBuddy(self.deviceNameInput) - form.addRow(deviceIdLabel, self.deviceIdInput) - form.addRow(deviceNameLabel, self.deviceNameInput) - layout.addLayout(form) + self.currentDeviceFormLayout = QGridLayout() + self.currentDeviceFormLayout.setHorizontalSpacing(12) + self.currentDeviceFormLayout.setVerticalSpacing(12) + self.currentDeviceFormLayout.setColumnStretch(1, 1) + self.currentDeviceFormLayout.setColumnStretch(3, 1) + self.deviceIdLabel = CaptionLabel("设备号", card) + self.deviceIdLabel.setBuddy(self.deviceIdInput) + self.deviceNameLabel = CaptionLabel("设备名", card) + self.deviceNameLabel.setBuddy(self.deviceNameInput) + self.adminBaseUrlLabel = CaptionLabel("管理端地址", card) + self.adminBaseUrlLabel.setBuddy(self.adminBaseUrlInput) + self.currentDeviceFormLayout.addWidget(self.deviceIdLabel, 0, 0) + self.currentDeviceFormLayout.addWidget(self.deviceIdInput, 0, 1) + self.currentDeviceFormLayout.addWidget(self.deviceNameLabel, 0, 2) + self.currentDeviceFormLayout.addWidget(self.deviceNameInput, 0, 3) + self.currentDeviceFormLayout.addWidget(self.adminBaseUrlLabel, 1, 0) + self.currentDeviceFormLayout.addWidget( + self.adminBaseUrlInput, 1, 1, 1, 3 + ) + layout.addLayout(self.currentDeviceFormLayout) commandLayout = QHBoxLayout() commandLayout.addStretch(1) @@ -434,21 +449,30 @@ class SettingsPage(QWidget): titleLayout.addWidget(self.updateStatusLabel, 1) layout.addLayout(titleLayout) - form = QFormLayout() - form.setHorizontalSpacing(16) - form.setVerticalSpacing(12) - versionLabel = CaptionLabel("当前版本", card) - manifestLabel = CaptionLabel("清单地址", card) - usernameLabel = CaptionLabel("账号", card) - passwordLabel = CaptionLabel("密码", card) - manifestLabel.setBuddy(self.updateManifestUrlInput) - usernameLabel.setBuddy(self.updateUsernameInput) - passwordLabel.setBuddy(self.updatePasswordInput) - form.addRow(versionLabel, self.currentVersionLabel) - form.addRow(manifestLabel, self.updateManifestUrlInput) - form.addRow(usernameLabel, self.updateUsernameInput) - form.addRow(passwordLabel, self.updatePasswordInput) - layout.addLayout(form) + self.updateFormLayout = QGridLayout() + self.updateFormLayout.setHorizontalSpacing(12) + self.updateFormLayout.setVerticalSpacing(12) + # 输入区按约 45:55 分配,标签列只占自身需要的宽度。 + self.updateFormLayout.setColumnStretch(1, 45) + self.updateFormLayout.setColumnStretch(3, 55) + self.versionLabel = CaptionLabel("当前版本", card) + self.manifestLabel = CaptionLabel("清单地址", card) + self.usernameLabel = CaptionLabel("账号", card) + self.passwordLabel = CaptionLabel("密码", card) + self.manifestLabel.setBuddy(self.updateManifestUrlInput) + self.usernameLabel.setBuddy(self.updateUsernameInput) + self.passwordLabel.setBuddy(self.updatePasswordInput) + self.updateFormLayout.addWidget(self.versionLabel, 0, 0) + self.updateFormLayout.addWidget(self.currentVersionLabel, 0, 1, 1, 3) + self.updateFormLayout.addWidget(self.manifestLabel, 1, 0) + self.updateFormLayout.addWidget( + self.updateManifestUrlInput, 1, 1, 1, 3 + ) + self.updateFormLayout.addWidget(self.usernameLabel, 2, 0) + self.updateFormLayout.addWidget(self.updateUsernameInput, 2, 1) + self.updateFormLayout.addWidget(self.passwordLabel, 2, 2) + self.updateFormLayout.addWidget(self.updatePasswordInput, 2, 3) + layout.addLayout(self.updateFormLayout) commandLayout = QHBoxLayout() commandLayout.addStretch(1) @@ -468,6 +492,11 @@ class SettingsPage(QWidget): self.currentDeviceStatusLabel.setText(message) + def set_admin_base_url(self, base_url: str) -> None: + """显示当前保存的 Admin 管理端地址。""" + + self.adminBaseUrlInput.setText(base_url) + def set_android_devices(self, devices: Iterable[AndroidDeviceRow]) -> None: """显示后续 ADB 服务返回的设备列表。""" diff --git a/client/src/settings_ui_event.py b/client/src/settings_ui_event.py index 45667cd..dc3e7ef 100644 --- a/client/src/settings_ui_event.py +++ b/client/src/settings_ui_event.py @@ -30,6 +30,7 @@ from .admin_gateway import ( ClientRegistrationGateway, ) from .current_client_service import ( + ADMIN_BASE_URL_KEY, CurrentClientService, generate_client_device_id, ) @@ -46,7 +47,7 @@ DEVICE_ID_PLACEHOLDER = "待生成" class CurrentClientSaveWorker(QObject): """在线程中先保存本地身份,再登记到 Admin。""" - localSaved = pyqtSignal(str, str) + localSaved = pyqtSignal(str, str, str) localSaveFailed = pyqtSignal(str) registrationSucceeded = pyqtSignal(str) registrationFailed = pyqtSignal(str) @@ -58,6 +59,7 @@ class CurrentClientSaveWorker(QObject): gateway: Optional[ClientRegistrationGateway], client_name: str, capabilities: ClaimCapabilities, + admin_base_url: str, gateway_error: str = "", ): super().__init__() @@ -65,6 +67,7 @@ class CurrentClientSaveWorker(QObject): self._gateway = gateway self._client_name = client_name self._capabilities = capabilities + self._admin_base_url = admin_base_url self._gateway_error = gateway_error self._cancelled = False @@ -77,12 +80,19 @@ class CurrentClientSaveWorker(QObject): def run(self) -> None: try: try: - saved = self._service.save(self._client_name) + saved = self._service.save( + self._client_name, + self._admin_base_url, + ) except Exception as exc: self.localSaveFailed.emit(str(exc) or "无法写入本地数据库") return - self.localSaved.emit(saved.client_id, saved.client_name) + self.localSaved.emit( + saved.client_id, + saved.client_name, + self._admin_base_url, + ) if self._cancelled: return @@ -300,10 +310,11 @@ class SettingsPageEventBinder(QObject): searchRequested = pyqtSignal() convertWifiRequested = pyqtSignal(str) - currentDeviceSaveRequested = pyqtSignal(str, str) + currentDeviceSaveRequested = pyqtSignal(str, str, str) saveRequested = pyqtSignal(str, str) deleteRequested = pyqtSignal(str) androidDeviceConfigurationChanged = pyqtSignal(str) + adminBaseUrlChanged = pyqtSignal(str) def __init__( self, @@ -353,6 +364,7 @@ class SettingsPageEventBinder(QObject): ) repository = settings_repository or SettingsRepository() + self._settings_repository = repository self.updateEventBinder = UpdateUiEventBinder( page, repository, @@ -365,6 +377,8 @@ class SettingsPageEventBinder(QObject): repository ) self._admin_gateway = admin_gateway + self._owns_admin_gateway = admin_gateway is None + self._pending_admin_gateway = None self._gateway_error = "" if self._admin_gateway is None: base_url = repository.get("admin.base_url", DEFAULT_ADMIN_BASE_URL) @@ -419,12 +433,20 @@ class SettingsPageEventBinder(QObject): device_id = self._page.deviceIdInput.text().strip() device_name = self._page.deviceNameInput.text().strip() + admin_base_url = self._page.adminBaseUrlInput.text().strip() self._page.deviceNameInput.setText(device_name) - self.currentDeviceSaveRequested.emit(device_id, device_name) + self.currentDeviceSaveRequested.emit( + device_id, + device_name, + admin_base_url, + ) - @pyqtSlot(str, str) + @pyqtSlot(str, str, str) def _start_save_current_device( - self, _displayed_device_id: str, device_name: str + self, + _displayed_device_id: str, + device_name: str, + admin_base_url: str, ) -> None: if ( self._closing @@ -436,6 +458,15 @@ class SettingsPageEventBinder(QObject): return try: + timeout_value = self._settings_repository.get( + "admin.request_timeout_seconds", 3.0 + ) + timeout_seconds = float(timeout_value) + validated_gateway = HttpAdminGateway( + admin_base_url, + timeout_seconds=timeout_seconds, + ) + normalized_base_url = admin_base_url.strip().rstrip("/") device = self._selected_android_device() purchase_mode = ( "live" @@ -452,10 +483,19 @@ class SettingsPageEventBinder(QObject): supported_types=supported_types, purchase_mode=purchase_mode, ) - except ValueError as exc: + except (TypeError, ValueError) as exc: self._page.set_current_device_status(f"保存失败:{exc}") + self._page.adminBaseUrlInput.setFocus() return + pending_gateway = ( + validated_gateway + if self._owns_admin_gateway + else self._admin_gateway + ) + self._pending_admin_gateway = pending_gateway + self._page.set_admin_base_url(normalized_base_url) + self._current_device_busy = True self._sync_button_state() self._page.set_current_device_status("正在保存本地设备信息…") @@ -463,10 +503,11 @@ class SettingsPageEventBinder(QObject): thread = QThread(self) worker = CurrentClientSaveWorker( self._client_service, - self._admin_gateway, + pending_gateway, device_name, capabilities, - self._gateway_error, + normalized_base_url, + "", ) worker.moveToThread(thread) @@ -905,6 +946,15 @@ class SettingsPageEventBinder(QObject): """页面创建时恢复本地 Client 信息。""" try: + base_url = self._settings_repository.get( + ADMIN_BASE_URL_KEY, + DEFAULT_ADMIN_BASE_URL, + ) + displayed_base_url = ( + base_url.strip() + if isinstance(base_url, str) and base_url.strip() + else DEFAULT_ADMIN_BASE_URL + ) saved = self._client_service.load() except Exception as exc: self._page.set_current_device_status( @@ -912,6 +962,7 @@ class SettingsPageEventBinder(QObject): ) return + self._page.set_admin_base_url(displayed_base_url) self._page.set_client_info(saved.client_id, saved.client_name) if saved.client_id: self._page.set_current_device_status("本地设备信息已加载") @@ -1144,11 +1195,20 @@ class SettingsPageEventBinder(QObject): self._refresh_registration_after_android_setting = False QTimer.singleShot(0, self._request_save_current_device) - @pyqtSlot(str, str) - def _on_local_saved(self, client_id: str, client_name: str) -> None: + @pyqtSlot(str, str, str) + def _on_local_saved( + self, + client_id: str, + client_name: str, + admin_base_url: str, + ) -> None: if self._closing: return self._page.set_client_info(client_id, client_name) + self._page.set_admin_base_url(admin_base_url) + self._admin_gateway = self._pending_admin_gateway + self._gateway_error = "" + self.adminBaseUrlChanged.emit(admin_base_url) self._page.set_current_device_status( "本地已保存,正在登记到 Admin…" ) @@ -1158,6 +1218,7 @@ class SettingsPageEventBinder(QObject): if self._closing: return self._page.set_current_device_status(f"本地保存失败:{message}") + self._pending_admin_gateway = None @pyqtSlot(str) def _on_registration_succeeded(self, _registered_at: str) -> None: @@ -1178,6 +1239,7 @@ class SettingsPageEventBinder(QObject): self._worker = None self._thread = None self._current_device_busy = False + self._pending_admin_gateway = None if not self._closing: self._sync_button_state() diff --git a/client/src/ui_main.py b/client/src/ui_main.py index a8058fe..00aeae6 100644 --- a/client/src/ui_main.py +++ b/client/src/ui_main.py @@ -63,6 +63,8 @@ class MainWindow(FluentWindow): self.pddTaskPage, task_repository or TaskRepository(), self, + claim_gateway=admin_gateway, + settings_repository=settings_repository, purchase_adapter_factory=create_u2_purchase_adapter, live_purchase_adapter_factory=create_u2_live_purchase_adapter, purchase_reconcile_factory=create_u2_purchase_reconcile_adapter, @@ -74,6 +76,9 @@ class MainWindow(FluentWindow): self.settingsPage.eventBinder.androidDeviceConfigurationChanged.connect( self.pddTaskPageEvent.android_device_configuration_changed ) + self.settingsPage.eventBinder.adminBaseUrlChanged.connect( + self.pddTaskPageEvent.update_admin_base_url + ) self.addSubInterface(self.pddTaskPage, FIF.HOME, "pdd") self.addSubInterface( self.settingsPage, diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index 8f3df69..fd7a9d8 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -1110,6 +1110,31 @@ class PDDTaskPageEventTest(unittest.TestCase): window.close() window.deleteLater() + def test_admin_base_url_change_reaches_persistent_claim_worker(self): + settings = self._saved_settings() + page = PDDTaskPage() + events = PDDTaskPageEvent( + page, + self.repository, + settings_repository=settings, + ) + worker = events._ensure_claim_executor() + + events.update_admin_base_url("http://192.168.0.9:8080/") + self.assertTrue( + wait_until( + self.app, + lambda: getattr(worker._gateway, "_base_url", "") + == "http://192.168.0.9:8080", + ) + ) + self.assertEqual( + getattr(events._claim_gateway, "_base_url", ""), + "http://192.168.0.9:8080", + ) + events.shutdown() + page.deleteLater() + def test_admin_task_mapping_uses_explicit_real_field_names(self): local_task = admin_task_to_new_claimed_task( collect_admin_task("COL-8020a8729f111c15") diff --git a/client/test/test_settings_ui_event.py b/client/test/test_settings_ui_event.py index 798736c..4b3d11d 100644 --- a/client/test/test_settings_ui_event.py +++ b/client/test/test_settings_ui_event.py @@ -19,6 +19,7 @@ from src.android_device_service import ( AndroidWifiConversionResult, ) from src.mock_admin_gateway import MockAdminGateway +from src.http_admin_gateway import DEFAULT_ADMIN_BASE_URL from src.selected_android_device_service import SELECTED_ANDROID_SERIAL_KEY from src.settings_repository import SettingsRepository from src.settings_ui import AndroidDeviceRow, SettingsPage @@ -212,10 +213,129 @@ class SettingsPageEventTest(unittest.TestCase): self.assertEqual(page.deviceIdInput.text(), "CLIENT-EXISTING") self.assertEqual(page.deviceNameInput.text(), "办公室电脑") + self.assertEqual(page.adminBaseUrlInput.text(), DEFAULT_ADMIN_BASE_URL) self.assertEqual(page.currentDeviceStatusLabel.text(), "本地设备信息已加载") page.eventBinder.shutdown() page.deleteLater() + def test_compact_form_layout_keeps_related_fields_on_same_row(self): + page = SettingsPage( + settings_repository=self.repository, + admin_gateway=MockAdminGateway(), + ) + + self.assertEqual( + page.currentDeviceFormLayout.getItemPosition( + page.currentDeviceFormLayout.indexOf(page.deviceIdInput) + ), + (0, 1, 1, 1), + ) + self.assertEqual( + page.currentDeviceFormLayout.getItemPosition( + page.currentDeviceFormLayout.indexOf(page.deviceNameInput) + ), + (0, 3, 1, 1), + ) + self.assertEqual( + page.currentDeviceFormLayout.getItemPosition( + page.currentDeviceFormLayout.indexOf(page.adminBaseUrlInput) + ), + (1, 1, 1, 3), + ) + self.assertEqual( + page.updateFormLayout.getItemPosition( + page.updateFormLayout.indexOf(page.updateUsernameInput) + ), + (2, 1, 1, 1), + ) + self.assertEqual( + page.updateFormLayout.getItemPosition( + page.updateFormLayout.indexOf(page.updatePasswordInput) + ), + (2, 3, 1, 1), + ) + self.assertEqual(page.updateFormLayout.columnStretch(1), 45) + self.assertEqual(page.updateFormLayout.columnStretch(3), 55) + self.assertIs(page.deviceIdLabel.buddy(), page.deviceIdInput) + self.assertIs(page.adminBaseUrlLabel.buddy(), page.adminBaseUrlInput) + self.assertIs(page.usernameLabel.buddy(), page.updateUsernameInput) + self.assertIs(page.passwordLabel.buddy(), page.updatePasswordInput) + page.eventBinder.shutdown() + page.deleteLater() + + def test_save_persists_normalized_admin_base_url_and_emits_change(self): + gateway = MockAdminGateway() + page = SettingsPage( + settings_repository=self.repository, + admin_gateway=gateway, + ) + changed_urls = [] + page.eventBinder.adminBaseUrlChanged.connect(changed_urls.append) + page.deviceNameInput.setText("仓库电脑") + page.adminBaseUrlInput.setText(" http://192.168.0.8:8080/ ") + + page.currentDeviceSaveButton.click() + self._wait_until(lambda: page.eventBinder._thread is None) + + expected = "http://192.168.0.8:8080" + self.assertEqual(self.repository.get("admin.base_url"), expected) + self.assertEqual(page.adminBaseUrlInput.text(), expected) + self.assertEqual(changed_urls, [expected]) + self.assertEqual(gateway.registration_count, 1) + page.eventBinder.shutdown() + page.deleteLater() + + def test_invalid_admin_base_url_does_not_save_any_current_device_field(self): + self.repository.set_many( + { + "admin.client_id": "CLIENT-EXISTING", + "admin.client_name": "原设备名", + "admin.base_url": "http://127.0.0.1:8080", + } + ) + gateway = MockAdminGateway() + page = SettingsPage( + settings_repository=self.repository, + admin_gateway=gateway, + ) + page.show() + self.app.processEvents() + page.deviceNameInput.setText("不应保存的新名称") + page.adminBaseUrlInput.setText("不是地址") + + page.currentDeviceSaveButton.click() + self.app.processEvents() + + self.assertEqual(self.repository.get("admin.client_name"), "原设备名") + self.assertEqual( + self.repository.get("admin.base_url"), + "http://127.0.0.1:8080", + ) + self.assertEqual(gateway.registration_count, 0) + self.assertIn("Admin 服务地址必须", page.currentDeviceStatusLabel.text()) + self.assertIs(self.app.focusWidget(), page.adminBaseUrlInput) + page.eventBinder.shutdown() + page.deleteLater() + + def test_admin_base_url_with_credentials_is_not_saved(self): + gateway = MockAdminGateway() + page = SettingsPage( + settings_repository=self.repository, + admin_gateway=gateway, + ) + page.adminBaseUrlInput.setText( + "http://operator@127.0.0.1:8080" + ) + + page.currentDeviceSaveButton.click() + self.app.processEvents() + + self.assertIsNone(self.repository.get("admin.base_url")) + self.assertEqual(gateway.registration_count, 0) + self.assertIn("不能包含账号、密码", page.currentDeviceStatusLabel.text()) + page.eventBinder.shutdown() + page.deleteLater() + def test_settings_page_has_no_manual_live_purchase_authorization(self): page = SettingsPage( settings_repository=self.repository, diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index e78053b..c2998a8 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -266,6 +266,14 @@ class TaskTableModel(QAbstractTableModel): 设置页使用可滚动布局和 Fluent 设置卡片,分组如下。 +### 当前设备 + +- 第一行依次显示设备号和设备名;设备号只读,设备名可以修改,两组输入随窗口宽度平均伸缩; +- 第二行显示“管理端地址”,对应 SQLite 中已有的 `admin.base_url`,没有保存值时显示默认地址; +- 点击“保存”前先验证地址;只接受有效的 HTTP/HTTPS 地址,不能包含账号、密码、查询参数或片段。验证失败时不保存设备号、设备名或地址,并把焦点放回地址输入框; +- 保存成功后立即用新地址登记 Client,后续任务领取、结果提交和失败提交也统一切换到新地址;已经发出的请求等待自身结束,不强制中断; +- 保存不新增数据库表,不增加心跳,也不连接或操作 Android 设备。 + ### Admin - 服务地址; @@ -311,7 +319,8 @@ class TaskTableModel(QAbstractTableModel): ### 软件更新 - 显示只读的当前版本; -- 清单地址、账号和密码分别使用带可见标签的单行输入框,密码框默认隐藏且不回填; +- 清单地址独占一行;账号和密码在下一行并排显示,输入区域按约 45:55 自适应伸缩; +- 三个字段都有可见标签,密码框默认隐藏且不回填; - “保存”把 URL/账号写入 SQLite,把密码写入 Windows 凭据管理器;已有密码时留空 表示不修改,修改账号时必须重新输入密码; - “检查更新”只使用已经保存的配置;存在未保存修改时先提示保存;检查和下载期间