Files
cmautobuy/client/src/pdd_ui.py
T

629 lines
23 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
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 任务表格模型,只保存已经从数据库读取的轻量行。"""
loadMoreRequested = pyqtSignal(int, int)
2026-08-06 14:18:42 +08:00
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
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.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() or index.row() >= len(self._rows):
2026-08-06 14:18:42 +08:00
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
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()
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()
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
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 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 == 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 "详情"
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()
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"
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
)
self.autoFetchButton.setMinimumWidth(132)
self.autoFetchButton.setAccessibleName("开始自动获取 PDD 任务")
2026-08-06 12:06:16 +08:00
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
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
2026-08-06 14:18:42 +08:00
self.keywordInput = SearchLineEdit(self)
self.keywordInput.setPlaceholderText("任务编号、商品编号或商品标题")
self.keywordInput.setClearButtonEnabled(True)
self.keywordInput.setMinimumWidth(190)
self.keywordInput.setMaximumWidth(480)
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)
2026-08-06 14:18:42 +08:00
self.commandCard = CardWidget(self)
commandLayout = QGridLayout(self.commandCard)
2026-08-06 14:36:35 +08:00
commandLayout.setContentsMargins(20, 14, 20, 14)
2026-08-06 14:18:42 +08:00
commandLayout.setHorizontalSpacing(12)
2026-08-06 14:36:35 +08:00
commandLayout.addWidget(self.autoFetchButton, 0, 0)
2026-08-06 14:18:42 +08:00
commandLayout.addWidget(CaptionLabel("任务类型", self.commandCard), 0, 1)
2026-08-06 14:36:35 +08:00
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)
2026-08-06 14:36:35 +08:00
commandLayout.setColumnStretch(8, 1)
commandLayout.addWidget(self.refreshButton, 0, 9)
commandLayout.addWidget(self.rerunButton, 0, 10)
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(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.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)
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.taskTable.selectionModel().selectionChanged.connect(
self._update_rerun_button
)
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)
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),
"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:
"""清除全部搜索条件并重新查询本地任务。"""
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_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_id = self.current_task_id()
if task_id:
self.rerunRequested.emit(task_id)
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.autoFetchButton.setEnabled(False)
return
if self._rerun_state == "cancelling":
self.rerunButton.setText("正在停止…")
self.rerunButton.setAccessibleName("正在停止重新采集")
self.rerunButton.setEnabled(False)
self.autoFetchButton.setEnabled(False)
return
self.rerunButton.setText("重新执行")
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务")
self.autoFetchButton.setEnabled(True)
has_selection = self.taskTable.selectionModel().hasSelection()
self.rerunButton.setEnabled(
2026-08-10 09:08:54 +08:00
has_selection and bool(self.current_task_id())
)
2026-08-06 14:18:42 +08:00
def _apply_filters(self) -> None:
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)