572 lines
21 KiB
Python
572 lines
21 KiB
Python
"""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
|
||
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)
|
||
|
||
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
|
||
|
||
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.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):
|
||
return Qt.NoItemFlags
|
||
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
|
||
|
||
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()
|
||
|
||
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()
|
||
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
|
||
|
||
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 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 == 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 "详情"
|
||
|
||
|
||
class PDDTaskPage(QWidget):
|
||
"""展示、筛选本机任务,并把用户操作转发给事件层。"""
|
||
|
||
autoFetchRequested = pyqtSignal()
|
||
searchRequested = pyqtSignal(dict)
|
||
refreshRequested = pyqtSignal()
|
||
detailRequested = pyqtSignal(str)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setObjectName("pddTaskPage")
|
||
|
||
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.taskTypeCombo = ComboBox(self)
|
||
self.taskTypeCombo.addItems(("全部", "采集", "采购"))
|
||
self.taskTypeCombo.setMinimumWidth(100)
|
||
self.taskTypeCombo.setAccessibleName("任务类型")
|
||
|
||
self.statusCombo = ComboBox(self)
|
||
self.statusCombo.addItems(
|
||
("全部", "待执行", "执行中", "已完成", "失败", "需要人工处理")
|
||
)
|
||
self.statusCombo.setMinimumWidth(130)
|
||
self.statusCombo.setAccessibleName("任务状态")
|
||
|
||
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)
|
||
self.keywordInput.setAccessibleName("任务关键词")
|
||
|
||
self.searchButton = PushButton(FIF.SEARCH, "搜索", self)
|
||
self.searchButton.setAccessibleName("搜索本地任务")
|
||
|
||
self.refreshButton = PushButton(FIF.SYNC, "刷新", self)
|
||
self.refreshButton.setAccessibleName("刷新本地任务表格")
|
||
|
||
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)
|
||
|
||
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(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)
|
||
|
||
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.clearFiltersButton.clicked.connect(self.clear_filters)
|
||
self.taskTable.clicked.connect(self._on_table_clicked)
|
||
self.taskTable.activated.connect(self._on_table_activated)
|
||
|
||
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 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)
|
||
|
||
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 _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()
|
||
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)
|