feat(collect): show and filter product status
This commit is contained in:
+30
-4
@@ -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
|
||||
|
||||
|
||||
+19
-1
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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`。
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user