"""只做本地校验和显式保存的配置页。""" from __future__ import annotations from pathlib import Path from typing import Protocol from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtGui import QAction, QKeySequence from PySide6.QtWidgets import ( QComboBox, QFormLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget, ) from cmbuyer_client.core.models import SecretToken from cmbuyer_client.core.errors import ValidationError from cmbuyer_client.core.validation import require_uuid4 from cmbuyer_client.localstate.models import LOOPBACK_SERVICE_URL, ProfileSettings class ProfileStore(Protocol): def save_profile(self, settings: ProfileSettings, token: SecretToken | None) -> None: ... class SettingsPage(QScrollArea): settings_saved = Signal(object, bool) VALIDATION_HINT = "服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行能力验证。" def __init__( self, store: ProfileStore, *, profile_id: str = "default", settings: ProfileSettings | None = None, has_stored_device_token: bool = False, identity_frozen: bool = False, parent: QWidget | None = None, ) -> None: super().__init__(parent) self.setObjectName("settingsPage") self.setWidgetResizable(True) self._store = store self._profile_id = profile_id self._has_stored_device_token = has_stored_device_token self._loaded_settings = settings self._identity_frozen = identity_frozen content = QWidget() outer = QVBoxLayout(content) title = QLabel("配置") title.setObjectName("settingsTitle") title.setStyleSheet("font-size: 20px; font-weight: 600;") outer.addWidget(title) form = QFormLayout() form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) outer.addLayout(form) self.service_url = QLineEdit(LOOPBACK_SERVICE_URL) self.service_url.setReadOnly(True) self.service_url.setObjectName("serviceUrl") form.addRow("采购服务 URL", self.service_url) self.device_id = QLineEdit() self.device_id.setObjectName("deviceId") self.device_id.setPlaceholderText("小写 UUIDv4") form.addRow("设备 UUID", self.device_id) self.device_token = QLineEdit() self.device_token.setObjectName("deviceToken") self.device_token.setEchoMode(QLineEdit.EchoMode.Password) # 控件只负责给输入设置合理上限;长度与字符集必须在 save() 中显式 # 验证。若这里限制为 64,粘贴 65 位 token 会被 Qt 静默截成合法 # 64 位并覆盖原凭据。 self.device_token.setMaxLength(256) self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留") form.addRow("设备 token", self.device_token) self.token_status = QLabel() self.token_status.setObjectName("tokenStatus") form.addRow("凭据状态", self.token_status) self.adb_path = QLineEdit() self.adb_path.setObjectName("adbPath") form.addRow("ADB 路径", self.adb_path) self.adb_serial = QLineEdit() self.adb_serial.setObjectName("adbSerial") form.addRow("设备 serial", self.adb_serial) self.transport = QComboBox() self.transport.setObjectName("transport") self.transport.addItem("USB", "usb") self.transport.addItem("WiFi", "wifi") form.addRow("连接方式", self.transport) self.poll_interval = self._spin(5, 300, 15, "pollInterval", " 秒") form.addRow("轮询间隔", self.poll_interval) self.failure_threshold = self._spin(1, 10, 3, "failureThreshold", " 次") form.addRow("连续失败停止阈值", self.failure_threshold) self.http_timeout = self._spin(1, 120, 10, "httpTimeout", " 秒") form.addRow("HTTP 超时", self.http_timeout) self.step_timeout = self._spin(5, 300, 45, "stepTimeout", " 秒") form.addRow("真机步骤超时", self.step_timeout) self.validation_hint = QLabel(self.VALIDATION_HINT) self.validation_hint.setObjectName("deferredValidationHint") self.validation_hint.setWordWrap(True) outer.addWidget(self.validation_hint) self.feedback = QLabel() self.feedback.setObjectName("settingsFeedback") self.feedback.setWordWrap(True) self.feedback.setAccessibleName("配置保存状态") outer.addWidget(self.feedback) self.save_action = QAction("保存配置", self) self.save_action.setShortcut(QKeySequence.StandardKey.Save) self.save_action.triggered.connect(self.save) self.addAction(self.save_action) self.save_button = QPushButton("保存配置") self.save_button.setObjectName("saveSettings") self.save_button.clicked.connect(self.save_action.trigger) outer.addWidget(self.save_button, 0, Qt.AlignmentFlag.AlignRight) outer.addStretch(1) self.setWidget(content) if settings is not None: self._load(settings) self._update_token_status() self.set_identity_frozen(identity_frozen) @staticmethod def _spin(minimum: int, maximum: int, value: int, name: str, suffix: str) -> QSpinBox: field = QSpinBox() field.setObjectName(name) field.setRange(minimum, maximum) field.setValue(value) field.setSuffix(suffix) return field def _load(self, settings: ProfileSettings) -> None: self.device_id.setText(settings.device_id) self.adb_path.setText(settings.adb_path) self.adb_serial.setText(settings.adb_serial) self.transport.setCurrentIndex(max(0, self.transport.findData(settings.transport))) self.poll_interval.setValue(settings.poll_interval_seconds) self.failure_threshold.setValue(settings.failure_threshold) self.http_timeout.setValue(settings.http_timeout_seconds) self.step_timeout.setValue(settings.step_timeout_seconds) def set_identity_frozen(self, frozen: bool) -> None: self._identity_frozen = frozen for field in ( self.device_id, self.adb_path, self.adb_serial, self.transport, self.poll_interval, self.failure_threshold, self.http_timeout, self.step_timeout, ): field.setEnabled(not frozen) if frozen: self.feedback.setText("存在待恢复或执行中的领取;服务与设备身份参数已冻结。") elif self.feedback.text().startswith("存在待恢复或执行中的领取"): self.feedback.clear() @Slot() def save(self) -> None: self.feedback.clear() token_text = self.device_token.text().strip() device_id = self.device_id.text().strip() try: require_uuid4(device_id, "invalid_device_id") except (TypeError, ValueError, ValidationError): self._validation_error(self.device_id, "设备 UUID 必须是小写 UUIDv4。") return if not self._has_stored_device_token and not token_text: self._validation_error(self.device_token, "首次保存必须填写设备 token。") return if token_text and (len(token_text) != 64 or any(character not in "0123456789abcdef" for character in token_text)): self._validation_error(self.device_token, "设备 token 必须为 64 位小写十六进制。") return try: if self._identity_frozen: if self._loaded_settings is None: self._validation_error(self.device_id, "冻结配置缺少原始设置,不能保存。") return settings = self._loaded_settings else: adb_text = self.adb_path.text().strip() if not adb_text: self._validation_error(self.adb_path, "请填写 ADB 路径。") return adb_file = Path(adb_text).expanduser() if not adb_file.is_file(): self._validation_error(self.adb_path, "ADB 路径必须指向本机已存在的文件。") return adb_file = adb_file.resolve(strict=True) serial = self.adb_serial.text().strip() if not serial: self._validation_error(self.adb_serial, "请填写设备 serial。") return settings = ProfileSettings( profile_id=self._profile_id, service_url=LOOPBACK_SERVICE_URL, device_id=device_id, adb_path=str(adb_file), adb_serial=serial, transport=str(self.transport.currentData()), poll_interval_seconds=self.poll_interval.value(), failure_threshold=self.failure_threshold.value(), http_timeout_seconds=self.http_timeout.value(), step_timeout_seconds=self.step_timeout.value(), ) token = SecretToken(token_text) if token_text else None except (TypeError, ValueError): self._validation_error(self.device_id, "配置格式不正确,请检查设备 UUID 与各项参数。") return try: # None 明确表示保留 T-303 中已有的 DPAPI 密文,绝不是清除凭据。 self._store.save_profile(settings, token) except Exception: self.feedback.setText("配置保存失败,本地安全存储未更新。") (self.device_token if token_text else self.device_id).setFocus() return self._has_stored_device_token = True self._loaded_settings = settings self.device_token.clear() self._update_token_status() self.feedback.setText("配置已保存。本地校验不代表服务、设备或 App 已就绪。") self.settings_saved.emit(settings, True) def _validation_error(self, field: QWidget, message: str) -> None: self.feedback.setText(message) field.setFocus() def _update_token_status(self) -> None: self.token_status.setText("已保存" if self._has_stored_device_token else "未保存")