Files
cmautobuy/docs/client/05-ui-specification.md
T

23 KiB
Raw Blame History

05 Client 界面交互规范

  • 文档状态:基线草案,待界面评审
  • 技术栈:PyQt5、Qt Widgets、PyQt-Fluent-Widgets
  • 当前验证组件版本:PyQt-Fluent-Widgets 1.11.3(版本以 client/requirements.txt 为准)

本文档中 [必须] / [建议] / [待定] 的含义见 文档索引。没有标注的默认是 [必须]。看不懂的词查 术语表。

1. 设计目标

  • 让操作人员在一个页面完成任务监控、搜索和异常定位。
  • “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有 Outbox,并优先处理未发送事件。
  • 长任务状态始终可找到,不使用连续模态弹窗打断工作。
  • 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。
  • 界面只展示任务状态,不在 Qt 主线程执行 Admin 或手机自动化。

2. 应用外壳

主窗口使用 FluentWindow,顶级导航保持稳定顺序:

  1. PDD 任务:主要业务页面。
  2. 设置:固定在导航底部。

页面必须使用稳定对象名:

  • pddTaskPage
  • settingsPage

现状提醒: 当前代码里是 3 个导航项(pdd / 设备 / 设置),和这里写的 2 个不一样。这是已知差异,见 02 架构 §3.1。设备相关设置最终要并入设置页,但要按工单单独做,不要顺手改。

窗口建议默认尺寸为 1280×800,有效最小尺寸不低于 960×600。窗口变窄时允许表格水平滚动和顶部命令换行,不能隐藏主要任务入口。

3. PDD 任务页布局

┌─────────────────────────────────────────────────────────────┐
│ PDD 任务                                                    │
│ [开始自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [已选0条] [重新执行] [重新上报] │
├─────────────────────────────────────────────────────────────┤
│ 类型 │ 商品标题 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 更新时间 │详情│
│                                                              │
│                        任务数据表格                           │
│                                                              │
├─────────────────────────────────────────────────────────────┤
│ 自动获取:已开启 · 当前 PDD-0001 · 正在采集 SKU · 已完成 42 个 │
└─────────────────────────────────────────────────────────────┘

垂直区域依次为顶部命令区、可伸展表格区和稳定状态区。表格获得全部剩余高度,状态区不得遮挡最后一行或滚动条。

4. 顶部命令区

4.1 开始自动获取 / 停止自动获取

  • 使用 PrimaryPushButton,是页面唯一主要强调操作。
  • 初始文本为“开始自动获取”,图标表达开始。
  • 启动成功后文本变为“停止自动获取”,按钮位置和宽度尽量稳定。
  • [必须] 这两个文案是按钮标签。状态栏里的“自动获取:已开启/已停止”说的是功能状态,两者不是一回事,不要混改。
  • 启动过程中禁用重复点击,显示“正在启动”。
  • 停止表示不再领取新任务;当前任务进入安全停止流程。
  • 如果设备、Admin 或必要设置无效,按钮可禁用,但附近必须说明缺少的条件。

每一轮先补交一条本地 Outbox;若无待提交结果则执行最早的本地采集任务,再没有 才向 Admin 领取一条。领取、手机采集和提交都在单独的工作线程完成,一轮结束且 线程完全退出后,由主线程的单次定时器安排下一轮。暂无任务时默认 5 秒后重试; 可恢复的 Admin 错误按 5、10、20、30 秒退避。需要人工处理、不可恢复错误、设备 或配置错误会停止自动获取。采购演练执行器和 Android 设备都准备好时,可以领取 采购任务;界面必须明确显示“演练”,任何路径都不得显示成真实下单。

  • 补交本地 Outbox 不依赖手机,可以在 Android 设备断开时继续执行。
  • 执行本地任务或向 Admin 领取新任务前,工作线程必须用 adb devices -l 检查已保存的精确设备号。USB 和 Wi-Fi 设备都只接受 device 状态。
  • 设备未连接、offline、unauthorized 或 ADB 检查失败时,立即停止本轮和自动获取;不得领取新任务,也不得启动本地任务。
  • 采集、采购、重新采集和设备错误使用较大的 InfoBar 提示,显示在软件窗口顶部水平居中,5 秒后自动关闭,并提供明显的“关闭提示”按钮。设备错误另外提供“打开设置”;重复错误替换旧提示,不得堆叠。完整错误原因保留在底部状态区。

4.2 搜索与筛选

建议控件:

  • 任务类型:ComboBox,选项为“全部、采集、采购”。
  • 任务状态:ComboBox,选项为“全部”及标准任务状态。
  • 关键词:SearchLineEdit,提示“任务编号、商品编号或商品标题”。
  • 搜索:普通 PushButton。

筛选条件之间采用 AND。点击搜索或在关键词输入框按 Enter 执行本地数据库查询,不请求领取任务。活动筛选必须可见,筛选无结果时保留条件并提供清除入口。

4.3 刷新、重新执行与重新上报

  • “刷新”紧邻“搜索”右侧,只重新读取本地任务列表,不请求 Admin,也不操作手机。
  • 页面右侧显示“已选 N 条”,“重新执行”后面是“重新上报”。
  • “重新执行”只在恰好勾选一条任务时启用;多选不允许批量操作手机。
  • 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 Escape 或关闭弹窗都不得启动任务。
  • 用户确认后,工作线程必须先检查已保存 Android 设备的实际连接状态,再重置任务和创建执行记录。检查失败时保留原任务状态、采集结果和执行历史。
  • 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。
  • 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。
  • 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。
  • 点击停止后按钮显示“正在停止…”并禁用重复点击;底部明确说明正在等待手机当前操作结束。uiautomator2/ADB 的单次调用返回后,采集在下一个安全检查点停止;不得强制结束工作线程。
  • 重新采集的前置校验警告和后台错误 InfoBar 都显示在软件窗口顶部水平居中,并有可见的“关闭提示”按钮;提示 5 秒后自动关闭,同一时间只保留一条,新提示替换旧提示。
  • 每次重新采集创建新的执行记录和幂等键;当前结果更新,旧结果保存在历史执行记录中。
  • 采购任务始终不能通过“重新执行”入口启动;需要处理时由自动获取的安全恢复流程决定。
  • “等待重试”当前没有倒计时。自动获取因可恢复采集错误停止时,底部状态显示 “重试已暂停”,并提示选择任务点击“重新执行”或重新启动获取任务。
  • “重新上报”作用于当前已经加载并勾选的任务。执行前显示任务数量,并明确说明不会重新采集、采购或操作手机。
  • 重新上报先查找每条任务最早一条尚未发送的 Outbox,包括 task_failure;全部事件都已发送时,才重发最新的 collect_result 或 purchase_result。
  • 重新上报使用事件原来的 idempotency_key 和 payload_json,不重新组装数据、不创建新 Outbox。历史结果再次得到 Admin 确认时不得覆盖任务最新执行状态;最新失败信息上报成功后应恢复对应失败状态。
  • 重新上报不依赖 Android 设备,在独立工作线程中逐条提交。自动获取、重新执行或另一批上报运行时不得启动。
  • 批量完成后显示成功、失败和跳过数量。成功任务取消勾选;失败及没有结果 Outbox 的跳过任务保留勾选,方便继续处理。
  • 相同幂等键和相同内容只用于让 Admin 再次确认已接收,不代表创建新结果或覆盖 Admin 数据。

5. 任务表格

表格显示的是本机已领取的全部任务,包括正在做的和早已做完的。已完成任务永久保留,不会被清理,所以数据只增不减——增量加载和索引是必须的,不是优化。

使用 qfluentwidgets.TableView、自定义 QAbstractTableModel 和 Repository 查询。不得使用行号作为任务身份,也不得把完整 pdd_data 放入模型。

5.1 列定义

列 对齐 显示规则
选择 居中 行首复选框;占位空行不可勾选,身份使用 remote_task_id
任务类型 居中 “采集”或“采购”,颜色只作辅助
商品标题 左对齐、可伸展 空值显示“尚未获取标题”,截断时提供完整工具提示
颜色 左对齐 无值显示 —
尺码 左对齐 无值显示 —
价格 右对齐 格式为 ¥39.90;采集摘要可显示 ¥39.90 起
数量 右对齐 采购数量;采集显示 —
状态 居中 中文状态文字和状态图标/标记
更新时间 左对齐 本地时区,精确到秒或按产品确认格式
操作 居中 “详情”链接或委托绘制按钮

5.2 数据行为

  • 默认排序为 updated_at DESC, id DESC。
  • 排序、筛选和数据变化后以稳定任务编号恢复当前行。
  • 当前行和勾选集相互独立;点击复选框不打开详情,也不启动任何业务操作。
  • 勾选集使用 remote_task_id 保存。普通刷新保留仍存在的勾选,搜索条件变化清空勾选,避免操作被筛选隐藏的旧任务。
  • 初始加载有限批次,滚动时通过 canFetchMore/fetchMore 增量加载后续任务。
  • 单击非交互区域只设置当前行和选择。
  • 双击行、按 Enter 或点击“详情”执行相同的非破坏性详情命令。
  • “详情”使用委托或链接语义,不为每行创建常驻 QWidget。
  • 空状态要分情况,文案不能一样:
    • 加载中;
    • 从没领取过任务 —— “还没有领取过任务,点击‘获取任务’开始领取”(不要写“暂无数据”,操作人员会以为是出错了);
    • 筛选无结果 —— 提供清除条件入口;
    • Admin 离线 —— 本地数据照常显示,只在信息条说明领取失败;
    • 加载失败。

界面上说的"任务编号"一律指 remote_task_id(例如 PDD-20260806-0001),不是数据库自增 id,更不是表格行号。行号会随排序和筛选变化,拿它当任务身份必出错。

5.3 增量加载模板(照抄即可)

任务可能有几万条,一次全查出来界面会卡住。做法是:先查 50 条,用户滚到底了再查下一批。Qt 的模型/视图自带这个机制,实现 canFetchMore 和 fetchMore 两个方法就行。

from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt

PAGE_SIZE = 50


class TaskTableModel(QAbstractTableModel):
    """任务表格的数据来源。只存显示需要的几个字段。"""

    def __init__(self, repository, parent=None):
        super().__init__(parent)
        self._repository = repository
        self._rows: list[dict] = []      # 已经加载进来的行
        self._filters: dict = {}         # 当前搜索条件
        self._has_more = True            # 数据库里还有没有没取完的

    def rowCount(self, parent=QModelIndex()) -> int:
        return 0 if parent.isValid() else len(self._rows)

    def canFetchMore(self, parent=QModelIndex()) -> bool:
        """Qt 会自己调用它,问"还能再加载吗"。"""
        return not parent.isValid() and self._has_more

    def fetchMore(self, parent=QModelIndex()) -> None:
        """Qt 在用户滚到底时自己调用它。"""
        if parent.isValid():
            return

        page = self._repository.list_tasks(
            filters=self._filters, limit=PAGE_SIZE, offset=len(self._rows)
        )
        if not page:
            self._has_more = False
            return

        # 必须用 begin/end 包住,直接改 self._rows 界面不会刷新
        start = len(self._rows)
        self.beginInsertRows(QModelIndex(), start, start + len(page) - 1)
        self._rows.extend(page)
        self.endInsertRows()

        self._has_more = len(page) == PAGE_SIZE

    def apply_filters(self, filters: dict) -> None:
        """换搜索条件时整表重来。"""
        self.beginResetModel()
        self._filters = filters
        self._rows = []
        self._has_more = True
        self.endResetModel()

注意:

别这么做 为什么
直接 self._rows.append(...) 不加 beginInsertRows 界面不刷新,或者直接崩
把整个 pdd_data 存进 _rows 几万条 JSON 全在内存里,程序会变得很卡
在 fetchMore 里发网络请求 这是主线程,界面会卡住。这里只能查本地 SQLite
给每个单元格 setIndexWidget 放按钮 几万个控件,内存直接爆。"详情"那列用委托画

换筛选之后要恢复用户原来选中的行:先记下当前行的 remote_task_id,重新加载完再按这个编号找回来,不要记行号。

6. 任务详情

详情是信息查看,不要求用户立即决策,优先使用非模态详情窗口或右侧详情面板,不使用简单消息框承载长 JSON。

当前 Client 使用可调整大小的非模态详情窗口。用户可以点击表格“详情”、双击任务行,或选中任务后按 Enter 打开。相同任务只保留一个详情窗口;再次打开时切回已有窗口。按 Esc 或“关闭”按钮返回任务列表。

详情面向采购人员,任务类型、状态、当前步骤和错误说明优先使用中文。数据库中的 current_step 等内部值只用于业务判断和日志,不直接显示;遇到尚未适配的新步骤时显示“未知步骤”。 采购演练和恢复至少要明确显示“演练已在提交前停止”、“结果待提交”、 “只允许核对订单”和“需要人工处理”;不能只用颜色表达安全状态。

采集规格按下面顺序展示:

  1. **颜色:**放在上方,每个颜色旁边直接显示采集到的价格;未采集到价格时显示“价格未采集”。同一颜色意外出现多个价格时显示最低价到最高价,并显示“价格不一致”,方便检查采集结果。
  2. **尺码:**放在颜色下方,只显示尺码文字。

颜色和尺码按采集顺序显示并去重。不可用颜色除文字外还要明确显示“不可用”,不能只改变颜色。没有规格数据时保留对应分区并显示空状态。

详情至少分为:

  1. **基本信息:**任务编号、类型、商品、规格、数量、金额和状态。
  2. **执行状态:**当前步骤、设备、尝试次数、开始/结束时间和最近错误。
  3. **PDD 数据:**标题、店铺、销量、评价、规格维度和 SKU 表格。
  4. **采购结果:**演练/真实模式、确认规格、价格、订单编号、下单时间和核对状态。
  5. **原始数据与诊断:**格式化 admin_payload、pdd_data、日志和 Artifact 入口。

原始 JSON 默认折叠,提供复制或导出操作;复制前不得包含凭据和敏感信息。

7. 底部状态区

状态区占据稳定位置,示例:

自动获取:已开启 · 当前任务 PDD-0001(采集)· 正在遍历规格 · 最近领取 16:20:31

应表达:

  • 引擎状态:已停止、轮询中、领取中、执行中、提交中、退避等待、正在停止。
  • 当前任务和当前步骤。
  • 最近一次领取到任务的时间。
  • 可恢复错误摘要及下一步。

高频轮询无任务时不要连续弹提示。设备断开、认证失败、结果提交持续失败等需要操作的问题使用 InfoBar,并提供“重试”“打开设置”或“查看详情”。

8. 设置页

设置页使用可滚动布局和 Fluent 设置卡片,分组如下。

Admin

  • 服务地址;
  • Client 编号;
  • 认证状态,不直接回显完整令牌;
  • 请求超时;
  • “测试连接”及最近结果。

Android 设备

  • ADB 地址;
  • PDD 包名;
  • “测试连接”;
  • 当前设备型号、Android 版本和 PDD 当前状态摘要。
  • 点击设备“搜索”时可以先尝试恢复已保存的 Wi-Fi ADB,但单台设备恢复失败 不能中断普通设备枚举。表格仍展示 adb devices -l 实际找到的 USB 和 Wi-Fi 设备,状态文字同时说明恢复失败原因以及“已保存配置仍保留”。
  • 只有普通设备枚举本身失败时才显示“搜索失败”并保留旧表格;程序不得因为 设备暂时离线自动删除已保存配置。

自动化

  • 轮询周期;
  • 任务超时;
  • 最大重试次数;
  • 演练模式开关。

安全与诊断

  • 最大购买数量;
  • 价格允许偏差;
  • Artifact 目录;
  • 保留天数;
  • 打开日志目录。

软件更新

  • 显示只读的当前版本;
  • 清单地址使用带可见标签的单行输入框,只允许完整 HTTPS URL;
  • “检查更新”同时保存已验证的非敏感清单地址;检查和下载期间按钮防重复;
  • 状态文字稳定显示未配置、检查中、已是最新、发现新版、下载进度、已准备、失败和 恢复建议;
  • 发现新版本时使用有明确“下载更新 / 暂不下载”的确认框;下载完成不强制重启, 提示操作人员完成当前任务后自行关闭并重新启动。

设置采用显式“保存设置”或项目统一的即时保存模式,不能在同一页面随机混用。MVP 推荐显式保存,验证失败时保留输入并聚焦第一个错误字段。

9. 状态与反馈

场景 反馈方式
领取到新任务 更新表格和状态区,不弹模态框
暂时没有可领取的任务 只更新状态区,不要连续弹提示
搜索无结果 表格空状态和清除条件入口
Admin 暂时离线 窗口顶部居中的较大信息条,5 秒后自动关闭;底部保留错误,本地数据继续可用
设备断开 窗口顶部居中的较大信息条,提供打开设置和关闭提示,5 秒后自动关闭
任务运行 底部状态区和必要进度,不阻塞整个窗口
任务失败 行状态、详情和信息条,不显示原始堆栈
多个订单候选 状态改为“需要人工处理”,打开详情决策
普通任务成功 更新行和状态区,不弹“成功”对话框
更新检查或下载失败 软件更新卡片保留地址并显示原因和重试方式,当前程序不变
更新下载完成 软件更新卡片持续提示“下次启动生效”,不强制关闭程序

9.1 错误提示模板(照抄即可)

规则很简单:普通成功什么都不弹(更新界面就够了);出错用 InfoBar;只有必须让用户当场做决定时才用对话框。

from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QPushButton
from qfluentwidgets import InfoBar, InfoBarPosition


def show_recoverable_error(self, title: str, content: str, on_retry) -> None:
    """显示一条可重试的错误提示。

    title:一句话说清出了什么事
    content:说清已经保留了什么、下一步该干什么
    on_retry:点"重试"时调用的函数
    """
    bar = InfoBar.error(
        title=title,
        content=content,
        orient=Qt.Horizontal,
        isClosable=True,
        position=InfoBarPosition.TOP_RIGHT,
        duration=-1,          # -1 = 不自动消失。重要错误必须用 -1
        parent=self,
    )

    retry_button = QPushButton("重试", bar)
    retry_button.clicked.connect(on_retry)
    retry_button.clicked.connect(bar.close)
    bar.addWidget(retry_button)

调用示例:

self.show_recoverable_error(
    title="Admin 连接失败",
    content="本地任务仍可查看,结果已保存,联网后会自动提交。",
    on_retry=self._sync_task_list,
)

写提示语的三条要求(对应 06 质量与安全 §5):

  1. 说清发生了什么 —— "Admin 连接失败",不是"操作失败"。
  2. 说清保住了什么 —— "结果已保存"。用户最怕的是白干。
  3. 说清下一步 —— 给"重试"按钮,或者给"打开设置"。

不要做的事:

别这么做 改成
把 Python 异常堆栈贴到界面上 界面显示人话,堆栈写进日志
轮询没任务也弹一条提示 只更新底部状态区
用 QMessageBox 报告普通错误 用 InfoBar,别打断用户
错误提示 3 秒自动消失 重要错误 duration=-1,让用户自己关

10. 键盘与无障碍

  • Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 刷新 → 重新执行 → 重新上报 → 表格 → 状态区可操作项。
  • Ctrl+F 聚焦关键词,Enter 打开当前行详情。不绑定 F5——界面上没有需要刷新的远端数据。
  • 仅图标按钮必须设置准确的无障碍名称和工具提示。
  • 表单具有可见标签,占位符不能替代标签。
  • 状态、错误和任务类型不能只依赖颜色。
  • 表格焦点、当前行和选择状态必须可区分。
  • 支持浅色、深色、高对比度和 100%–200% 显示缩放。

11. 原型与验收

该页面属于主要界面改版。正式重写桌面界面前,建议先制作使用模拟数据的可运行 HTML 原型,确认信息架构、列宽、筛选、详情和状态变化。HTML 原型只用于确认交互,不能替代 PyQt 的窗口、性能、DPI 和无障碍验证。

桌面实现至少验证:

  • 空数据、少量数据、大量数据和超长标题;
  • 紧凑、默认、最大化和 Windows 贴靠;
  • 筛选和增量加载后的当前行保持;
  • 快速重复点击、后台迟到结果和窗口关闭;
  • 键盘、浅色、深色、高对比度和 200% 缩放。