From 583e441e175f67341d3443a51bd6517bda573601 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 6 Aug 2026 16:31:34 +0800 Subject: [PATCH] feat(client): connect PDD task list to SQLite (#9) --- client/src/pdd_ui.py | 157 +++++++++++++------------------ client/src/pdd_ui_event.py | 115 ++++++++++++++++++++++ client/src/ui_main.py | 10 +- client/test/test_pdd_ui_event.py | 142 ++++++++++++++++++++++++++++ 4 files changed, 334 insertions(+), 90 deletions(-) create mode 100644 client/test/test_pdd_ui_event.py diff --git a/client/src/pdd_ui.py b/client/src/pdd_ui.py index 0b797b0..b56323e 100644 --- a/client/src/pdd_ui.py +++ b/client/src/pdd_ui.py @@ -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: diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index 00ad228..63346cb 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -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, + ) diff --git a/client/src/ui_main.py b/client/src/ui_main.py index 3c86669..74b3323 100644 --- a/client/src/ui_main.py +++ b/client/src/ui_main.py @@ -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() diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py new file mode 100644 index 0000000..8e29b41 --- /dev/null +++ b/client/test/test_pdd_ui_event.py @@ -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()