"""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)