"""设置页的界面结构。 本文件只负责显示设备信息和提供界面更新入口。按钮事件统一由 ``settings_ui_event.py`` 绑定;ADB、SQLite 和硬件信息读取不得放在这里。 改动本文件前必读 ``client/AGENTS.md``。页面 ``objectName`` 固定为 ``settingsPage``,图标使用 ``FluentIcon``,不得使用表情符号。 """ from dataclasses import dataclass from typing import Iterable, Optional from PyQt5.QtCore import QAbstractTableModel, QEvent, QModelIndex, Qt, pyqtSignal from PyQt5.QtWidgets import ( QAbstractItemView, QFormLayout, QHeaderView, QHBoxLayout, QSizePolicy, QVBoxLayout, QWidget, ) from qfluentwidgets import ( CaptionLabel, CardWidget, FluentIcon as FIF, LineEdit, PushButton, ScrollArea, SubtitleLabel, TableView, TitleLabel, ) @dataclass(frozen=True) class AndroidDeviceRow: """一台 ADB 已发现设备的界面数据。""" serial: str connection_type: str model: str = "—" android_version: str = "—" status: str = "device" @property def status_text(self) -> str: return { "device": "已连接", "offline": "离线", "unauthorized": "未授权", }.get(self.status, self.status or "未知") class AndroidDeviceTableModel(QAbstractTableModel): """Android 设备表格模型,使用序列号维护互斥勾选。""" checkedDeviceChanged = pyqtSignal(str) HEADERS = ("选择", "设备号", "连接方式", "型号", "Android 版本", "状态") def __init__(self, parent=None): super().__init__(parent) self._rows: list[AndroidDeviceRow] = [] self._checked_serial = "" self._placeholder_row_count = 1 def rowCount(self, parent=QModelIndex()) -> int: if parent.isValid(): return 0 return len(self._rows) or self._placeholder_row_count def columnCount(self, parent=QModelIndex()) -> int: return 0 if parent.isValid() else len(self.HEADERS) def headerData(self, section, orientation, role=Qt.DisplayRole): if role == Qt.DisplayRole and orientation == Qt.Horizontal: return self.HEADERS[section] return super().headerData(section, orientation, role) def data(self, index, role=Qt.DisplayRole): if not index.isValid() or not 0 <= index.row() < len(self._rows): return None device = self._rows[index.row()] if index.column() == 0 and role == Qt.CheckStateRole: return Qt.Checked if device.serial == self._checked_serial else Qt.Unchecked if role == Qt.DisplayRole: values = ( "", device.serial, device.connection_type, device.model, device.android_version, device.status_text, ) return values[index.column()] if role == Qt.TextAlignmentRole: if index.column() in (0, 2, 4, 5): return int(Qt.AlignCenter) return int(Qt.AlignLeft | Qt.AlignVCenter) if role == Qt.ToolTipRole: if index.column() == 0 and device.status != "device": return "设备状态正常后才能选择" if index.column() == 1: return device.serial return None def flags(self, index): if not index.isValid() or index.row() >= len(self._rows): return Qt.NoItemFlags flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable if index.column() == 0 and self._rows[index.row()].status == "device": flags |= Qt.ItemIsUserCheckable return flags def setData(self, index, value, role=Qt.EditRole) -> bool: if ( not index.isValid() or index.row() >= len(self._rows) or index.column() != 0 or role != Qt.CheckStateRole or self._rows[index.row()].status != "device" ): return False serial = self._rows[index.row()].serial checked_serial = serial if value == Qt.Checked else "" self.set_checked_serial(checked_serial) return True def set_devices(self, devices: Iterable[AndroidDeviceRow]) -> None: """替换设备列表,并按稳定序列号保留有效勾选。""" rows = list(devices) valid_serials = {row.serial for row in rows if row.status == "device"} checked_serial = self._checked_serial self.beginResetModel() self._rows = rows if checked_serial not in valid_serials: self._checked_serial = "" self.endResetModel() if checked_serial != self._checked_serial: self.checkedDeviceChanged.emit(self._checked_serial) def set_placeholder_row_count(self, count: int) -> None: """设置空设备列表占位行数,真实设备行不受影响。""" count = max(1, count) if self._rows or count == self._placeholder_row_count: self._placeholder_row_count = count return self.beginResetModel() self._placeholder_row_count = count self.endResetModel() def set_checked_serial(self, serial: str) -> None: """勾选一台正常设备;传空字符串清除勾选。""" if serial: selectable = any( row.serial == serial and row.status == "device" for row in self._rows ) if not selectable: serial = "" if serial == self._checked_serial: return old_serial = self._checked_serial self._checked_serial = serial for candidate in (old_serial, serial): row = self.row_for_serial(candidate) if row >= 0: index = self.index(row, 0) self.dataChanged.emit(index, index, [Qt.CheckStateRole]) self.checkedDeviceChanged.emit(serial) @property def checked_serial(self) -> str: return self._checked_serial def row_for_serial(self, serial: str) -> int: for row, device in enumerate(self._rows): if device.serial == serial: return row return -1 def device_for_serial(self, serial: str) -> Optional[AndroidDeviceRow]: row = self.row_for_serial(serial) return self._rows[row] if row >= 0 else None class SettingsPage(QWidget): """设备管理与后续应用设置的统一页面。""" def __init__( self, parent=None, settings_repository=None, admin_gateway=None, android_device_service=None, ): super().__init__(parent) self.setObjectName("settingsPage") self._build_ui() # 延迟到控件创建完成后绑定,避免事件层在构造过程中访问半成品页面。 from .settings_ui_event import SettingsPageEventBinder self.eventBinder = SettingsPageEventBinder( self, settings_repository=settings_repository, admin_gateway=admin_gateway, android_device_service=android_device_service, ) def _build_ui(self) -> None: self.deviceIdInput = LineEdit(self) self.deviceIdInput.setText("待生成") self.deviceIdInput.setReadOnly(True) self.deviceIdInput.setAccessibleName("当前客户端设备号") self.deviceNameInput = LineEdit(self) self.deviceNameInput.setPlaceholderText("请输入便于识别的设备名") self.deviceNameInput.setClearButtonEnabled(True) self.deviceNameInput.setMaxLength(50) self.deviceNameInput.setAccessibleName("当前客户端设备名") self.currentDeviceSaveButton = PushButton(FIF.SAVE, "保存", self) self.currentDeviceSaveButton.setAccessibleName("保存当前设备信息") self.currentDeviceStatusLabel = CaptionLabel( "设备信息尚未保存", self ) self.currentDeviceStatusLabel.setAccessibleName("当前设备保存状态") self.currentDeviceStatusLabel.setWordWrap(True) self.currentDeviceCard = self._build_current_device_card() self.searchButton = PushButton(FIF.SEARCH, "搜索", self) self.convertWifiButton = PushButton(FIF.WIFI, "转为 Wi-Fi", self) self.convertWifiButton.setAccessibleName( "将勾选的 USB Android 设备转为 Wi-Fi 连接" ) self.convertWifiButton.setToolTip( "开启 ADB 5555,并在拔掉 USB 后重新连接" ) self.saveButton = PushButton(FIF.SAVE, "保存", self) self.deleteButton = PushButton(FIF.DELETE, "删除", self) self.convertWifiButton.setEnabled(False) self.saveButton.setEnabled(False) self.deleteButton.setEnabled(False) self.deviceTableModel = AndroidDeviceTableModel(self) self.deviceTable = TableView(self) self.deviceTable.setModel(self.deviceTableModel) self.deviceTable.setAccessibleName("已连接的 Android 设备") self.deviceTable.setSelectionBehavior(QAbstractItemView.SelectRows) self.deviceTable.setSelectionMode(QAbstractItemView.SingleSelection) self.deviceTable.setEditTriggers(QAbstractItemView.NoEditTriggers) self.deviceTable.verticalHeader().hide() self.deviceTable.verticalHeader().setDefaultSectionSize(42) self.deviceTable.setMinimumHeight(250) self.deviceTable.setColumnWidth(0, 64) self.deviceTable.setColumnWidth(1, 210) self.deviceTable.setColumnWidth(2, 96) self.deviceTable.setColumnWidth(4, 112) self.deviceTable.viewport().installEventFilter(self) header = self.deviceTable.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Fixed) header.setSectionResizeMode(1, QHeaderView.Interactive) header.setSectionResizeMode(2, QHeaderView.Fixed) header.setSectionResizeMode(3, QHeaderView.Stretch) header.setSectionResizeMode(4, QHeaderView.Fixed) header.setSectionResizeMode(5, QHeaderView.ResizeToContents) self.deviceStatusLabel = CaptionLabel("当前使用设备:未选择", self) self.deviceStatusLabel.setAccessibleName("Android 设备搜索和选择状态") self.deviceStatusLabel.setWordWrap(True) self.pddAppStatusLabel = CaptionLabel("未检测", self) self.pddAppStatusLabel.setAccessibleName("PDD 应用安装状态") self.pddAppStatusLabel.setWordWrap(True) self.androidDeviceCard = self._build_android_device_card() content = QWidget(self) content.setObjectName("settingsContent") contentLayout = QVBoxLayout(content) contentLayout.setContentsMargins(36, 30, 36, 36) contentLayout.setSpacing(16) contentLayout.addWidget(TitleLabel("设置", content)) contentLayout.addWidget(self.currentDeviceCard) contentLayout.addWidget(self.androidDeviceCard) contentLayout.addStretch(1) scrollArea = ScrollArea(self) scrollArea.setWidgetResizable(True) scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scrollArea.setWidget(content) scrollArea.enableTransparentBackground() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(scrollArea) def eventFilter(self, watched, event): if watched is self.deviceTable.viewport() and event.type() == QEvent.Resize: self._update_device_placeholder_rows() return super().eventFilter(watched, event) def _update_device_placeholder_rows(self) -> None: """用不可交互空行填满 Android 设备表格的可视区域。""" row_height = max(1, self.deviceTable.verticalHeader().defaultSectionSize()) viewport_height = max(row_height, self.deviceTable.viewport().height()) row_count = max(1, viewport_height // row_height) self.deviceTableModel.set_placeholder_row_count(row_count) def _build_current_device_card(self) -> CardWidget: card = CardWidget(self) card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) layout = QVBoxLayout(card) layout.setContentsMargins(24, 20, 24, 22) layout.setSpacing(12) titleLayout = QHBoxLayout() titleLayout.setSpacing(12) titleLayout.addWidget(SubtitleLabel("当前设备", card)) titleLayout.addStretch(1) 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) commandLayout = QHBoxLayout() commandLayout.addStretch(1) commandLayout.addWidget(self.currentDeviceSaveButton) layout.addLayout(commandLayout) return card def _build_android_device_card(self) -> CardWidget: card = CardWidget(self) layout = QVBoxLayout(card) layout.setContentsMargins(24, 20, 24, 22) layout.setSpacing(12) titleLayout = QHBoxLayout() titleLayout.setSpacing(12) titleLayout.addWidget(SubtitleLabel("Android 设备", card)) titleLayout.addStretch(1) titleLayout.addWidget(self.deviceStatusLabel, 1) layout.addLayout(titleLayout) pddStatusLayout = QHBoxLayout() pddStatusLayout.setSpacing(12) pddStatusLayout.addWidget(CaptionLabel("PDD 应用", card)) pddStatusLayout.addWidget(self.pddAppStatusLabel, 1) layout.addLayout(pddStatusLayout) commandLayout = QHBoxLayout() commandLayout.setSpacing(10) commandLayout.addWidget(self.searchButton) commandLayout.addWidget(self.convertWifiButton) commandLayout.addStretch(1) commandLayout.addWidget(self.saveButton) commandLayout.addWidget(self.deleteButton) layout.addLayout(commandLayout) layout.addWidget(self.deviceTable, 1) return card def set_client_info(self, device_id: str, device_name: str) -> None: """显示后续设备身份服务提供的当前客户端信息。""" self.deviceIdInput.setText(device_id or "待生成") self.deviceNameInput.setText(device_name) def set_current_device_status(self, message: str) -> None: """显示本地保存和 Admin 登记两个阶段的结果。""" self.currentDeviceStatusLabel.setText(message) def set_android_devices(self, devices: Iterable[AndroidDeviceRow]) -> None: """显示后续 ADB 服务返回的设备列表。""" self.deviceTableModel.set_devices(devices) self._update_device_placeholder_rows() def set_pdd_app_status(self, message: str) -> None: """显示当前勾选设备上的 PDD 安装检测结果。""" self.pddAppStatusLabel.setText(message) def set_saved_android_device(self, serial: str) -> None: """显示已经保存并实际用于自动化的 Android 设备。""" self.deviceTableModel.set_checked_serial(serial) text = serial if serial else "未选择" self.deviceStatusLabel.setText(f"当前使用设备:{text}")