diff --git a/client/src/settings_ui.py b/client/src/settings_ui.py index 249d291..6d90435 100644 --- a/client/src/settings_ui.py +++ b/client/src/settings_ui.py @@ -1,101 +1,332 @@ """设置页的界面结构。 -分四组:Admin、Android 设备、自动化、安全与诊断。 -完整规范见 `docs/client/05-ui-specification.md` §8。 +本文件只负责显示设备信息和提供界面更新入口。按钮事件统一由 +``settings_ui_event.py`` 绑定;ADB、SQLite 和硬件信息读取不得放在这里。 -改动本文件前必读 `client/AGENTS.md`。以下几条最容易踩: - -- **认证状态只显示“已配置/未配置”或掩码值,绝不回显完整 token。** -- 本文件只负责显示和输入转发,“测试连接”这类会联网、会连手机的动作 - 放 `settings_ui_event.py`,且必须走后台线程。 -- 表单必须有**可见标签**,占位符不能当标签用(占位符一输入就没了, - 用户会忘记这个框是干嘛的)。 -- 用可滚动布局和 Fluent 设置卡片;MVP 采用显式“保存设置”按钮, - 不要在同一页混用即时保存。 -- 校验失败时保留用户已输入的内容,并把焦点移到第一个出错的字段, - 不要清空重来。 -- 图标用 `FluentIcon`,**不得用表情符号**。 -- 页面 `objectName` 固定为 `settingsPage`,不要改。 - -注意:设备连接**不是**独立的顶级页面,统一放在本页, -见 `docs/client/01-requirements.md` §3。 +改动本文件前必读 ``client/AGENTS.md``。页面 ``objectName`` 固定为 +``settingsPage``,图标使用 ``FluentIcon``,不得使用表情符号。 """ -from PyQt5.QtWidgets import QHBoxLayout, QSizePolicy, QVBoxLayout, QWidget +from dataclasses import dataclass +from typing import Iterable, Optional + +from PyQt5.QtCore import QAbstractTableModel, 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, ) -class DevicePage(QWidget): - """Android 设备连接页。""" +@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.setObjectName("devicePage") + self._rows: list[AndroidDeviceRow] = [] + self._checked_serial = "" - self.addressInput = LineEdit(self) - self.addressInput.setText("192.168.0.173:5555") - self.addressInput.setPlaceholderText("IP:端口") - self.addressInput.setClearButtonEnabled(True) - self.addressInput.setAccessibleName("Android 设备地址") + def rowCount(self, parent=QModelIndex()) -> int: + return 0 if parent.isValid() else len(self._rows) - self.connectButton = PushButton(FIF.LINK, "连接设备", self) - self.connectButton.setAccessibleName("连接 Android 设备") - self.deviceStatusLabel = CaptionLabel("当前状态:未连接", self) + def columnCount(self, parent=QModelIndex()) -> int: + return 0 if parent.isValid() else len(self.HEADERS) - card = CardWidget(self) - card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - cardLayout = QVBoxLayout(card) - cardLayout.setContentsMargins(24, 20, 24, 22) - cardLayout.setSpacing(12) - cardLayout.addWidget(SubtitleLabel("无线调试设备", card)) - cardLayout.addWidget(CaptionLabel("设备地址", card)) + 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) - connectionLayout = QHBoxLayout() - connectionLayout.setSpacing(12) - connectionLayout.addWidget(self.addressInput, 1) - connectionLayout.addWidget(self.connectButton) - cardLayout.addLayout(connectionLayout) - cardLayout.addWidget(self.deviceStatusLabel) + def data(self, index, role=Qt.DisplayRole): + if not index.isValid() or not 0 <= index.row() < len(self._rows): + return None - layout = QVBoxLayout(self) - layout.setContentsMargins(36, 30, 36, 36) - layout.setSpacing(16) - layout.addWidget(TitleLabel("设备管理", self)) - # description = BodyLabel("管理 uiautomator2 使用的 Android 设备连接。", self) - # description.setWordWrap(True) - # layout.addWidget(description) - layout.addWidget(card) - layout.addStretch(1) + 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(): + 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.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_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): super().__init__(parent) self.setObjectName("settingsPage") + self._build_ui() - card = CardWidget(self) - card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - cardLayout = QVBoxLayout(card) - cardLayout.setContentsMargins(24, 20, 24, 22) - cardLayout.setSpacing(8) - cardLayout.addWidget(SubtitleLabel("应用设置", card)) - # hint = BodyLabel("主题、日志和任务策略可以在这里继续扩展。", card) - # hint.setWordWrap(True) - # cardLayout.addWidget(hint) + # 延迟到控件创建完成后绑定,避免事件层在构造过程中访问半成品页面。 + from .settings_ui_event import SettingsPageEventBinder + + self.eventBinder = SettingsPageEventBinder(self) + + 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.currentDeviceCard = self._build_current_device_card() + + self.addressInput = LineEdit(self) + self.addressInput.setPlaceholderText("例如 192.168.0.173:5555") + self.addressInput.setClearButtonEnabled(True) + self.addressInput.setAccessibleName("Android 无线调试地址") + + self.searchButton = PushButton(FIF.SEARCH, "搜索", self) + self.connectButton = PushButton(FIF.LINK, "连接", self) + self.saveButton = PushButton(FIF.SAVE, "保存", self) + self.deleteButton = PushButton(FIF.DELETE, "删除", self) + 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) + 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.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(36, 30, 36, 36) - layout.setSpacing(16) - layout.addWidget(TitleLabel("设置", self)) - layout.addWidget(card) - layout.addStretch(1) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(scrollArea) + + 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) + layout.addWidget(SubtitleLabel("当前设备", card)) + + 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) + return card + + def _build_android_device_card(self) -> CardWidget: + card = CardWidget(self) + layout = QVBoxLayout(card) + layout.setContentsMargins(24, 20, 24, 22) + layout.setSpacing(12) + layout.addWidget(SubtitleLabel("Android 设备", card)) + + addressLayout = QHBoxLayout() + addressLayout.setSpacing(12) + addressLabel = CaptionLabel("无线地址", card) + addressLabel.setBuddy(self.addressInput) + addressLayout.addWidget(addressLabel) + addressLayout.addWidget(self.addressInput, 1) + addressLayout.addWidget(self.connectButton) + layout.addLayout(addressLayout) + + commandLayout = QHBoxLayout() + commandLayout.setSpacing(10) + commandLayout.addWidget(self.searchButton) + commandLayout.addStretch(1) + commandLayout.addWidget(self.saveButton) + commandLayout.addWidget(self.deleteButton) + layout.addLayout(commandLayout) + layout.addWidget(self.deviceTable, 1) + layout.addWidget(self.deviceStatusLabel) + 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_android_devices(self, devices: Iterable[AndroidDeviceRow]) -> None: + """显示后续 ADB 服务返回的设备列表。""" + + self.deviceTableModel.set_devices(devices) + + 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}") diff --git a/client/src/settings_ui_event.py b/client/src/settings_ui_event.py index 616d270..7984d32 100644 --- a/client/src/settings_ui_event.py +++ b/client/src/settings_ui_event.py @@ -1,25 +1,92 @@ """设置页的事件绑定。 -处理保存设置、测试 Admin 连接、测试设备连接等动作。 - -改动本文件前必读 `client/AGENTS.md`。以下几条最容易踩: - -- **凭据(token、密码)不写进 SQLite 的 `app_settings`,也不写进日志。** - 存到 Windows 凭据管理器,见 `docs/client/06-quality-security.md` §7。 - `data/` 目录是明文的、设计上就是整个拷走的,拷一次等于泄露一次。 -- “测试连接”会联网、会连手机,**必须走后台线程**, - 用 `QObject` + `moveToThread`(模板见 `docs/client/02-architecture.md` §5.1)。 - 在主线程里直接连,界面会卡住好几秒。 -- 非敏感设置存 `app_settings` 表,键名沿用 `<分组>.<名称>` 的写法, - 清单见 `docs/client/03-data-model.md` §6。 -- 涉及路径的设置(日志目录、Artifact 目录)一律基于 `data_dir()`, - **不要硬编码,也不要用 `os.getcwd()`**——打包成 exe 后会失效, - 见 `docs/client/03-data-model.md` §2.2。 -- 保存前要校验:数量上限、价格偏差、轮询周期这些填错了会直接影响 - 采购安全,不能存进去再说。 -- 测试结果用 `InfoBar` 反馈,失败时说清“哪一步失败、下一步做什么”, - 不要把原始异常堆栈贴到界面上。 - -注意:真实下单开关属于红线,默认关闭,不得在这里做成 -一个随手能打开的普通开关,见根目录 `AGENTS.md` 红线第 2 条。 +本文件把界面操作转换为轻量信号,不直接执行 ADB、SQLite 或硬件信息读取。 +后续应用服务应在后台线程处理阻塞操作,再通过页面更新方法返回结果。 """ + +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot + + +class SettingsPageEventBinder(QObject): + """绑定设备管理控件,并向应用层发出稳定事件。""" + + searchRequested = pyqtSignal() + connectRequested = pyqtSignal(str) + saveRequested = pyqtSignal(str, str) + deleteRequested = pyqtSignal(str) + + def __init__(self, page): + super().__init__(page) + self._page = page + self._busy = False + + page.searchButton.clicked.connect(self._request_search) + page.connectButton.clicked.connect(self._request_connect) + page.saveButton.clicked.connect(self._request_save) + page.deleteButton.clicked.connect(self._request_delete) + page.addressInput.textChanged.connect(self._sync_button_state) + page.deviceTableModel.checkedDeviceChanged.connect(self._sync_button_state) + self._sync_button_state() + + @pyqtSlot() + def _request_search(self) -> None: + if self._busy: + return + self.searchRequested.emit() + + @pyqtSlot() + def _request_connect(self) -> None: + if self._busy: + return + + address = self._page.addressInput.text().strip() + if not address: + self._page.deviceStatusLabel.setText( + "连接失败:请先填写无线调试地址,例如 192.168.0.173:5555" + ) + self._page.addressInput.setFocus() + return + self.connectRequested.emit(address) + + @pyqtSlot() + def _request_save(self) -> None: + if self._busy: + return + + serial = self._page.deviceTableModel.checked_serial + if not serial: + self._page.deviceStatusLabel.setText("保存失败:请先勾选一台已连接设备") + self._page.deviceTable.setFocus() + return + + device_name = self._page.deviceNameInput.text().strip() + self.saveRequested.emit(device_name, serial) + + @pyqtSlot() + def _request_delete(self) -> None: + if self._busy: + return + + serial = self._page.deviceTableModel.checked_serial + if not serial: + self._page.deviceStatusLabel.setText("删除失败:请先勾选一台设备") + self._page.deviceTable.setFocus() + return + self.deleteRequested.emit(serial) + + @pyqtSlot() + def _sync_button_state(self, *_args) -> None: + serial = self._page.deviceTableModel.checked_serial + has_address = bool(self._page.addressInput.text().strip()) + self._page.searchButton.setEnabled(not self._busy) + self._page.connectButton.setEnabled(not self._busy and has_address) + self._page.saveButton.setEnabled(not self._busy and bool(serial)) + self._page.deleteButton.setEnabled(not self._busy and bool(serial)) + + def set_busy(self, busy: bool, message: str = "") -> None: + """切换界面忙碌状态,防止用户重复提交设备命令。""" + + self._busy = busy + self._sync_button_state() + if message: + self._page.deviceStatusLabel.setText(message) diff --git a/client/src/ui_main.py b/client/src/ui_main.py index dc067fa..3c86669 100644 --- a/client/src/ui_main.py +++ b/client/src/ui_main.py @@ -28,7 +28,7 @@ from qfluentwidgets import ( ) from .pdd_ui import PDDTaskPage -from .settings_ui import DevicePage, SettingsPage +from .settings_ui import SettingsPage class MainWindow(FluentWindow): @@ -38,11 +38,9 @@ class MainWindow(FluentWindow): super().__init__() self.pddTaskPage = PDDTaskPage(self) - self.devicePage = DevicePage(self) self.settingsPage = SettingsPage(self) self.addSubInterface(self.pddTaskPage, FIF.HOME, "pdd") - self.addSubInterface(self.devicePage, FIF.IOT, "设备") self.addSubInterface( self.settingsPage, FIF.SETTING,