From a92f1c86f47a63659cf5d8397465f50c70d0a127 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 20 Jul 2026 10:16:25 +0800 Subject: [PATCH] feat(collect): show and filter product status --- app/gui/models.py | 34 +++++++++++-- app/gui/tabs/collect.py | 20 +++++++- app/product_status.py | 32 ++++++++++++ docs/routes.md | 1 + docs/tasks/T-669.md | 17 ++++--- tests/test_gui.py | 107 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 199 insertions(+), 12 deletions(-) diff --git a/app/gui/models.py b/app/gui/models.py index cd2af92..7799771 100644 --- a/app/gui/models.py +++ b/app/gui/models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import ai, appconfig, diagnostics +from .. import ai, appconfig, diagnostics, product_status from ..collect_skip import skipped_stage_text from .widgets import * @@ -10,7 +10,7 @@ from .widgets import * class TaskTableModel(QAbstractTableModel): """Table model for task rows shared by workflow tabs.""" - HEADERS = ["账号", "别名", "商品ID", "阶段"] + HEADERS = ["账号", "别名", "商品ID", "阶段", "商品状态"] STAGE_TEXT = { "imported": "待采集", @@ -89,9 +89,14 @@ class TaskTableModel(QAbstractTableModel): task = self.tasks[index.row()] if role == Qt.DisplayRole: return self._display_value(task, index.column()) - if role == Qt.ForegroundRole and index.column() == 3: - return self._stage_color(task) + if role == Qt.ForegroundRole: + if index.column() == 3: + return self._stage_color(task) + if index.column() == 4: + return self._product_status_color(task) if role == Qt.ToolTipRole: + if index.column() == 4: + return self._product_status_tooltip(task) error = getattr(task, "last_error", "") or "" if self.is_unmatched(task) or getattr(task, "status", "") == "skipped": if error: @@ -154,12 +159,33 @@ class TaskTableModel(QAbstractTableModel): return _qcolor(COLOR_SUCCESS) return _qcolor(COLOR_PENDING) + def _product_status_color(self, task): + status = product_status.raw_status(getattr(task, "product_status", None)) + if status == product_status.STATUS_NORMAL: + return _qcolor(COLOR_SUCCESS) + if status == product_status.STATUS_UNLISTED: + return _qcolor(COLOR_DANGER) + if status == product_status.STATUS_REVIEWING: + return _qcolor(COLOR_WARNING) + return _qcolor(COLOR_MUTED) + + def _product_status_tooltip(self, task): + note = str(getattr(task, "product_status_note", "") or "").strip() + detected_at = str(getattr(task, "product_status_at", "") or "").strip() + lines = [note] if note else [] + if detected_at: + lines.append(f"检测时间:{detected_at}") + if not lines and product_status.raw_status(getattr(task, "product_status", None)) == product_status.STATUS_UNCHECKED: + lines.append("尚未检测商品状态") + return "\n".join(lines) or None + def _display_value(self, task, column): values = [ self._account_name(task), task.alias, task.item_id, self._stage_text(task), + product_status.display_status(getattr(task, "product_status", None)), ] return values[column] if 0 <= column < len(values) else None diff --git a/app/gui/tabs/collect.py b/app/gui/tabs/collect.py index e139cb9..4f612ac 100644 --- a/app/gui/tabs/collect.py +++ b/app/gui/tabs/collect.py @@ -114,6 +114,10 @@ class CollectTab(QWidget): self.status_filter.setObjectName("collectStatusFilter") for label, value in self.STATUS_FILTERS: self.status_filter.addItem(label, value) + self.product_status_filter = QComboBox() + self.product_status_filter.setObjectName("collectProductStatusFilter") + for label, value in product_status.PRODUCT_STATUS_FILTER_ITEMS: + self.product_status_filter.addItem(label, value) self.delete_batch_button = QPushButton("删除批次") self.delete_batch_button.setObjectName("deleteBatchButton") self.delete_batch_button.setStyleSheet(_danger_outline_button_style("deleteBatchButton")) @@ -134,7 +138,9 @@ class CollectTab(QWidget): filter_layout.addWidget(self.shop_filter, 1) filter_layout.addWidget(QLabel("商品ID")) filter_layout.addWidget(self.item_filter, 1) - filter_layout.addWidget(QLabel("状态")) + filter_layout.addWidget(QLabel("商品状态")) + filter_layout.addWidget(self.product_status_filter, 1) + filter_layout.addWidget(QLabel("处理状态")) filter_layout.addWidget(self.status_filter, 1) filter_layout.addWidget(self.delete_batch_button) @@ -173,6 +179,8 @@ class CollectTab(QWidget): self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + for column in (3, 4): + self.table.horizontalHeader().setSectionResizeMode(column, QHeaderView.ResizeToContents) self.table.verticalHeader().setVisible(False) self.run_log_view = QPlainTextEdit() @@ -208,6 +216,7 @@ class CollectTab(QWidget): self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) self.shop_filter.currentIndexChanged.connect(self.refresh_tasks) self.item_filter.textChanged.connect(self.refresh_tasks) + self.product_status_filter.currentIndexChanged.connect(self.refresh_tasks) self.status_filter.currentIndexChanged.connect(self.refresh_tasks) self.delete_batch_button.clicked.connect(self.delete_current_batch) self.collect_button.clicked.connect(self.collect_old_data) @@ -556,6 +565,7 @@ class CollectTab(QWidget): selected_batch = self.batch_filter.currentData() selected_shop = self.shop_filter.currentData() selected_status = self.status_filter.currentData() or "all" + selected_product_status = self.product_status_filter.currentData() or "all" item_query = self.item_filter.text().strip() if self.current_batch_id and self.batch_filter.findData(self.current_batch_id) < 0: selected_batch = self.current_batch_id @@ -570,6 +580,7 @@ class CollectTab(QWidget): task for task in task_rows if self._matches_shop(task, selected_shop) and self._matches_item(task, item_query) + and self._matches_product_status(task, selected_product_status) and self._matches_status(task, selected_status, account_rows) ] except Exception as exc: @@ -638,6 +649,11 @@ class CollectTab(QWidget): return True return item_query in str(getattr(task, "item_id", "")) + def _matches_product_status(self, task, selected_product_status): + if selected_product_status in (None, "all"): + return True + return product_status.raw_status(getattr(task, "product_status", None)) == selected_product_status + def _matches_status(self, task, selected_status, account_rows): if selected_status in (None, "all"): return True @@ -837,6 +853,7 @@ class CollectTab(QWidget): self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) self.item_filter.setEnabled(not running) + self.product_status_filter.setEnabled(not running) self.status_filter.setEnabled(not running) self._update_delete_batch_button() @@ -848,6 +865,7 @@ class CollectTab(QWidget): self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) self.item_filter.setEnabled(not running) + self.product_status_filter.setEnabled(not running) self.status_filter.setEnabled(not running) self._update_delete_batch_button() diff --git a/app/product_status.py b/app/product_status.py index eefe8da..a49a2f4 100644 --- a/app/product_status.py +++ b/app/product_status.py @@ -11,6 +11,7 @@ STATUS_NORMAL = "normal" STATUS_UNLISTED = "unlisted" STATUS_REVIEWING = "reviewing" STATUS_UNKNOWN = "unknown" +STATUS_UNCHECKED = "unchecked" VALID_PRODUCT_STATUSES = frozenset( { @@ -28,6 +29,23 @@ PRODUCT_STATUS_LABELS = { STATUS_UNKNOWN: "状态未知", } +PRODUCT_STATUS_FILTER_ITEMS = ( + ("全部商品状态", "all"), + ("架上商品", STATUS_NORMAL), + ("未上架", STATUS_UNLISTED), + ("审核中", STATUS_REVIEWING), + ("状态未知", STATUS_UNKNOWN), + ("待检测", STATUS_UNCHECKED), +) + +PRODUCT_STATUS_DISPLAY_LABELS = { + STATUS_NORMAL: "架上商品", + STATUS_UNLISTED: "未上架", + STATUS_REVIEWING: "审核中", + STATUS_UNKNOWN: "状态未知", + STATUS_UNCHECKED: "待检测", +} + SCOPE_NORMAL_ONLY = "normal_only" SCOPE_ALL = "all" @@ -53,6 +71,20 @@ def status_label(value) -> str: return PRODUCT_STATUS_LABELS[normalize_status(value)] +def display_status(value) -> str: + """Return the UI label without treating an empty snapshot as unknown.""" + + return PRODUCT_STATUS_DISPLAY_LABELS[raw_status(value)] + + +def raw_status(value) -> str: + """Return a UI filter status while keeping blank snapshots distinguishable.""" + + if not str(value or "").strip(): + return STATUS_UNCHECKED + return normalize_status(value) + + def is_normal(value) -> bool: return normalize_status(value) == STATUS_NORMAL diff --git a/docs/routes.md b/docs/routes.md index f1cd39e..fc6bcf9 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -73,6 +73,7 @@ - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 - 采集:点击后先从专用范围选择框选择范围,默认「采集架上商品」(即检测结果为正常),「采集全部商品」为警示橙色描边而非删除红色,包含未上架、审核中和状态未知商品;三个选项纵向全宽显示,常见 Windows 缩放下不截断。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images///__old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 +- ①任务表显示账号、别名、商品 ID、阶段和商品状态。商品状态筛选位于「处理状态」筛选左侧,选项为全部商品状态、架上商品、未上架、审核中、状态未知、待检测;它与批次、店铺、商品 ID 和处理状态叠加。空状态在①显示为「待检测」,显式 `unknown` 显示为「状态未知」;历史默认的架上商品可在 tooltip 查看“未实时检测”备注。商品状态只影响列表可见范围,不改变采集、生成或更新的安全规则。 - 商品状态功能于 `2026-07-18T16:34:37` 上线。更早创建的活动历史批次中,空状态在启动数据库时自动按「架上商品」写为 `normal`,备注「历史批次默认按架上商品处理(未实时检测)」且不写检测时间;不打开 Chrome、不访问详情页。上线时点及之后的空状态仍按未知处理,真实保存的未上架、审核中、状态未知也绝不覆盖。①不提供「重新检测商品状态」入口。 - 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。 diff --git a/docs/tasks/T-669.md b/docs/tasks/T-669.md index 2c052eb..cc71666 100644 --- a/docs/tasks/T-669.md +++ b/docs/tasks/T-669.md @@ -1,7 +1,7 @@ --- id: T-669 title: 导入采集商品状态展示与筛选 -status: TODO +status: DONE phase: 7 deps: [T-662a, T-667] created: 2026-07-20 @@ -24,11 +24,11 @@ created: 2026-07-20 ## 验收标准 -- [ ] ①列表显示「商品状态」列,并能正确区分架上商品、未上架、审核中、状态未知和待检测;阶段列仍正确显示待采集、已采集、已生成等工作流状态。 -- [ ] 商品状态下拉位于「处理状态」左侧;两项筛选可叠加,且继续与批次、店铺、商品 ID 筛选共同生效。 -- [ ] 历史默认正常记录显示「架上商品」,tooltip 明确其未实时检测语义;真实 `unknown` 与 NULL/空白的「待检测」不混淆。 -- [ ] 筛选、刷新、采集、回写、生成和更新既有行为不回归;不改变商品状态的写入时机、历史迁移规则或③更新安全预检。 -- [ ] `tests/test_gui.py` 和模型相关测试覆盖状态列展示、tooltip、商品状态筛选与处理状态叠加筛选。 +- [x] ①列表显示「商品状态」列,并能正确区分架上商品、未上架、审核中、状态未知和待检测;阶段列仍正确显示待采集、已采集、已生成等工作流状态。 +- [x] 商品状态下拉位于「处理状态」左侧;两项筛选可叠加,且继续与批次、店铺、商品 ID 筛选共同生效。 +- [x] 历史默认正常记录显示「架上商品」,tooltip 明确其未实时检测语义;真实 `unknown` 与 NULL/空白的「待检测」不混淆。 +- [x] 筛选、刷新、采集、回写、生成和更新既有行为不回归;不改变商品状态的写入时机、历史迁移规则或③更新安全预检。 +- [x] `tests/test_gui.py` 和模型相关测试覆盖状态列展示、tooltip、商品状态筛选与处理状态叠加筛选。 ## 验证 @@ -47,4 +47,7 @@ git diff --check ## 执行记录 -- 待实现。 +- 在 `product_status` 增加仅供界面筛选/展示使用的原始状态区分:NULL/空白为「待检测」,显式 `unknown` 仍为「状态未知」,不改变生成和更新预检的空状态安全语义。 +- ①任务表新增商品状态列、语义色和状态备注/检测时间 tooltip;顶部新增商品状态筛选,原「状态」改名为「处理状态」,筛选与批次、店铺、商品 ID、处理状态叠加。 +- 更新①路由文档,并补充表格展示、历史默认备注、组合筛选和运行中禁用的 GUI 回归测试。 +- 验证通过:`py -3.10 -m unittest discover -s tests`(621 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 9ee7dae..5b75667 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -424,6 +424,82 @@ class GuiTests(TempDirMixin, unittest.TestCase): model.data(model.index(0, 3), gui.Qt.ToolTipRole), ) + def test_collect_task_model_displays_product_statuses_and_snapshot_note(self): + account = SimpleNamespace(alias="papa", account_name="papa 店铺") + tasks = [ + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1001", + stage="imported", + status="pending", + last_error="", + product_status="normal", + product_status_note="历史批次默认按架上商品处理(未实时检测)", + product_status_at=None, + ), + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1002", + stage="imported", + status="pending", + last_error="", + product_status="unlisted", + product_status_note="商品未上架", + product_status_at="2026-07-20T09:00:00", + ), + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1003", + stage="imported", + status="pending", + last_error="", + product_status="reviewing", + product_status_note="商品审核中", + product_status_at=None, + ), + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1004", + stage="imported", + status="pending", + last_error="", + product_status="unknown", + product_status_note="", + product_status_at=None, + ), + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1005", + stage="imported", + status="pending", + last_error="", + product_status=None, + product_status_note=None, + product_status_at=None, + ), + ] + model = gui.TaskTableModel() + model.set_tasks(tasks, [account]) + + self.assertEqual(["账号", "别名", "商品ID", "阶段", "商品状态"], model.HEADERS) + self.assertEqual( + ["架上商品", "未上架", "审核中", "状态未知", "待检测"], + [model.index(row, 4).data() for row in range(model.rowCount())], + ) + self.assert_foreground(model, 0, 4, gui.COLOR_SUCCESS) + self.assert_foreground(model, 1, 4, gui.COLOR_DANGER) + self.assert_foreground(model, 2, 4, gui.COLOR_WARNING) + self.assert_foreground(model, 3, 4, gui.COLOR_MUTED) + self.assert_foreground(model, 4, 4, gui.COLOR_MUTED) + self.assertIn("未实时检测", model.index(0, 4).data(Qt.ToolTipRole)) + self.assertIn("检测时间", model.index(1, 4).data(Qt.ToolTipRole)) + self.assertEqual("尚未检测商品状态", model.index(4, 4).data(Qt.ToolTipRole)) + def test_workflow_failed_tooltips_show_failed_step_and_reason(self): account = SimpleNamespace(alias="papa", account_name="papa 店铺") collect_task = SimpleNamespace( @@ -8329,18 +8405,48 @@ class GuiTests(TempDirMixin, unittest.TestCase): tasks = {task.item_id: task for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"])} db.set_collected(tasks["1002"].id, "旧标题", os.path.join(temp_dir, "old.jpg"), path=cfg["db_path"]) db.mark_failed(tasks["2002"].id, "collect", "采集失败", path=cfg["db_path"]) + db.set_product_status(tasks["1002"].id, "normal", path=cfg["db_path"]) + db.set_product_status(tasks["2001"].id, "unlisted", path=cfg["db_path"]) + db.set_product_status(tasks["2002"].id, "reviewing", path=cfg["db_path"]) + db.set_product_status(tasks["3001"].id, "unknown", path=cfg["db_path"]) tab = CollectTab(config=cfg) self.addCleanup(tab.close) self.assertEqual("collectShopFilter", tab.shop_filter.objectName()) self.assertEqual("collectItemFilter", tab.item_filter.objectName()) + self.assertEqual("collectProductStatusFilter", tab.product_status_filter.objectName()) self.assertEqual("collectStatusFilter", tab.status_filter.objectName()) + self.assertEqual( + ["全部商品状态", "架上商品", "未上架", "审核中", "状态未知", "待检测"], + [ + tab.product_status_filter.itemText(index) + for index in range(tab.product_status_filter.count()) + ], + ) self.assertEqual(5, tab.model.rowCount()) + self.assertEqual("待检测", tab.model.index(0, 4).data()) + self.assertEqual("架上商品", tab.model.index(1, 4).data()) self.assertIn("5 行", tab.summary_label.text()) self.assertEqual("未匹配(1)", tab.show_unmatched_button.text()) self.assertIn("总数5", tab.batch_progress_label.text()) + tab.product_status_filter.setCurrentIndex( + tab.product_status_filter.findData("normal") + ) + self.assertEqual(1, tab.model.rowCount()) + self.assertEqual("1002", tab.model.index(0, 2).data()) + tab.status_filter.setCurrentIndex(tab.status_filter.findData("collected")) + self.assertEqual(1, tab.model.rowCount()) + tab.product_status_filter.setCurrentIndex( + tab.product_status_filter.findData("unlisted") + ) + self.assertEqual(0, tab.model.rowCount()) + tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) + self.assertEqual(1, tab.model.rowCount()) + self.assertEqual("2001", tab.model.index(0, 2).data()) + tab.product_status_filter.setCurrentIndex(tab.product_status_filter.findData("all")) + tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-a")) self.assertEqual(2, tab.model.rowCount()) self.assertEqual(["1001", "1002"], [tab.model.index(row, 2).data() for row in range(tab.model.rowCount())]) @@ -8418,6 +8524,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("normal_only", captured["kwargs"]["collect_scope"]) self.assertFalse(tab.shop_filter.isEnabled()) self.assertFalse(tab.item_filter.isEnabled()) + self.assertFalse(tab.product_status_filter.isEnabled()) self.assertFalse(tab.status_filter.isEnabled()) self.assert_removed(temp_dir)