feat: 实现设置页设备管理界面 (#5)

This commit is contained in:
chengma
2026-08-06 15:31:05 +08:00
parent 3190d1ff78
commit 41f7cbb6f3
3 changed files with 388 additions and 92 deletions
+298 -67
View File
@@ -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}")
+89 -22
View File
@@ -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)
+1 -3
View File
@@ -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,