feat(client): connect PDD task list to SQLite (#9)

This commit is contained in:
chengma
2026-08-06 16:31:34 +08:00
parent be6ee3d880
commit 583e441e17
4 changed files with 334 additions and 90 deletions
+68 -89
View File
@@ -78,7 +78,9 @@ class TaskRow:
class TaskTableModel(QAbstractTableModel):
"""PDD 任务表格模型,当前使用内存数据模拟 Repository 查询结果。"""
"""PDD 任务表格模型,只保存已经从数据库读取的轻量行。"""
loadMoreRequested = pyqtSignal(int, int)
HEADERS = (
"任务类型",
@@ -94,14 +96,9 @@ class TaskTableModel(QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
self._source_rows: list[TaskRow] = []
self._filtered_rows: list[TaskRow] = []
self._rows: list[TaskRow] = []
self._task_type = "全部"
self._status = "全部"
self._keyword = ""
self._sort_column = 7
self._sort_order = Qt.DescendingOrder
self._has_more = False
self._loading = False
self._placeholder_row_count = 1
def rowCount(self, parent=QModelIndex()) -> int:
@@ -151,54 +148,66 @@ class TaskTableModel(QAbstractTableModel):
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def canFetchMore(self, parent=QModelIndex()) -> bool:
"""告诉 Qt 内存结果中是否还有下一页。"""
"""告诉 Qt 数据库中是否还有下一页。"""
return not parent.isValid() and len(self._rows) < len(self._filtered_rows)
return not parent.isValid() and self._has_more and not self._loading
def fetchMore(self, parent=QModelIndex()) -> None:
"""每次再展示最多 50 行,模拟后续 Repository 的分页行为。"""
"""请求事件层从数据库读取下一页。"""
if parent.isValid() or not self.canFetchMore(parent):
return
start = len(self._rows)
end = min(start + PAGE_SIZE, len(self._filtered_rows))
self.beginInsertRows(QModelIndex(), start, end - 1)
self._rows.extend(self._filtered_rows[start:end])
self.endInsertRows()
self._loading = True
self.loadMoreRequested.emit(len(self._rows), PAGE_SIZE)
def sort(self, column, order=Qt.AscendingOrder) -> None:
if not 0 <= column < len(self.HEADERS):
return
loaded_count = max(PAGE_SIZE, len(self._rows))
self.beginResetModel()
self._sort_column = column
self._sort_order = order
self._sort_filtered_rows()
self._rows = self._filtered_rows[:loaded_count]
self.endResetModel()
"""列表顺序由 Repository 固定为更新时间倒序。"""
def set_tasks(self, tasks: Iterable[TaskRow]) -> None:
"""替换内存任务列表;后续由 Repository 查询结果调用。"""
"""替换全部任务;保留给简单测试和静态预览使用。"""
self.beginResetModel()
self._source_rows = list(tasks)
self._rebuild_filtered_rows()
self._rows = self._filtered_rows[:PAGE_SIZE]
self._rows = list(tasks)
self._has_more = False
self._loading = False
self.endResetModel()
def apply_filters(self, task_type: str, status: str, keyword: str) -> None:
"""按类型、状态和关键词同时筛选当前内存任务。"""
def reset_for_reload(self) -> None:
"""清空旧结果,准备从数据库重新读取第一页。"""
self.beginResetModel()
self._task_type = task_type
self._status = status
self._keyword = keyword.strip().casefold()
self._rebuild_filtered_rows()
self._rows = self._filtered_rows[:PAGE_SIZE]
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。"""
@@ -215,7 +224,7 @@ class TaskTableModel(QAbstractTableModel):
return -1
def filtered_count(self) -> int:
return len(self._filtered_rows)
return len(self._rows)
def data_row_count(self) -> int:
"""返回真实任务行数,不包含填满空表格的占位行。"""
@@ -237,48 +246,6 @@ class TaskTableModel(QAbstractTableModel):
self._placeholder_row_count = count
self.endResetModel()
def _rebuild_filtered_rows(self) -> None:
self._filtered_rows = [
task for task in self._source_rows if self._matches_filters(task)
]
self._sort_filtered_rows()
def _matches_filters(self, task: TaskRow) -> bool:
if self._task_type != "全部" and task.task_type != self._task_type:
return False
if self._status != "全部" and task.status != self._status:
return False
if not self._keyword:
return True
searchable_text = " ".join(
(task.remote_task_id, task.goods_id, task.title)
).casefold()
return self._keyword in searchable_text
def _sort_filtered_rows(self) -> None:
reverse = self._sort_order == Qt.DescendingOrder
self._filtered_rows.sort(
key=lambda task: self._sort_value(task, self._sort_column),
reverse=reverse,
)
@staticmethod
def _sort_value(task: TaskRow, column: int):
values = (
task.task_type,
task.title,
task.color,
task.size,
task.price_cents if task.price_cents is not None else -1,
task.quantity if task.quantity is not None else -1,
task.status,
task.updated_at,
task.remote_task_id,
)
value = values[column]
return value.casefold() if isinstance(value, str) else value
@staticmethod
def _display_value(task: TaskRow, column: int) -> str:
if column == 0:
@@ -381,7 +348,7 @@ class PDDTaskPage(QWidget):
self.taskTable.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.taskTable.setAlternatingRowColors(True)
self.taskTable.setWordWrap(False)
self.taskTable.setSortingEnabled(True)
self.taskTable.setSortingEnabled(False)
self.taskTable.verticalHeader().setVisible(False)
header = self.taskTable.horizontalHeader()
@@ -396,7 +363,6 @@ class PDDTaskPage(QWidget):
self.taskTable.setColumnWidth(6, 116)
self.taskTable.setColumnWidth(7, 156)
self.taskTable.setColumnWidth(8, 72)
self.taskTable.sortByColumn(7, Qt.DescendingOrder)
self.taskTable.viewport().installEventFilter(self)
self.emptyStateCard = CardWidget(self)
@@ -464,6 +430,24 @@ class PDDTaskPage(QWidget):
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:
"""显示正在读取本地任务的状态。"""
@@ -472,6 +456,7 @@ class PDDTaskPage(QWidget):
def set_load_error(self, message: str) -> None:
"""显示本地任务加载失败状态,不清除当前筛选条件。"""
self.taskModel.finish_loading()
self._show_empty_state(
"任务加载失败",
message or "无法读取本地任务,请检查日志后重试。",
@@ -498,7 +483,7 @@ class PDDTaskPage(QWidget):
self.statusLabel.setText(text or "自动获取:已停止 · 当前没有执行中的任务")
def clear_filters(self) -> None:
"""清除全部搜索条件并恢复当前内存任务列表。"""
"""清除全部搜索条件并重新查询本地任务。"""
self.taskTypeCombo.setCurrentIndex(0)
self.statusCombo.setCurrentIndex(0)
@@ -513,17 +498,11 @@ class PDDTaskPage(QWidget):
return task.remote_task_id if task else ""
def _apply_filters(self) -> None:
current_task_id = self.current_task_id()
filters = {
"task_type": self.taskTypeCombo.currentText(),
"status": self.statusCombo.currentText(),
"keyword": self.keywordInput.text().strip(),
}
self.taskModel.apply_filters(
filters["task_type"], filters["status"], filters["keyword"]
)
self._update_empty_state()
self._restore_current_task(current_task_id)
self.searchRequested.emit(filters)
def _on_table_clicked(self, index: QModelIndex) -> None:
+115
View File
@@ -19,3 +19,118 @@
(模板见 `docs/client/05-ui-specification.md` §9.1);
只有必须让用户当场做决定时才用模态对话框。
"""
from typing import Dict, Optional
from PyQt5.QtCore import QObject
from .pdd_ui import PDDTaskPage, TaskRow
from .task_models import TaskFilters, TaskStatus, TaskSummary, TaskType
from .task_repository import TaskRepository
TASK_TYPE_BY_TEXT = {
"采集": TaskType.COLLECT,
"采购": TaskType.PURCHASE,
}
TASK_STATUS_BY_TEXT = {
"待执行": TaskStatus.CLAIMED,
"执行中": TaskStatus.RUNNING,
"结果待提交": TaskStatus.RESULT_PENDING,
"等待重试": TaskStatus.RETRY_WAIT,
"需要人工处理": TaskStatus.MANUAL_REVIEW,
"已完成": TaskStatus.SUCCEEDED,
"失败": TaskStatus.FAILED,
"已取消": TaskStatus.CANCELLED,
}
TASK_TYPE_TEXT = {
TaskType.COLLECT: "采集",
TaskType.PURCHASE: "采购",
}
TASK_STATUS_TEXT = {
TaskStatus.CLAIMED: "待执行",
TaskStatus.RUNNING: "执行中",
TaskStatus.RESULT_PENDING: "结果待提交",
TaskStatus.RETRY_WAIT: "等待重试",
TaskStatus.MANUAL_REVIEW: "需要人工处理",
TaskStatus.SUCCEEDED: "已完成",
TaskStatus.FAILED: "失败",
TaskStatus.CANCELLED: "已取消",
}
class PDDTaskPageEvent(QObject):
"""把 PDD 页面只读操作连接到本地任务 Repository。"""
def __init__(
self,
page: PDDTaskPage,
repository: Optional[TaskRepository] = None,
parent=None,
):
super().__init__(parent or page)
self._page = page
self._repository = repository or TaskRepository()
self._filters = TaskFilters()
page.searchRequested.connect(self.search_tasks)
page.refreshRequested.connect(self.refresh_tasks)
page.taskModel.loadMoreRequested.connect(self._load_page)
def load_initial_tasks(self) -> None:
"""应用启动后读取第一页本地任务。"""
self._reload()
def search_tasks(self, values: Dict[str, str]) -> None:
"""把界面中文筛选值转换成领域筛选,并重新查询。"""
self._filters = TaskFilters(
task_type=TASK_TYPE_BY_TEXT.get(values.get("task_type", "全部")),
status=TASK_STATUS_BY_TEXT.get(values.get("status", "全部")),
keyword=values.get("keyword", "").strip(),
)
self._reload()
def refresh_tasks(self) -> None:
"""使用当前筛选条件刷新列表。"""
self._reload()
def _reload(self) -> None:
self._page.begin_task_reload()
self._page.taskModel.fetchMore()
def _load_page(self, offset: int, limit: int) -> None:
try:
summaries = self._repository.list_tasks(
filters=self._filters,
limit=limit,
offset=offset,
)
except Exception:
self._page.set_load_error("无法读取本地任务,请检查数据库后重试。")
return
rows = [summary_to_row(summary) for summary in summaries]
self._page.append_task_page(rows, has_more=len(rows) == limit)
def summary_to_row(summary: TaskSummary) -> TaskRow:
"""把领域摘要转换成只供表格显示的轻量行。"""
return TaskRow(
remote_task_id=summary.remote_task_id,
task_type=TASK_TYPE_TEXT[summary.task_type],
goods_id=summary.goods_id or "",
title=summary.title or "",
color=summary.target_color or "",
size=summary.target_size or "",
price_cents=summary.price_cent,
quantity=summary.quantity,
status=TASK_STATUS_TEXT[summary.status],
updated_at=summary.updated_at,
)
+9 -1
View File
@@ -28,17 +28,24 @@ from qfluentwidgets import (
)
from .pdd_ui import PDDTaskPage
from .pdd_ui_event import PDDTaskPageEvent
from .settings_ui import SettingsPage
from .task_repository import TaskRepository
class MainWindow(FluentWindow):
"""应用主窗口。"""
def __init__(self):
def __init__(self, task_repository=None):
super().__init__()
self.pddTaskPage = PDDTaskPage(self)
self.settingsPage = SettingsPage(self)
self.pddTaskPageEvent = PDDTaskPageEvent(
self.pddTaskPage,
task_repository or TaskRepository(),
self,
)
self.addSubInterface(self.pddTaskPage, FIF.HOME, "pdd")
self.addSubInterface(
@@ -52,6 +59,7 @@ class MainWindow(FluentWindow):
self.setMinimumSize(820, 560)
self.resize(1040, 680)
self._moveToScreenCenter()
self.pddTaskPageEvent.load_initial_tasks()
def _moveToScreenCenter(self):
screen = QApplication.primaryScreen()
+142
View File
@@ -0,0 +1,142 @@
"""PDD 任务列表接入 SQLite 的离屏测试。"""
import os
import tempfile
import unittest
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt5.QtWidgets import QApplication
from src.pdd_ui import PDDTaskPage
from src.pdd_ui_event import PDDTaskPageEvent, summary_to_row
from src.task_models import NewClaimedTask, TaskStatus, TaskSummary, TaskType
from src.task_repository import TaskRepository
from src.ui_main import MainWindow
class BrokenRepository:
"""模拟无法读取数据库的 Repository。"""
def list_tasks(self, filters=None, limit=50, offset=0):
raise RuntimeError("database is unavailable")
class PDDTaskPageEventTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def setUp(self):
self.temp_directory = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp_directory.name) / "client.db"
self.repository = TaskRepository(self.db_path)
def tearDown(self):
self.temp_directory.cleanup()
def _add_task(
self,
number: int,
task_type: TaskType = TaskType.COLLECT,
title: str = "测试商品",
) -> None:
self.repository.add_claimed_task(
NewClaimedTask(
remote_task_id=f"PDD-{number:03d}",
task_type=task_type,
goods_url=f"pdd://goods/{number}",
goods_id=str(number),
title=title,
target_color="黑色",
target_size="M",
price_cent=3990,
quantity=1 if task_type is TaskType.PURCHASE else None,
),
received_at="2026-08-06T08:00:00Z",
)
def test_initial_load_and_fetch_next_page(self):
for number in range(51):
self._add_task(number)
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
events.load_initial_tasks()
self.assertEqual(page.taskModel.data_row_count(), 50)
self.assertEqual(page.taskModel.row_at(0).remote_task_id, "PDD-050")
self.assertTrue(page.taskModel.canFetchMore())
page.taskModel.fetchMore()
self.assertEqual(page.taskModel.data_row_count(), 51)
self.assertEqual(page.taskModel.row_at(50).remote_task_id, "PDD-000")
self.assertFalse(page.taskModel.canFetchMore())
page.deleteLater()
def test_search_uses_type_status_and_keyword_together(self):
self._add_task(1, TaskType.COLLECT, "目标短袖")
self._add_task(2, TaskType.PURCHASE, "目标短袖")
self._add_task(3, TaskType.COLLECT, "其他商品")
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
page.searchRequested.emit(
{"task_type": "采集", "status": "待执行", "keyword": "目标"}
)
self.assertEqual(page.taskModel.data_row_count(), 1)
row = page.taskModel.row_at(0)
self.assertEqual(row.remote_task_id, "PDD-001")
self.assertEqual(row.task_type, "采集")
self.assertEqual(row.status, "待执行")
self.assertEqual(row.price_cents, 3990)
page.deleteLater()
def test_query_failure_shows_readable_error(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(page, BrokenRepository())
events.load_initial_tasks()
self.assertFalse(page.emptyStateCard.isHidden())
self.assertEqual(page.emptyTitleLabel.text(), "任务加载失败")
self.assertIn("检查数据库", page.emptyMessageLabel.text())
self.assertFalse(page.taskModel.canFetchMore())
page.deleteLater()
def test_summary_to_row_does_not_copy_detail_json(self):
summary = TaskSummary(
id=1,
remote_task_id="PDD-001",
task_type=TaskType.PURCHASE,
goods_id=None,
title=None,
target_color=None,
target_size=None,
price_cent=None,
quantity=2,
status=TaskStatus.MANUAL_REVIEW,
updated_at="2026-08-06T08:00:00Z",
)
row = summary_to_row(summary)
self.assertEqual(row.task_type, "采购")
self.assertEqual(row.status, "需要人工处理")
self.assertEqual(row.goods_id, "")
self.assertFalse(hasattr(row, "pdd_data"))
def test_main_window_keeps_event_object_alive(self):
window = MainWindow(task_repository=self.repository)
self.assertIsInstance(window.pddTaskPageEvent, PDDTaskPageEvent)
self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0)
window.close()
window.deleteLater()
if __name__ == "__main__":
unittest.main()