feat(client): connect PDD task list to SQLite (#9)
This commit is contained in:
+68
-89
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user