feat: 增加任务批量勾选和重新上报 (#89)

This commit is contained in:
chengma
2026-08-10 10:56:48 +08:00
parent d017062d82
commit 34ff39a200
8 changed files with 734 additions and 60 deletions
+223 -46
View File
@@ -56,7 +56,8 @@ from qfluentwidgets import (
PAGE_SIZE = 50
DETAIL_COLUMN = 8
CHECK_COLUMN = 0
DETAIL_COLUMN = 9
REMOTE_TASK_ID_ROLE = Qt.UserRole + 1
@@ -81,8 +82,10 @@ class TaskTableModel(QAbstractTableModel):
"""PDD 任务表格模型,只保存已经从数据库读取的轻量行。"""
loadMoreRequested = pyqtSignal(int, int)
checkedTaskIdsChanged = pyqtSignal(object)
HEADERS = (
"选择",
"任务类型",
"商品标题",
"颜色",
@@ -100,6 +103,7 @@ class TaskTableModel(QAbstractTableModel):
self._has_more = False
self._loading = False
self._placeholder_row_count = 1
self._checked_task_ids: set[str] = set()
def rowCount(self, parent=QModelIndex()) -> int:
if parent.isValid():
@@ -121,18 +125,25 @@ class TaskTableModel(QAbstractTableModel):
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
)
if role == Qt.DisplayRole:
return self._display_value(task, column)
if role == Qt.TextAlignmentRole:
if column in (0, 6, 8):
if column in (0, 1, 7, 9):
return Qt.AlignCenter
if column in (4, 5):
if column in (5, 6):
return Qt.AlignRight | Qt.AlignVCenter
return Qt.AlignLeft | Qt.AlignVCenter
if role == Qt.ToolTipRole:
if column == 1:
if column == 2:
return task.title or "尚未获取标题"
if column == DETAIL_COLUMN:
return f"查看任务 {task.remote_task_id} 的详情"
@@ -145,7 +156,30 @@ class TaskTableModel(QAbstractTableModel):
def flags(self, index):
if not index.isValid() or index.row() >= len(self._rows):
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
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
def canFetchMore(self, parent=QModelIndex()) -> bool:
"""告诉 Qt 数据库中是否还有下一页。"""
@@ -172,6 +206,7 @@ class TaskTableModel(QAbstractTableModel):
self._has_more = False
self._loading = False
self.endResetModel()
self._prune_checked_task_ids()
def reset_for_reload(self) -> None:
"""清空旧结果,准备从数据库重新读取第一页。"""
@@ -192,6 +227,8 @@ class TaskTableModel(QAbstractTableModel):
self._has_more = has_more
self._loading = False
self.endResetModel()
if not has_more:
self._prune_checked_task_ids()
return
if page:
@@ -201,6 +238,8 @@ class TaskTableModel(QAbstractTableModel):
self.endInsertRows()
self._has_more = has_more
self._loading = False
if not has_more:
self._prune_checked_task_ids()
def finish_loading(self) -> None:
"""查询失败时停止继续请求,等待用户刷新或重新搜索。"""
@@ -231,6 +270,54 @@ class TaskTableModel(QAbstractTableModel):
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:
"""设置无数据时显示的禁用空行数量。"""
@@ -248,24 +335,26 @@ class TaskTableModel(QAbstractTableModel):
@staticmethod
def _display_value(task: TaskRow, column: int) -> str:
if column == 0:
return task.task_type
if column == CHECK_COLUMN:
return ""
if column == 1:
return task.title or "尚未获取标题"
return task.task_type
if column == 2:
return task.color or "—"
return task.title or "尚未获取标题"
if column == 3:
return task.size or "—"
return task.color or "—"
if column == 4:
return task.size or "—"
if column == 5:
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
return "—" if task.quantity is None else str(task.quantity)
if column == 7:
return task.status
if column == 8:
return task.updated_at or "—"
return "详情"
@@ -278,6 +367,7 @@ class PDDTaskPage(QWidget):
refreshRequested = pyqtSignal()
rerunRequested = pyqtSignal(str)
rerunCancelRequested = pyqtSignal()
resubmitRequested = pyqtSignal(object)
openSettingsRequested = pyqtSignal()
detailRequested = pyqtSignal(str)
@@ -286,6 +376,7 @@ class PDDTaskPage(QWidget):
self.setObjectName("pddTaskPage")
self._rerun_state = "idle"
self._resubmit_running = False
self.taskModel = TaskTableModel(self)
self._build_command_area()
self._build_content_area()
@@ -301,11 +392,13 @@ class PDDTaskPage(QWidget):
self.autoFetchButton.setMinimumWidth(132)
self.autoFetchButton.setAccessibleName("开始自动获取 PDD 任务")
self.taskTypeLabel = CaptionLabel("任务类型", self)
self.taskTypeCombo = ComboBox(self)
self.taskTypeCombo.addItems(("全部", "采集", "采购"))
self.taskTypeCombo.setMinimumWidth(100)
self.taskTypeCombo.setAccessibleName("任务类型")
self.statusLabelCaption = CaptionLabel("任务状态", self)
self.statusCombo = ComboBox(self)
self.statusCombo.addItems(
("全部", "待执行", "执行中", "已完成", "失败", "需要人工处理")
@@ -313,6 +406,7 @@ class PDDTaskPage(QWidget):
self.statusCombo.setMinimumWidth(130)
self.statusCombo.setAccessibleName("任务状态")
self.keywordLabel = CaptionLabel("关键词", self)
self.keywordInput = SearchLineEdit(self)
self.keywordInput.setPlaceholderText("任务编号、商品编号或商品标题")
self.keywordInput.setClearButtonEnabled(True)
@@ -331,22 +425,68 @@ class PDDTaskPage(QWidget):
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务")
self.rerunButton.setEnabled(False)
self.resubmitButton = PushButton(FIF.SEND, "重新上报", self)
self.resubmitButton.setAccessibleName("重新上报勾选任务的已有结果")
self.resubmitButton.setEnabled(False)
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)
commandLayout.addWidget(self.rerunButton, 0, 10)
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
)
def _build_content_area(self) -> None:
self.taskTable = TableView(self)
@@ -362,16 +502,17 @@ class PDDTaskPage(QWidget):
header = self.taskTable.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Interactive)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, 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.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)
self.emptyStateCard = CardWidget(self)
@@ -420,12 +561,11 @@ class PDDTaskPage(QWidget):
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)
self.clearFiltersButton.clicked.connect(self.clear_filters)
self.taskTable.clicked.connect(self._on_table_clicked)
self.taskTable.activated.connect(self._on_table_activated)
self.taskTable.selectionModel().selectionChanged.connect(
self._update_rerun_button
)
self.taskModel.checkedTaskIdsChanged.connect(self._update_checked_actions)
self.findShortcut = QShortcut(QKeySequence.Find, self)
self.findShortcut.activated.connect(self.keywordInput.setFocus)
@@ -435,6 +575,12 @@ class PDDTaskPage(QWidget):
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)
def set_tasks(self, tasks: Iterable[TaskRow]) -> None:
"""显示任务数据,并尽量恢复原来选中的稳定任务编号。"""
@@ -516,6 +662,12 @@ class PDDTaskPage(QWidget):
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())
def set_rerun_cancelling(self) -> None:
"""显示取消请求已发出,防止用户重复点击。"""
@@ -528,33 +680,58 @@ class PDDTaskPage(QWidget):
return
if self._rerun_state != "idle":
return
task_id = self.current_task_id()
if task_id:
self.rerunRequested.emit(task_id)
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:
if self._rerun_state == "running":
self.rerunButton.setText("停止重新采集")
self.rerunButton.setAccessibleName("停止当前重新采集")
self.rerunButton.setEnabled(True)
self.resubmitButton.setEnabled(False)
self.autoFetchButton.setEnabled(False)
return
if self._rerun_state == "cancelling":
self.rerunButton.setText("正在停止…")
self.rerunButton.setAccessibleName("正在停止重新采集")
self.rerunButton.setEnabled(False)
self.resubmitButton.setEnabled(False)
self.autoFetchButton.setEnabled(False)
return
self.rerunButton.setText("重新执行")
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务")
self.autoFetchButton.setEnabled(True)
has_selection = self.taskTable.selectionModel().hasSelection()
self.autoFetchButton.setEnabled(not self._resubmit_running)
self.rerunButton.setEnabled(
has_selection and bool(self.current_task_id())
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()
def _apply_filters(self) -> None:
self.taskModel.clear_checked_tasks()
filters = {
"task_type": self.taskTypeCombo.currentText(),
"status": self.statusCombo.currentText(),