From ad197004eda1ab1803b7a866565bbab14cf5de86 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 6 Aug 2026 14:18:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20PDD=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=88=97=E8=A1=A8=20UI=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pdd_ui.py | 553 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 505 insertions(+), 48 deletions(-) diff --git a/client/src/pdd_ui.py b/client/src/pdd_ui.py index 7c9c499..e6332fb 100644 --- a/client/src/pdd_ui.py +++ b/client/src/pdd_ui.py @@ -25,83 +25,540 @@ “开始自动获取”。其余操作全部只读本地数据库。 """ +from dataclasses import dataclass +from typing import Iterable, Optional + +from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt, pyqtSignal +from PyQt5.QtGui import QKeySequence from PyQt5.QtWidgets import ( + QAbstractItemView, QGridLayout, + QHeaderView, QHBoxLayout, - QSizePolicy, + QShortcut, + QStackedWidget, QVBoxLayout, QWidget, ) from qfluentwidgets import ( + BodyLabel, CaptionLabel, CardWidget, + ComboBox, FluentIcon as FIF, - LineEdit, 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 任务表格模型,当前使用内存数据模拟 Repository 查询结果。""" + + HEADERS = ( + "任务类型", + "商品标题", + "颜色", + "尺码", + "价格", + "数量", + "状态", + "更新时间", + "操作", + ) + + def __init__(self, parent=None): + super().__init__(parent) + self._source_rows: list[TaskRow] = [] + self._filtered_rows: list[TaskRow] = [] + self._rows: list[TaskRow] = [] + self._task_type = "全部" + self._status = "全部" + self._keyword = "" + self._sort_column = 7 + self._sort_order = Qt.DescendingOrder + + def rowCount(self, parent=QModelIndex()) -> int: + return 0 if parent.isValid() else len(self._rows) + + 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(): + return Qt.NoItemFlags + return Qt.ItemIsEnabled | Qt.ItemIsSelectable + + def canFetchMore(self, parent=QModelIndex()) -> bool: + """告诉 Qt 内存结果中是否还有下一页。""" + + return not parent.isValid() and len(self._rows) < len(self._filtered_rows) + + def fetchMore(self, parent=QModelIndex()) -> None: + """每次再展示最多 50 行,模拟后续 Repository 的分页行为。""" + + if parent.isValid() or not self.canFetchMore(parent): + return + + start = len(self._rows) + end = min(start + PAGE_SIZE, len(self._filtered_rows)) + self.beginInsertRows(QModelIndex(), start, end - 1) + self._rows.extend(self._filtered_rows[start:end]) + self.endInsertRows() + + def sort(self, column, order=Qt.AscendingOrder) -> None: + if not 0 <= column < len(self.HEADERS): + return + + loaded_count = max(PAGE_SIZE, len(self._rows)) + self.beginResetModel() + self._sort_column = column + self._sort_order = order + self._sort_filtered_rows() + self._rows = self._filtered_rows[:loaded_count] + self.endResetModel() + + def set_tasks(self, tasks: Iterable[TaskRow]) -> None: + """替换内存任务列表;后续由 Repository 查询结果调用。""" + + self.beginResetModel() + self._source_rows = list(tasks) + self._rebuild_filtered_rows() + self._rows = self._filtered_rows[:PAGE_SIZE] + self.endResetModel() + + def apply_filters(self, task_type: str, status: str, keyword: str) -> None: + """按类型、状态和关键词同时筛选当前内存任务。""" + + self.beginResetModel() + self._task_type = task_type + self._status = status + self._keyword = keyword.strip().casefold() + self._rebuild_filtered_rows() + self._rows = self._filtered_rows[:PAGE_SIZE] + self.endResetModel() + + 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._filtered_rows) + + def _rebuild_filtered_rows(self) -> None: + self._filtered_rows = [ + task for task in self._source_rows if self._matches_filters(task) + ] + self._sort_filtered_rows() + + def _matches_filters(self, task: TaskRow) -> bool: + if self._task_type != "全部" and task.task_type != self._task_type: + return False + if self._status != "全部" and task.status != self._status: + return False + if not self._keyword: + return True + + searchable_text = " ".join( + (task.remote_task_id, task.goods_id, task.title) + ).casefold() + return self._keyword in searchable_text + + def _sort_filtered_rows(self) -> None: + reverse = self._sort_order == Qt.DescendingOrder + self._filtered_rows.sort( + key=lambda task: self._sort_value(task, self._sort_column), + reverse=reverse, + ) + + @staticmethod + def _sort_value(task: TaskRow, column: int): + values = ( + task.task_type, + task.title, + task.color, + task.size, + task.price_cents if task.price_cents is not None else -1, + task.quantity if task.quantity is not None else -1, + task.status, + task.updated_at, + task.remote_task_id, + ) + value = values[column] + return value.casefold() if isinstance(value, str) else value + + @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.goodsInput = LineEdit(self) - self.goodsInput.setPlaceholderText("输入拼多多商品链接或商品 ID") - self.goodsInput.setClearButtonEnabled(True) - self.goodsInput.setAccessibleName("商品链接或商品 ID") + 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() - self.colorInput = LineEdit(self) - self.colorInput.setPlaceholderText("例如:黑色") - self.colorInput.setClearButtonEnabled(True) - self.colorInput.setAccessibleName("目标颜色") + def _build_command_area(self) -> None: + self.autoFetchButton = PrimaryPushButton( + FIF.PLAY, "开始自动获取", self + ) + self.autoFetchButton.setMinimumWidth(132) + self.autoFetchButton.setAccessibleName("开始自动获取 PDD 任务") - self.sizeInput = LineEdit(self) - self.sizeInput.setPlaceholderText("例如:L") - self.sizeInput.setClearButtonEnabled(True) - self.sizeInput.setAccessibleName("目标尺码") + self.taskTypeCombo = ComboBox(self) + self.taskTypeCombo.addItems(("全部", "采集", "采购")) + self.taskTypeCombo.setMinimumWidth(100) + self.taskTypeCombo.setAccessibleName("任务类型") - self.startButton = PrimaryPushButton(FIF.PLAY, "开始任务", self) - self.startButton.setAccessibleName("开始自动购买任务") - self.statusLabel = CaptionLabel("等待启动", self) + self.statusCombo = ComboBox(self) + self.statusCombo.addItems( + ("全部", "待执行", "执行中", "已完成", "失败", "需要人工处理") + ) + self.statusCombo.setMinimumWidth(130) + self.statusCombo.setAccessibleName("任务状态") - 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("商品链接 / ID", card)) - cardLayout.addWidget(self.goodsInput) + self.keywordInput = SearchLineEdit(self) + self.keywordInput.setPlaceholderText("任务编号、商品编号或商品标题") + self.keywordInput.setClearButtonEnabled(True) + self.keywordInput.setMinimumWidth(240) + self.keywordInput.setAccessibleName("任务关键词") - specLayout = QGridLayout() - specLayout.setHorizontalSpacing(16) - specLayout.setVerticalSpacing(8) - specLayout.addWidget(CaptionLabel("颜色", card), 0, 0) - specLayout.addWidget(CaptionLabel("尺码", card), 0, 1) - specLayout.addWidget(self.colorInput, 1, 0) - specLayout.addWidget(self.sizeInput, 1, 1) - specLayout.setColumnStretch(0, 1) - specLayout.setColumnStretch(1, 1) - cardLayout.addLayout(specLayout) + self.searchButton = PushButton(FIF.SEARCH, "搜索", self) + self.searchButton.setAccessibleName("搜索本地任务") - actionLayout = QHBoxLayout() - actionLayout.setContentsMargins(0, 6, 0, 0) - actionLayout.addWidget(self.statusLabel) - actionLayout.addStretch(1) - actionLayout.addWidget(self.startButton) - cardLayout.addLayout(actionLayout) + self.refreshButton = PushButton(FIF.SYNC, "刷新", self) + self.refreshButton.setAccessibleName("刷新本地任务表格") + self.commandCard = CardWidget(self) + commandLayout = QGridLayout(self.commandCard) + commandLayout.setContentsMargins(20, 16, 20, 18) + commandLayout.setHorizontalSpacing(12) + commandLayout.setVerticalSpacing(6) + commandLayout.addWidget(CaptionLabel("任务类型", self.commandCard), 0, 1) + commandLayout.addWidget(CaptionLabel("任务状态", self.commandCard), 0, 2) + commandLayout.addWidget(CaptionLabel("关键词", self.commandCard), 0, 3) + commandLayout.addWidget(self.autoFetchButton, 1, 0) + commandLayout.addWidget(self.taskTypeCombo, 1, 1) + commandLayout.addWidget(self.statusCombo, 1, 2) + commandLayout.addWidget(self.keywordInput, 1, 3) + commandLayout.addWidget(self.searchButton, 1, 4) + commandLayout.addWidget(self.refreshButton, 1, 5) + commandLayout.setColumnStretch(3, 1) + + 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(True) + 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.sortByColumn(7, Qt.DescendingOrder) + + self.emptyPage = QWidget(self) + emptyLayout = QVBoxLayout(self.emptyPage) + emptyLayout.setContentsMargins(32, 32, 32, 32) + emptyLayout.addStretch(1) + self.emptyTitleLabel = SubtitleLabel("还没有领取过任务", self.emptyPage) + self.emptyTitleLabel.setAlignment(Qt.AlignCenter) + self.emptyMessageLabel = BodyLabel( + "点击“开始自动获取”开始领取任务。", self.emptyPage + ) + self.emptyMessageLabel.setAlignment(Qt.AlignCenter) + self.emptyMessageLabel.setWordWrap(True) + self.clearFiltersButton = PushButton("清除筛选条件", self.emptyPage) + self.clearFiltersButton.setAccessibleName("清除全部任务筛选条件") + + clearButtonLayout = QHBoxLayout() + clearButtonLayout.addStretch(1) + clearButtonLayout.addWidget(self.clearFiltersButton) + clearButtonLayout.addStretch(1) + + emptyLayout.addWidget(self.emptyTitleLabel) + emptyLayout.addWidget(self.emptyMessageLabel) + emptyLayout.addSpacing(8) + emptyLayout.addLayout(clearButtonLayout) + emptyLayout.addStretch(1) + + self.contentStack = QStackedWidget(self) + self.contentStack.addWidget(self.taskTable) + self.contentStack.addWidget(self.emptyPage) + + 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)) - # description = BodyLabel("配置商品与目标规格,然后启动自动化任务。", self) - # description.setWordWrap(True) - # layout.addWidget(description) - layout.addWidget(card) - layout.addStretch(1) + layout.addWidget(TitleLabel("PDD 任务", self)) + layout.addWidget(self.commandCard) + layout.addWidget(self.contentStack, 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 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 set_loading(self) -> None: + """显示正在读取本地任务的状态。""" + + self._show_empty_state("正在加载任务", "正在读取本地任务,请稍候。", False) + + def set_load_error(self, message: str) -> None: + """显示本地任务加载失败状态,不清除当前筛选条件。""" + + 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: + current_task_id = self.current_task_id() + filters = { + "task_type": self.taskTypeCombo.currentText(), + "status": self.statusCombo.currentText(), + "keyword": self.keywordInput.text().strip(), + } + self.taskModel.apply_filters( + filters["task_type"], filters["status"], filters["keyword"] + ) + self._update_empty_state() + self._restore_current_task(current_task_id) + 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.rowCount() > 0: + self.contentStack.setCurrentWidget(self.taskTable) + return + + if self._has_active_filters(): + self._show_empty_state( + "没有符合条件的任务", + "请调整搜索条件,或者清除筛选后查看全部本地任务。", + True, + ) + return + + self._show_empty_state( + "还没有领取过任务", + "点击“开始自动获取”开始领取任务。", + False, + ) + + 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.contentStack.setCurrentWidget(self.emptyPage)