feat(client): add safe polling session UI

This commit is contained in:
QiuSW
2026-08-05 01:59:35 +08:00
parent 20112dbd61
commit dfd88c3336
14 changed files with 2253 additions and 17 deletions
+247
View File
@@ -0,0 +1,247 @@
"""只做本地校验和显式保存的配置页。"""
from __future__ import annotations
from pathlib import Path
from typing import Protocol
from PySide6.QtCore import QRegularExpression, Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator
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)
self.device_token.setMaxLength(64)
self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留")
self.device_token.setValidator(QRegularExpressionValidator(QRegularExpression("[0-9a-f]{0,64}"), self))
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 "未保存")