feat(collect): show and filter product status

This commit is contained in:
chengma
2026-07-20 10:16:25 +08:00
parent c81a24d173
commit a92f1c86f4
6 changed files with 199 additions and 12 deletions
+29 -3
View File
@@ -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:
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
+19 -1
View File
@@ -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()
+32
View File
@@ -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
+1
View File
@@ -73,6 +73,7 @@
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
- 采集:点击后先从专用范围选择框选择范围,默认「采集架上商品」(即检测结果为正常),「采集全部商品」为警示橙色描边而非删除红色,包含未上架、审核中和状态未知商品;三个选项纵向全宽显示,常见 Windows 缩放下不截断。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_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 不关闭。
+10 -7
View File
@@ -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`。
+107
View File
@@ -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)