Files
cmautobuy/client/src/pdd_ui.py
T

837 lines
31 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
CHECK_COLUMN = 0
DETAIL_COLUMN = 9
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)
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
self._checked_task_ids: set[str] = set()
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.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, 1, 7, 9):
return Qt.AlignCenter
if column in (5, 6):
return Qt.AlignRight | Qt.AlignVCenter
return Qt.AlignLeft | Qt.AlignVCenter
if role == Qt.ToolTipRole:
if column == 2:
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
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 数据库中是否还有下一页。"""
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()
self._prune_checked_task_ids()
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()
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
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 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()
@staticmethod
def _display_value(task: TaskRow, column: int) -> str:
if column == CHECK_COLUMN:
return ""
if column == 1:
return task.task_type
if column == 2:
return task.title or "尚未获取标题"
if column == 3:
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 == 6:
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 "详情"
class PDDTaskPage(QWidget):
"""展示、筛选本机任务,并把用户操作转发给事件层。"""
autoFetchRequested = pyqtSignal()
searchRequested = pyqtSignal(dict)
refreshRequested = pyqtSignal()
rerunRequested = pyqtSignal(str)
rerunCancelRequested = pyqtSignal()
resubmitRequested = pyqtSignal(object)
removeRequested = pyqtSignal(object)
openSettingsRequested = pyqtSignal()
detailRequested = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("pddTaskPage")
self._rerun_state = "idle"
self._resubmit_running = False
self._remove_running = False
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.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(
("全部", "待执行", "执行中", "已完成", "失败", "需要人工处理")
)
self.statusCombo.setMinimumWidth(130)
self.statusCombo.setAccessibleName("任务状态")
self.keywordLabel = CaptionLabel("关键词", self)
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)
self.keywordInput.setAccessibleName("任务关键词")
self.searchButton = PushButton(FIF.SEARCH, "搜索", self)
self.searchButton.setAccessibleName("搜索本地任务")
self.refreshButton = PushButton(FIF.SYNC, "刷新", self)
self.refreshButton.setAccessibleName("刷新本地任务表格")
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)
self.removeButton = PushButton(FIF.DELETE, "删除", self)
self.removeButton.setAccessibleName("从任务列表删除勾选的本地任务")
self.removeButton.setEnabled(False)
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(14):
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.removeButton, 0, 9, 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.removeButton, 0, 13, 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)
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(2, QHeaderView.Stretch)
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)
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.rerunButton.clicked.connect(self._request_rerun)
self.resubmitButton.clicked.connect(self._request_resubmit)
self.removeButton.clicked.connect(self._request_remove)
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)
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)
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 and not self._resubmit_running and not self._remove_running
)
self.autoFetchButton.setAccessibleName(f"{text} PDD 任务")
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 set_rerun_running(self, running: bool) -> None:
"""设置重新采集状态;运行时保留可见的停止入口。"""
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_remove_running(self, running: bool) -> None:
"""设置本地任务删除忙碌状态并防止重复操作。"""
self._remove_running = running
self._update_checked_actions(self.taskModel.checked_task_ids())
def set_rerun_cancelling(self) -> None:
"""显示取消请求已发出,防止用户重复点击。"""
self._rerun_state = "cancelling"
self._update_rerun_button()
def _request_rerun(self) -> None:
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 or self._remove_running:
return
task_ids = self.taskModel.checked_task_ids()
if task_ids:
self.resubmitRequested.emit(task_ids)
def _request_remove(self) -> None:
if self._remove_running or self._resubmit_running:
return
task_ids = self.taskModel.checked_task_ids()
if task_ids:
self.removeRequested.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.removeButton.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.removeButton.setEnabled(False)
self.autoFetchButton.setEnabled(False)
return
self.rerunButton.setText("重新执行")
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务")
idle = not self._resubmit_running and not self._remove_running
self.autoFetchButton.setEnabled(idle)
self.rerunButton.setEnabled(
idle and len(self.taskModel.checked_task_ids()) == 1
)
self.resubmitButton.setEnabled(
idle and bool(self.taskModel.checked_task_ids())
)
self.removeButton.setEnabled(
idle 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.removeButton.setText(
"正在删除…" if self._remove_running else "删除"
)
self._update_rerun_button()
def _apply_filters(self) -> None:
self.taskModel.clear_checked_tasks()
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)