Files
cmautobuy/client/src/pdd_ui.py
T

572 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PDD 任务页的界面结构。
这一页显示**本机已领取的全部任务**(正在做的和早已做完的),
提供搜索、筛选、详情,以及启动/停止自动获取。
完整规范见 `docs/client/05-ui-specification.md`。
改动本文件前必读 `client/AGENTS.md`。以下几条最容易踩:
- 本文件只负责显示和把用户操作转发出去,**不写业务逻辑**,
不发 Admin 请求、不查数据库、不碰手机。这些放 `pdd_ui_event.py`。
- 任务表格必须用**模型/视图**(`QAbstractTableModel` + `TableView`),
并用 `remote_task_id` 作为任务身份。**不要用表格行号**——
行号会随排序和筛选变化,拿它当身份必出错。
- 不要为每个单元格创建常驻 `QWidget`(几万行会把内存吃光)。
“详情”那一列用委托绘制。
- 不要把完整的 `pdd_data` 放进表格模型,那是详情数据,
查看详情时再从数据库读。
- 表格数据只增不减(已完成任务永久保留),所以增量加载是必须的,
照抄 `docs/client/05-ui-specification.md` §5.3 的模板。
- 空状态要分情况:从没领过任务、筛选无结果、加载失败,文案不能一样。
- 图标用 `FluentIcon`,**不得用表情符号**。
- 页面 `objectName` 固定为 `pddTaskPage`,不要改。
注意:本页唯一会产生外部后果(真的去操作手机、可能下单)的命令是
“开始自动获取”。其余操作全部只读本地数据库。
"""
from dataclasses import dataclass
from typing import Iterable, Optional
from PyQt5.QtCore import QAbstractTableModel, QEvent, QModelIndex, Qt, pyqtSignal
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWidgets import (
QAbstractItemView,
QGridLayout,
QHeaderView,
QHBoxLayout,
QShortcut,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from qfluentwidgets import (
BodyLabel,
CaptionLabel,
CardWidget,
ComboBox,
FluentIcon as FIF,
PrimaryPushButton,
PushButton,
SearchLineEdit,
SubtitleLabel,
TableView,
TitleLabel,
)
PAGE_SIZE = 50
DETAIL_COLUMN = 8
REMOTE_TASK_ID_ROLE = Qt.UserRole + 1
@dataclass(frozen=True)
class TaskRow:
"""任务表格的一行,只保存列表展示和搜索需要的字段。"""
remote_task_id: str
task_type: str
goods_id: str = ""
title: str = ""
color: str = ""
size: str = ""
price_cents: Optional[int] = None
price_is_starting: bool = False
quantity: Optional[int] = None
status: str = "待执行"
updated_at: str = ""
class TaskTableModel(QAbstractTableModel):
"""PDD 任务表格模型,只保存已经从数据库读取的轻量行。"""
loadMoreRequested = pyqtSignal(int, int)
HEADERS = (
"任务类型",
"商品标题",
"颜色",
"尺码",
"价格",
"数量",
"状态",
"更新时间",
"操作",
)
def __init__(self, parent=None):
super().__init__(parent)
self._rows: list[TaskRow] = []
self._has_more = False
self._loading = False
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 orientation == Qt.Horizontal and role == Qt.DisplayRole:
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
task = self._rows[index.row()]
column = index.column()
if role == Qt.DisplayRole:
return self._display_value(task, column)
if role == Qt.TextAlignmentRole:
if column in (0, 6, 8):
return Qt.AlignCenter
if column in (4, 5):
return Qt.AlignRight | Qt.AlignVCenter
return Qt.AlignLeft | Qt.AlignVCenter
if role == Qt.ToolTipRole:
if column == 1:
return task.title or "尚未获取标题"
if column == DETAIL_COLUMN:
return f"查看任务 {task.remote_task_id} 的详情"
if role == REMOTE_TASK_ID_ROLE:
return task.remote_task_id
return None
def flags(self, index):
if not index.isValid() or index.row() >= len(self._rows):
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def canFetchMore(self, parent=QModelIndex()) -> bool:
"""告诉 Qt 数据库中是否还有下一页。"""
return not parent.isValid() and self._has_more and not self._loading
def fetchMore(self, parent=QModelIndex()) -> None:
"""请求事件层从数据库读取下一页。"""
if parent.isValid() or not self.canFetchMore(parent):
return
self._loading = True
self.loadMoreRequested.emit(len(self._rows), PAGE_SIZE)
def sort(self, column, order=Qt.AscendingOrder) -> None:
"""列表顺序由 Repository 固定为更新时间倒序。"""
def set_tasks(self, tasks: Iterable[TaskRow]) -> None:
"""替换全部任务;保留给简单测试和静态预览使用。"""
self.beginResetModel()
self._rows = list(tasks)
self._has_more = False
self._loading = False
self.endResetModel()
def reset_for_reload(self) -> None:
"""清空旧结果,准备从数据库重新读取第一页。"""
self.beginResetModel()
self._rows = []
self._has_more = True
self._loading = False
self.endResetModel()
def append_tasks(self, tasks: Iterable[TaskRow], has_more: bool) -> None:
"""追加一页任务,并结束本次加载状态。"""
page = list(tasks)
if not self._rows:
self.beginResetModel()
self._rows = page
self._has_more = has_more
self._loading = False
self.endResetModel()
return
if page:
start = len(self._rows)
self.beginInsertRows(QModelIndex(), start, start + len(page) - 1)
self._rows.extend(page)
self.endInsertRows()
self._has_more = has_more
self._loading = False
def finish_loading(self) -> None:
"""查询失败时停止继续请求,等待用户刷新或重新搜索。"""
self._has_more = False
self._loading = False
def row_at(self, row: int) -> Optional[TaskRow]:
"""根据当前可见行获取任务,越界时返回 None。"""
if 0 <= row < len(self._rows):
return self._rows[row]
return None
def find_row(self, remote_task_id: str) -> int:
"""根据稳定任务编号查找当前已加载行。"""
for row, task in enumerate(self._rows):
if task.remote_task_id == remote_task_id:
return row
return -1
def filtered_count(self) -> int:
return len(self._rows)
def data_row_count(self) -> int:
"""返回真实任务行数,不包含填满空表格的占位行。"""
return len(self._rows)
def set_placeholder_row_count(self, count: int) -> None:
"""设置无数据时显示的禁用空行数量。"""
count = max(1, count)
if count == self._placeholder_row_count:
return
if self._rows:
self._placeholder_row_count = count
return
self.beginResetModel()
self._placeholder_row_count = count
self.endResetModel()
@staticmethod
def _display_value(task: TaskRow, column: int) -> str:
if column == 0:
return task.task_type
if column == 1:
return task.title or "尚未获取标题"
if column == 2:
return task.color or "—"
if column == 3:
return task.size or "—"
if column == 4:
if task.price_cents is None:
return "—"
suffix = " 起" if task.price_is_starting else ""
return f"¥{task.price_cents / 100:.2f}{suffix}"
if column == 5:
return "—" if task.quantity is None else str(task.quantity)
if column == 6:
return task.status
if column == 7:
return task.updated_at or "—"
return "详情"
class PDDTaskPage(QWidget):
"""展示、筛选本机任务,并把用户操作转发给事件层。"""
autoFetchRequested = pyqtSignal()
searchRequested = pyqtSignal(dict)
refreshRequested = pyqtSignal()
detailRequested = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("pddTaskPage")
self.taskModel = TaskTableModel(self)
self._build_command_area()
self._build_content_area()
self._build_status_area()
self._build_page_layout()
self._connect_ui_events()
self._update_empty_state()
def _build_command_area(self) -> None:
self.autoFetchButton = PrimaryPushButton(
FIF.PLAY, "开始自动获取", self
)
self.autoFetchButton.setMinimumWidth(132)
self.autoFetchButton.setAccessibleName("开始自动获取 PDD 任务")
self.taskTypeCombo = ComboBox(self)
self.taskTypeCombo.addItems(("全部", "采集", "采购"))
self.taskTypeCombo.setMinimumWidth(100)
self.taskTypeCombo.setAccessibleName("任务类型")
self.statusCombo = ComboBox(self)
self.statusCombo.addItems(
("全部", "待执行", "执行中", "已完成", "失败", "需要人工处理")
)
self.statusCombo.setMinimumWidth(130)
self.statusCombo.setAccessibleName("任务状态")
self.keywordInput = SearchLineEdit(self)
self.keywordInput.setPlaceholderText("任务编号、商品编号或商品标题")
self.keywordInput.setClearButtonEnabled(True)
self.keywordInput.setMinimumWidth(190)
self.keywordInput.setMaximumWidth(480)
self.keywordInput.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.keywordInput.setAccessibleName("任务关键词")
self.searchButton = PushButton(FIF.SEARCH, "搜索", self)
self.searchButton.setAccessibleName("搜索本地任务")
self.refreshButton = PushButton(FIF.SYNC, "刷新", self)
self.refreshButton.setAccessibleName("刷新本地任务表格")
self.commandCard = CardWidget(self)
commandLayout = QGridLayout(self.commandCard)
commandLayout.setContentsMargins(20, 14, 20, 14)
commandLayout.setHorizontalSpacing(12)
commandLayout.addWidget(self.autoFetchButton, 0, 0)
commandLayout.addWidget(CaptionLabel("任务类型", self.commandCard), 0, 1)
commandLayout.addWidget(self.taskTypeCombo, 0, 2)
commandLayout.addWidget(CaptionLabel("任务状态", self.commandCard), 0, 3)
commandLayout.addWidget(self.statusCombo, 0, 4)
commandLayout.addWidget(CaptionLabel("关键词", self.commandCard), 0, 5)
commandLayout.addWidget(self.keywordInput, 0, 6)
commandLayout.addWidget(self.searchButton, 0, 7)
commandLayout.setColumnStretch(6, 4)
commandLayout.setColumnStretch(8, 1)
commandLayout.addWidget(self.refreshButton, 0, 9)
def _build_content_area(self) -> None:
self.taskTable = TableView(self)
self.taskTable.setModel(self.taskModel)
self.taskTable.setAccessibleName("本机 PDD 任务列表")
self.taskTable.setSelectionBehavior(QAbstractItemView.SelectRows)
self.taskTable.setSelectionMode(QAbstractItemView.SingleSelection)
self.taskTable.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.taskTable.setAlternatingRowColors(True)
self.taskTable.setWordWrap(False)
self.taskTable.setSortingEnabled(False)
self.taskTable.verticalHeader().setVisible(False)
header = self.taskTable.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Interactive)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setMinimumSectionSize(64)
self.taskTable.setColumnWidth(0, 82)
self.taskTable.setColumnWidth(2, 108)
self.taskTable.setColumnWidth(3, 82)
self.taskTable.setColumnWidth(4, 96)
self.taskTable.setColumnWidth(5, 70)
self.taskTable.setColumnWidth(6, 116)
self.taskTable.setColumnWidth(7, 156)
self.taskTable.setColumnWidth(8, 72)
self.taskTable.viewport().installEventFilter(self)
self.emptyStateCard = CardWidget(self)
emptyLayout = QHBoxLayout(self.emptyStateCard)
emptyLayout.setContentsMargins(16, 10, 16, 10)
emptyTextLayout = QVBoxLayout()
emptyTextLayout.setSpacing(2)
self.emptyTitleLabel = SubtitleLabel("", self.emptyStateCard)
self.emptyMessageLabel = BodyLabel("", self.emptyStateCard)
self.emptyMessageLabel.setWordWrap(True)
emptyTextLayout.addWidget(self.emptyTitleLabel)
emptyTextLayout.addWidget(self.emptyMessageLabel)
self.clearFiltersButton = PushButton(
"清除筛选条件", self.emptyStateCard
)
self.clearFiltersButton.setAccessibleName("清除全部任务筛选条件")
emptyLayout.addLayout(emptyTextLayout, 1)
emptyLayout.addWidget(self.clearFiltersButton)
def _build_status_area(self) -> None:
self.statusCard = CardWidget(self)
statusLayout = QHBoxLayout(self.statusCard)
statusLayout.setContentsMargins(16, 10, 16, 10)
self.statusLabel = CaptionLabel(
"自动获取:已停止 · 当前没有执行中的任务", self.statusCard
)
self.statusLabel.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.statusLabel.setAccessibleName("PDD 任务运行状态")
statusLayout.addWidget(self.statusLabel, 1)
def _build_page_layout(self) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(36, 30, 36, 36)
layout.setSpacing(16)
layout.addWidget(TitleLabel("PDD 任务", self))
layout.addWidget(self.commandCard)
layout.addWidget(self.emptyStateCard)
layout.addWidget(self.taskTable, 1)
layout.addWidget(self.statusCard)
def _connect_ui_events(self) -> None:
self.autoFetchButton.clicked.connect(self.autoFetchRequested.emit)
self.searchButton.clicked.connect(self._apply_filters)
self.keywordInput.returnPressed.connect(self._apply_filters)
self.refreshButton.clicked.connect(self.refreshRequested.emit)
self.clearFiltersButton.clicked.connect(self.clear_filters)
self.taskTable.clicked.connect(self._on_table_clicked)
self.taskTable.activated.connect(self._on_table_activated)
self.findShortcut = QShortcut(QKeySequence.Find, self)
self.findShortcut.activated.connect(self.keywordInput.setFocus)
def eventFilter(self, watched, event):
if watched is self.taskTable.viewport() and event.type() == QEvent.Resize:
self._update_placeholder_rows()
return super().eventFilter(watched, event)
def set_tasks(self, tasks: Iterable[TaskRow]) -> None:
"""显示任务数据,并尽量恢复原来选中的稳定任务编号。"""
current_task_id = self.current_task_id()
self.taskModel.set_tasks(tasks)
self._update_empty_state()
self._restore_current_task(current_task_id)
def begin_task_reload(self) -> None:
"""清空旧结果并显示本地数据库加载状态。"""
self._task_id_before_reload = self.current_task_id()
self.taskModel.reset_for_reload()
self.set_loading()
def append_task_page(
self, tasks: Iterable[TaskRow], has_more: bool
) -> None:
"""把事件层读取的一页任务追加到表格。"""
self.taskModel.append_tasks(tasks, has_more)
self._update_empty_state()
task_id = getattr(self, "_task_id_before_reload", "")
self._restore_current_task(task_id)
self._task_id_before_reload = ""
def set_loading(self) -> None:
"""显示正在读取本地任务的状态。"""
self._show_empty_state("正在加载任务", "正在读取本地任务,请稍候。", False)
def set_load_error(self, message: str) -> None:
"""显示本地任务加载失败状态,不清除当前筛选条件。"""
self.taskModel.finish_loading()
self._show_empty_state(
"任务加载失败",
message or "无法读取本地任务,请检查日志后重试。",
False,
)
def set_auto_fetch_state(self, state: str) -> None:
"""更新自动获取按钮;state 为 stopped/starting/running/stopping。"""
states = {
"stopped": ("开始自动获取", FIF.PLAY, True),
"starting": ("正在启动", FIF.SYNC, False),
"running": ("停止自动获取", FIF.PAUSE, True),
"stopping": ("正在停止", FIF.PAUSE, False),
}
text, icon, enabled = states.get(state, states["stopped"])
self.autoFetchButton.setText(text)
self.autoFetchButton.setIcon(icon)
self.autoFetchButton.setEnabled(enabled)
def set_engine_status(self, text: str) -> None:
"""更新页面底部的任务引擎状态说明。"""
self.statusLabel.setText(text or "自动获取:已停止 · 当前没有执行中的任务")
def clear_filters(self) -> None:
"""清除全部搜索条件并重新查询本地任务。"""
self.taskTypeCombo.setCurrentIndex(0)
self.statusCombo.setCurrentIndex(0)
self.keywordInput.clear()
self._apply_filters()
def current_task_id(self) -> str:
"""返回当前行的稳定任务编号,没有当前行时返回空字符串。"""
index = self.taskTable.currentIndex()
task = self.taskModel.row_at(index.row()) if index.isValid() else None
return task.remote_task_id if task else ""
def _apply_filters(self) -> None:
filters = {
"task_type": self.taskTypeCombo.currentText(),
"status": self.statusCombo.currentText(),
"keyword": self.keywordInput.text().strip(),
}
self.searchRequested.emit(filters)
def _on_table_clicked(self, index: QModelIndex) -> None:
if index.column() == DETAIL_COLUMN:
self._emit_detail(index)
def _on_table_activated(self, index: QModelIndex) -> None:
self._emit_detail(index)
def _emit_detail(self, index: QModelIndex) -> None:
task = self.taskModel.row_at(index.row())
if task is not None:
self.detailRequested.emit(task.remote_task_id)
def _restore_current_task(self, remote_task_id: str) -> None:
if not remote_task_id:
return
row = self.taskModel.find_row(remote_task_id)
if row < 0:
return
index = self.taskModel.index(row, 0)
self.taskTable.setCurrentIndex(index)
self.taskTable.selectRow(row)
self.taskTable.scrollTo(index)
def _has_active_filters(self) -> bool:
return (
self.taskTypeCombo.currentIndex() != 0
or self.statusCombo.currentIndex() != 0
or bool(self.keywordInput.text().strip())
)
def _update_empty_state(self) -> None:
if self.taskModel.data_row_count() > 0:
self.emptyStateCard.hide()
return
if self._has_active_filters():
self._show_empty_state(
"没有符合条件的任务",
"请调整搜索条件,或者清除筛选后查看全部本地任务。",
True,
)
return
self.emptyStateCard.hide()
self._update_placeholder_rows()
def _show_empty_state(
self, title: str, message: str, show_clear_button: bool
) -> None:
self.emptyTitleLabel.setText(title)
self.emptyMessageLabel.setText(message)
self.clearFiltersButton.setVisible(show_clear_button)
self.emptyStateCard.show()
self._update_placeholder_rows()
def _update_placeholder_rows(self) -> None:
"""用不可交互的空行填满无数据时的表格可视区域。"""
row_height = max(1, self.taskTable.verticalHeader().defaultSectionSize())
viewport_height = max(row_height, self.taskTable.viewport().height())
row_count = max(1, viewport_height // row_height)
self.taskModel.set_placeholder_row_count(row_count)