Files
cmautobuy/client/src/pdd_ui.py
T

807 lines
30 KiB
Python
Raw Normal View History

"""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`,不要改。
注意:“自动获取”和“重新执行”会真的操作手机。重新执行当前只允许
采集任务,采购任务必须在事件层拦截,不能从本入口下单。
"""
2026-08-06 12:06:16 +08:00
2026-08-06 14:18:42 +08:00
from dataclasses import dataclass
from typing import Iterable, Optional
from PyQt5.QtCore import QAbstractTableModel, QEvent, QModelIndex, Qt, pyqtSignal
2026-08-06 14:18:42 +08:00
from PyQt5.QtGui import QKeySequence
2026-08-06 12:06:16 +08:00
from PyQt5.QtWidgets import (
2026-08-06 14:18:42 +08:00
QAbstractItemView,
2026-08-06 12:06:16 +08:00
QGridLayout,
2026-08-06 14:18:42 +08:00
QHeaderView,
2026-08-06 12:06:16 +08:00
QHBoxLayout,
2026-08-06 14:18:42 +08:00
QShortcut,
QSizePolicy,
2026-08-06 12:06:16 +08:00
QVBoxLayout,
QWidget,
)
from qfluentwidgets import (
2026-08-06 14:18:42 +08:00
BodyLabel,
2026-08-06 12:06:16 +08:00
CaptionLabel,
CardWidget,
2026-08-06 14:18:42 +08:00
ComboBox,
2026-08-06 12:06:16 +08:00
FluentIcon as FIF,
PrimaryPushButton,
2026-08-06 14:18:42 +08:00
PushButton,
SearchLineEdit,
2026-08-06 12:06:16 +08:00
SubtitleLabel,
2026-08-06 14:18:42 +08:00
TableView,
2026-08-06 12:06:16 +08:00
TitleLabel,
)
2026-08-06 14:18:42 +08:00
PAGE_SIZE = 50
CHECK_COLUMN = 0
DETAIL_COLUMN = 9
2026-08-06 14:18:42 +08:00
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)
checkedTaskIdsChanged = pyqtSignal(object)
2026-08-06 14:18:42 +08:00
HEADERS = (
"选择",
2026-08-06 14:18:42 +08:00
"任务类型",
"商品标题",
"颜色",
"尺码",
"价格",
"数量",
"状态",
"更新时间",
"操作",
)
def __init__(self, parent=None):
super().__init__(parent)
self._rows: list[TaskRow] = []
self._has_more = False
self._loading = False
self._placeholder_row_count = 1
self._checked_task_ids: set[str] = set()
2026-08-06 14:18:42 +08:00
def rowCount(self, parent=QModelIndex()) -> int:
if parent.isValid():
return 0
return len(self._rows) or self._placeholder_row_count
2026-08-06 14:18:42 +08:00
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.CheckStateRole and column == CHECK_COLUMN:
return (
Qt.Checked
if task.remote_task_id in self._checked_task_ids
else Qt.Unchecked
)
2026-08-06 14:18:42 +08:00
if role == Qt.DisplayRole:
return self._display_value(task, column)
if role == Qt.TextAlignmentRole:
if column in (0, 1, 7, 9):
2026-08-06 14:18:42 +08:00
return Qt.AlignCenter
if column in (5, 6):
2026-08-06 14:18:42 +08:00
return Qt.AlignRight | Qt.AlignVCenter
return Qt.AlignLeft | Qt.AlignVCenter
if role == Qt.ToolTipRole:
if column == 2:
2026-08-06 14:18:42 +08:00
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):
2026-08-06 14:18:42 +08:00
return Qt.NoItemFlags
flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if index.column() == CHECK_COLUMN:
flags |= Qt.ItemIsUserCheckable
return flags
def setData(self, index, value, role=Qt.EditRole):
"""切换任务勾选状态;业务身份始终使用远端任务编号。"""
if (
not index.isValid()
or index.column() != CHECK_COLUMN
or index.row() >= len(self._rows)
or role != Qt.CheckStateRole
):
return False
task_id = self._rows[index.row()].remote_task_id
if value == Qt.Checked:
self._checked_task_ids.add(task_id)
else:
self._checked_task_ids.discard(task_id)
self.dataChanged.emit(index, index, [Qt.CheckStateRole])
self.checkedTaskIdsChanged.emit(self.checked_task_ids())
return True
2026-08-06 14:18:42 +08:00
def canFetchMore(self, parent=QModelIndex()) -> bool:
"""告诉 Qt 数据库中是否还有下一页。"""
2026-08-06 14:18:42 +08:00
return not parent.isValid() and self._has_more and not self._loading
2026-08-06 14:18:42 +08:00
def fetchMore(self, parent=QModelIndex()) -> None:
"""请求事件层从数据库读取下一页。"""
2026-08-06 14:18:42 +08:00
if parent.isValid() or not self.canFetchMore(parent):
return
self._loading = True
self.loadMoreRequested.emit(len(self._rows), PAGE_SIZE)
2026-08-06 14:18:42 +08:00
def sort(self, column, order=Qt.AscendingOrder) -> None:
"""列表顺序由 Repository 固定为更新时间倒序。"""
2026-08-06 14:18:42 +08:00
def set_tasks(self, tasks: Iterable[TaskRow]) -> None:
"""替换全部任务;保留给简单测试和静态预览使用。"""
2026-08-06 14:18:42 +08:00
self.beginResetModel()
self._rows = list(tasks)
self._has_more = False
self._loading = False
2026-08-06 14:18:42 +08:00
self.endResetModel()
self._prune_checked_task_ids()
2026-08-06 14:18:42 +08:00
def reset_for_reload(self) -> None:
"""清空旧结果,准备从数据库重新读取第一页。"""
2026-08-06 14:18:42 +08:00
self.beginResetModel()
self._rows = []
self._has_more = True
self._loading = False
2026-08-06 14:18:42 +08:00
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()
if not has_more:
self._prune_checked_task_ids()
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
if not has_more:
self._prune_checked_task_ids()
def finish_loading(self) -> None:
"""查询失败时停止继续请求,等待用户刷新或重新搜索。"""
self._has_more = False
self._loading = False
2026-08-06 14:18:42 +08:00
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)
2026-08-06 14:18:42 +08:00
def data_row_count(self) -> int:
"""返回真实任务行数,不包含填满空表格的占位行。"""
return len(self._rows)
def checked_task_ids(self) -> tuple[str, ...]:
"""按当前列表顺序返回已勾选的稳定任务编号。"""
loaded_order = [
task.remote_task_id
for task in self._rows
if task.remote_task_id in self._checked_task_ids
]
loaded = set(loaded_order)
return tuple(loaded_order) + tuple(sorted(self._checked_task_ids - loaded))
def clear_checked_tasks(self) -> None:
"""清空全部勾选,并只刷新复选框列。"""
if not self._checked_task_ids:
return
self._checked_task_ids.clear()
if self._rows:
self.dataChanged.emit(
self.index(0, CHECK_COLUMN),
self.index(len(self._rows) - 1, CHECK_COLUMN),
[Qt.CheckStateRole],
)
self.checkedTaskIdsChanged.emit(())
def uncheck_tasks(self, remote_task_ids: Iterable[str]) -> None:
"""取消指定任务的勾选,供批量操作成功后更新界面。"""
removed = self._checked_task_ids.intersection(remote_task_ids)
if not removed:
return
self._checked_task_ids.difference_update(removed)
for row, task in enumerate(self._rows):
if task.remote_task_id in removed:
index = self.index(row, CHECK_COLUMN)
self.dataChanged.emit(index, index, [Qt.CheckStateRole])
self.checkedTaskIdsChanged.emit(self.checked_task_ids())
def _prune_checked_task_ids(self) -> None:
"""完整加载结束后移除本地已不存在的勾选。"""
loaded = {task.remote_task_id for task in self._rows}
removed = self._checked_task_ids - loaded
if not removed:
return
self._checked_task_ids.intersection_update(loaded)
self.checkedTaskIdsChanged.emit(self.checked_task_ids())
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()
2026-08-06 14:18:42 +08:00
@staticmethod
def _display_value(task: TaskRow, column: int) -> str:
if column == CHECK_COLUMN:
return ""
2026-08-06 14:18:42 +08:00
if column == 1:
return task.task_type
2026-08-06 14:18:42 +08:00
if column == 2:
return task.title or "尚未获取标题"
2026-08-06 14:18:42 +08:00
if column == 3:
return task.color or "—"
2026-08-06 14:18:42 +08:00
if column == 4:
return task.size or "—"
if column == 5:
2026-08-06 14:18:42 +08:00
if task.price_cents is None:
return "—"
suffix = " 起" if task.price_is_starting else ""
return f"¥{task.price_cents / 100:.2f}{suffix}"
if column == 6:
return "—" if task.quantity is None else str(task.quantity)
2026-08-06 14:18:42 +08:00
if column == 7:
return task.status
if column == 8:
2026-08-06 14:18:42 +08:00
return task.updated_at or "—"
return "详情"
2026-08-06 12:06:16 +08:00
class PDDTaskPage(QWidget):
2026-08-06 14:18:42 +08:00
"""展示、筛选本机任务,并把用户操作转发给事件层。"""
autoFetchRequested = pyqtSignal()
searchRequested = pyqtSignal(dict)
refreshRequested = pyqtSignal()
rerunRequested = pyqtSignal(str)
2026-08-10 09:08:54 +08:00
rerunCancelRequested = pyqtSignal()
resubmitRequested = pyqtSignal(object)
openSettingsRequested = pyqtSignal()
2026-08-06 14:18:42 +08:00
detailRequested = pyqtSignal(str)
2026-08-06 12:06:16 +08:00
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("pddTaskPage")
2026-08-10 09:08:54 +08:00
self._rerun_state = "idle"
self._resubmit_running = False
2026-08-06 14:18:42 +08:00
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()
2026-08-06 12:06:16 +08:00
2026-08-06 14:18:42 +08:00
def _build_command_area(self) -> None:
self.autoFetchButton = PrimaryPushButton(
FIF.PLAY, "自动获取", self
2026-08-06 14:18:42 +08:00
)
self.autoFetchButton.setMinimumWidth(132)
self.autoFetchButton.setAccessibleName("自动获取 PDD 任务")
2026-08-06 12:06:16 +08:00
self.taskTypeLabel = CaptionLabel("任务类型", self)
2026-08-06 14:18:42 +08:00
self.taskTypeCombo = ComboBox(self)
self.taskTypeCombo.addItems(("全部", "采集", "采购"))
self.taskTypeCombo.setMinimumWidth(100)
self.taskTypeCombo.setAccessibleName("任务类型")
2026-08-06 12:06:16 +08:00
self.statusLabelCaption = CaptionLabel("任务状态", self)
2026-08-06 14:18:42 +08:00
self.statusCombo = ComboBox(self)
self.statusCombo.addItems(
("全部", "待执行", "执行中", "已完成", "失败", "需要人工处理")
)
self.statusCombo.setMinimumWidth(130)
self.statusCombo.setAccessibleName("任务状态")
2026-08-06 12:06:16 +08:00
self.keywordLabel = CaptionLabel("关键词", self)
2026-08-06 14:18:42 +08:00
self.keywordInput = SearchLineEdit(self)
self.keywordInput.setPlaceholderText("任务编号、商品编号或商品标题")
self.keywordInput.setClearButtonEnabled(True)
self.keywordInput.setMinimumWidth(150)
self.keywordInput.setMaximumWidth(240)
self.keywordInput.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
2026-08-06 14:18:42 +08:00
self.keywordInput.setAccessibleName("任务关键词")
2026-08-06 12:06:16 +08:00
2026-08-06 14:18:42 +08:00
self.searchButton = PushButton(FIF.SEARCH, "搜索", self)
self.searchButton.setAccessibleName("搜索本地任务")
2026-08-06 12:06:16 +08:00
2026-08-06 14:18:42 +08:00
self.refreshButton = PushButton(FIF.SYNC, "刷新", self)
self.refreshButton.setAccessibleName("刷新本地任务表格")
2026-08-06 12:06:16 +08:00
self.rerunButton = PushButton(FIF.UPDATE, "重新执行", self)
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务")
self.rerunButton.setEnabled(False)
self.resubmitButton = PushButton(FIF.SEND, "重新上报", self)
self.resubmitButton.setAccessibleName("重新上报勾选任务的已有结果")
self.resubmitButton.setEnabled(False)
2026-08-06 14:18:42 +08:00
self.commandCard = CardWidget(self)
self.checkedCountLabel = CaptionLabel("已选 0 条", self.commandCard)
self.checkedCountLabel.setAccessibleName("已勾选任务数量")
self.commandLayout = QGridLayout(self.commandCard)
self.commandLayout.setContentsMargins(20, 14, 20, 14)
self.commandLayout.setHorizontalSpacing(12)
self.commandLayout.setVerticalSpacing(10)
self._compact_commands = False
# 先使用安全的双行布局,窗口真正变宽后再切换为单行。
self._update_command_layout(compact=True)
def _update_command_layout(self, compact: bool) -> None:
"""在窄窗口把搜索区换到第二行,不重建任何控件。"""
if compact == self._compact_commands and self.commandLayout.count():
return
self._compact_commands = compact
for column in range(13):
self.commandLayout.setColumnStretch(column, 0)
if compact:
positions = (
(self.autoFetchButton, 0, 0, 1, 1),
(self.taskTypeLabel, 0, 1, 1, 1),
(self.taskTypeCombo, 0, 2, 1, 1),
(self.statusLabelCaption, 0, 3, 1, 1),
(self.statusCombo, 0, 4, 1, 1),
(self.checkedCountLabel, 0, 6, 1, 1),
(self.rerunButton, 0, 7, 1, 1),
(self.resubmitButton, 0, 8, 1, 1),
(self.keywordLabel, 1, 0, 1, 1),
(self.keywordInput, 1, 1, 1, 5),
(self.searchButton, 1, 6, 1, 1),
(self.refreshButton, 1, 7, 1, 1),
)
self.commandLayout.setColumnStretch(5, 1)
else:
positions = (
(self.autoFetchButton, 0, 0, 1, 1),
(self.taskTypeLabel, 0, 1, 1, 1),
(self.taskTypeCombo, 0, 2, 1, 1),
(self.statusLabelCaption, 0, 3, 1, 1),
(self.statusCombo, 0, 4, 1, 1),
(self.keywordLabel, 0, 5, 1, 1),
(self.keywordInput, 0, 6, 1, 1),
(self.searchButton, 0, 7, 1, 1),
(self.refreshButton, 0, 8, 1, 1),
(self.checkedCountLabel, 0, 10, 1, 1),
(self.rerunButton, 0, 11, 1, 1),
(self.resubmitButton, 0, 12, 1, 1),
)
self.commandLayout.setColumnStretch(6, 4)
self.commandLayout.setColumnStretch(9, 1)
for widget, row, column, row_span, column_span in positions:
self.commandLayout.addWidget(
widget, row, column, row_span, column_span
)
2026-08-06 14:18:42 +08:00
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)
2026-08-06 14:18:42 +08:00
self.taskTable.verticalHeader().setVisible(False)
header = self.taskTable.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Interactive)
header.setSectionResizeMode(2, QHeaderView.Stretch)
2026-08-06 14:18:42 +08:00
header.setMinimumSectionSize(64)
self.taskTable.setColumnWidth(0, 64)
self.taskTable.setColumnWidth(1, 82)
self.taskTable.setColumnWidth(3, 108)
self.taskTable.setColumnWidth(4, 82)
self.taskTable.setColumnWidth(5, 96)
self.taskTable.setColumnWidth(6, 70)
self.taskTable.setColumnWidth(7, 116)
self.taskTable.setColumnWidth(8, 156)
self.taskTable.setColumnWidth(9, 72)
self.taskTable.viewport().installEventFilter(self)
2026-08-06 14:18:42 +08:00
self.emptyStateCard = CardWidget(self)
emptyLayout = QHBoxLayout(self.emptyStateCard)
emptyLayout.setContentsMargins(16, 10, 16, 10)
emptyTextLayout = QVBoxLayout()
emptyTextLayout.setSpacing(2)
2026-08-06 14:36:35 +08:00
self.emptyTitleLabel = SubtitleLabel("", self.emptyStateCard)
self.emptyMessageLabel = BodyLabel("", self.emptyStateCard)
2026-08-06 14:18:42 +08:00
self.emptyMessageLabel.setWordWrap(True)
emptyTextLayout.addWidget(self.emptyTitleLabel)
emptyTextLayout.addWidget(self.emptyMessageLabel)
self.clearFiltersButton = PushButton(
"清除筛选条件", self.emptyStateCard
)
2026-08-06 14:18:42 +08:00
self.clearFiltersButton.setAccessibleName("清除全部任务筛选条件")
emptyLayout.addLayout(emptyTextLayout, 1)
emptyLayout.addWidget(self.clearFiltersButton)
2026-08-06 14:18:42 +08:00
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:
2026-08-06 12:06:16 +08:00
layout = QVBoxLayout(self)
layout.setContentsMargins(36, 30, 36, 36)
layout.setSpacing(16)
2026-08-06 14:18:42 +08:00
layout.addWidget(TitleLabel("PDD 任务", self))
layout.addWidget(self.commandCard)
layout.addWidget(self.emptyStateCard)
layout.addWidget(self.taskTable, 1)
2026-08-06 14:18:42 +08:00
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.rerunButton.clicked.connect(self._request_rerun)
self.resubmitButton.clicked.connect(self._request_resubmit)
2026-08-06 14:18:42 +08:00
self.clearFiltersButton.clicked.connect(self.clear_filters)
self.taskTable.clicked.connect(self._on_table_clicked)
self.taskTable.activated.connect(self._on_table_activated)
self.taskModel.checkedTaskIdsChanged.connect(self._update_checked_actions)
2026-08-06 14:18:42 +08:00
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 resizeEvent(self, event) -> None:
"""按内容区宽度切换顶部命令区的单行或双行布局。"""
self._update_command_layout(compact=event.size().width() < 1180)
super().resizeEvent(event)
2026-08-06 14:18:42 +08:00
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 = ""
2026-08-06 14:18:42 +08:00
def set_loading(self) -> None:
"""显示正在读取本地任务的状态。"""
self._show_empty_state("正在加载任务", "正在读取本地任务,请稍候。", False)
def set_load_error(self, message: str) -> None:
"""显示本地任务加载失败状态,不清除当前筛选条件。"""
self.taskModel.finish_loading()
2026-08-06 14:18:42 +08:00
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),
2026-08-06 14:18:42 +08:00
"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)
self.autoFetchButton.setAccessibleName(f"{text} PDD 任务")
2026-08-06 14:18:42 +08:00
def set_engine_status(self, text: str) -> None:
"""更新页面底部的任务引擎状态说明。"""
self.statusLabel.setText(text or "自动获取:已停止 · 当前没有执行中的任务")
def clear_filters(self) -> None:
"""清除全部搜索条件并重新查询本地任务。"""
2026-08-06 14:18:42 +08:00
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 set_rerun_running(self, running: bool) -> None:
2026-08-10 09:08:54 +08:00
"""设置重新采集状态;运行时保留可见的停止入口。"""
2026-08-10 09:08:54 +08:00
self._rerun_state = "running" if running else "idle"
self._update_rerun_button()
def set_resubmit_running(self, running: bool) -> None:
"""设置重新上报忙碌状态并防止重复提交。"""
self._resubmit_running = running
self._update_checked_actions(self.taskModel.checked_task_ids())
2026-08-10 09:08:54 +08:00
def set_rerun_cancelling(self) -> None:
"""显示取消请求已发出,防止用户重复点击。"""
self._rerun_state = "cancelling"
self._update_rerun_button()
def _request_rerun(self) -> None:
2026-08-10 09:08:54 +08:00
if self._rerun_state == "running":
self.rerunCancelRequested.emit()
return
if self._rerun_state != "idle":
return
task_ids = self.taskModel.checked_task_ids()
if len(task_ids) == 1:
self.rerunRequested.emit(task_ids[0])
def _request_resubmit(self) -> None:
if self._resubmit_running:
return
task_ids = self.taskModel.checked_task_ids()
if task_ids:
self.resubmitRequested.emit(task_ids)
def _update_rerun_button(self) -> None:
2026-08-10 09:08:54 +08:00
if self._rerun_state == "running":
self.rerunButton.setText("停止重新采集")
self.rerunButton.setAccessibleName("停止当前重新采集")
self.rerunButton.setEnabled(True)
self.resubmitButton.setEnabled(False)
2026-08-10 09:08:54 +08:00
self.autoFetchButton.setEnabled(False)
return
if self._rerun_state == "cancelling":
self.rerunButton.setText("正在停止…")
self.rerunButton.setAccessibleName("正在停止重新采集")
self.rerunButton.setEnabled(False)
self.resubmitButton.setEnabled(False)
2026-08-10 09:08:54 +08:00
self.autoFetchButton.setEnabled(False)
return
self.rerunButton.setText("重新执行")
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务")
self.autoFetchButton.setEnabled(not self._resubmit_running)
self.rerunButton.setEnabled(
not self._resubmit_running
and len(self.taskModel.checked_task_ids()) == 1
)
self.resubmitButton.setEnabled(
not self._resubmit_running
and bool(self.taskModel.checked_task_ids())
)
def _update_checked_actions(self, task_ids=None) -> None:
"""同步勾选数量和批量命令启用状态。"""
checked = tuple(task_ids or ())
self.checkedCountLabel.setText(f"已选 {len(checked)} 条")
self.resubmitButton.setText(
"正在上报…" if self._resubmit_running else "重新上报"
)
self.resubmitButton.setEnabled(bool(checked) and not self._resubmit_running)
self._update_rerun_button()
2026-08-06 14:18:42 +08:00
def _apply_filters(self) -> None:
self.taskModel.clear_checked_tasks()
2026-08-06 14:18:42 +08:00
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()
2026-08-06 14:18:42 +08:00
return
if self._has_active_filters():
self._show_empty_state(
"没有符合条件的任务",
"请调整搜索条件,或者清除筛选后查看全部本地任务。",
True,
)
return
2026-08-06 14:36:35 +08:00
self.emptyStateCard.hide()
self._update_placeholder_rows()
2026-08-06 14:18:42 +08:00
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)