diff --git a/app/gui/tabs/apply.py b/app/gui/tabs/apply.py index ab20145..089bf59 100644 --- a/app/gui/tabs/apply.py +++ b/app/gui/tabs/apply.py @@ -64,6 +64,10 @@ class ApplyTab(QWidget): self.status_filter.setObjectName("applyStatusFilter") for label, value in self.STATUS_FILTERS: self.status_filter.addItem(label, value) + self.product_status_filter = QComboBox() + self.product_status_filter.setObjectName("applyProductStatusFilter") + for label, value in product_status.PRODUCT_STATUS_FILTER_ITEMS: + self.product_status_filter.addItem(label, value) self.refresh_button = QPushButton("刷新") filter_layout = QHBoxLayout() @@ -73,7 +77,9 @@ class ApplyTab(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.refresh_button) @@ -152,6 +158,7 @@ class ApplyTab(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.refresh_button.clicked.connect(self.refresh_tasks) self.preview_update_button.clicked.connect(self.preview_update) @@ -176,6 +183,7 @@ class ApplyTab(QWidget): selected_batch = self.batch_filter.currentData() selected_shop = self.shop_filter.currentData() selected_status = self.status_filter.currentData() or "generated" + selected_product_status = self.product_status_filter.currentData() or "all" item_query = self.item_filter.text().strip() self._populate_batch_filter(batches, selected_batch) selected_batch = self.batch_filter.currentData() @@ -190,6 +198,7 @@ class ApplyTab(QWidget): task for task in batch_tasks 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) ] except Exception as exc: @@ -494,6 +503,9 @@ class ApplyTab(QWidget): def _item_filter_label(self): return self.item_filter.text().strip() or "全部商品" + def _product_status_filter_label(self): + return self.product_status_filter.currentText() or "全部商品状态" + def _is_update_task(self, task): if task.stage in {"generated", "applied"}: return True @@ -507,6 +519,11 @@ class ApplyTab(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): if selected_status in (None, "all"): return True @@ -548,7 +565,8 @@ class ApplyTab(QWidget): + f"批次:{self._batch_filter_label()}\n" + f"店铺:{self._shop_filter_label()}\n" + f"商品ID:{self._item_filter_label()}\n" - + f"状态:{self._status_label()}\n" + + f"商品状态:{self._product_status_filter_label()}\n" + + f"更新状态:{self._status_label()}\n" + f"更新内容:{update_mode_text}\n" + f"任务数:{len(tasks)}\n" + f"预计批次:{batch_count}\n" @@ -760,6 +778,7 @@ class ApplyTab(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_mode_combo.setEnabled(not running) self._update_write_back_button() @@ -772,6 +791,7 @@ class ApplyTab(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_mode_combo.setEnabled(not running) self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids())) diff --git a/docs/routes.md b/docs/routes.md index e0458fb..8943a0d 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -129,11 +129,11 @@ └───────────────────────────────────────────────────────────────┘ ``` -- 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。 +- 顶部**按批次 / 店铺 / 商品ID / 商品状态 / 更新状态筛选**(与①②一致);商品状态位于更新状态左侧,选项与①一致,按原始快照区分架上商品、未上架、审核中、状态未知和待检测;更新状态继续表示已生成、失败、已更新、略过和全部状态。商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。 - 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。 - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 - 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前统一用状态优先的预检计划:仅 `product_status=normal` 可进入标题/封面完整性检查,`unlisted`、`reviewing`、`unknown` 及上线后历史 NULL 一律排除,不传给 `ApplyWorker`。商品状态功能上线前的活动历史批次空状态已在数据库初始化时默认迁移为 `normal`,可进入既有内容完整性检查;该默认值会标注为未实时检测,但不要求用户额外打开商品页。状态异常和缺失内容记录都在确认框、状态栏和本轮日志列出分类、数量与示例商品ID;全部被排除时按真实原因中文弹窗阻断,不打开 Chrome、不改任务状态。用户确认后重新计算计划指纹,任务状态、更新时间、新标题或新封面变化则中止并要求重新开始。 -- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。 +- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前商品状态/更新状态筛选、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。 - 完成缺失内容预检并剔除不合格记录后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前可执行任务超过该值时不阻断,而是自动分批执行。 - 弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、执行设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 - 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。 diff --git a/docs/tasks/T-672.md b/docs/tasks/T-672.md index a3f553a..3dc28fd 100644 --- a/docs/tasks/T-672.md +++ b/docs/tasks/T-672.md @@ -1,7 +1,7 @@ --- id: T-672 title: 更新蝦皮商品状态筛选与更新状态命名 -status: TODO +status: DONE phase: 7 deps: [T-671] created: 2026-07-20 @@ -24,11 +24,11 @@ created: 2026-07-20 ## 验收标准 -- [ ] ③筛选行显示「商品状态」和「更新状态」两个明确标签,商品状态位于更新状态左侧。 -- [ ] 筛选「商品状态=架上商品」后点击检查或开始更新,只从该筛选结果构建候选,不会扫描同批次其他未上架、审核中或未知商品。 -- [ ] 商品状态筛选可与更新状态、批次、店铺、商品 ID 叠加;确认框显示当前商品状态筛选和最终可执行数量。 -- [ ] 选择未上架、审核中、状态未知或待检测后,检查/开始更新仍被既有状态预检阻止,不创建 `ApplyWorker`、不打开 Chrome、不提交蝦皮。 -- [ ] `tests/test_gui.py` 与商品状态计划测试覆盖筛选交集、确认范围文本、只从当前模型任务取候选,以及异常状态无法绕过预检。 +- [x] ③筛选行显示「商品状态」和「更新状态」两个明确标签,商品状态位于更新状态左侧。 +- [x] 筛选「商品状态=架上商品」后点击检查或开始更新,只从该筛选结果构建候选,不会扫描同批次其他未上架、审核中或未知商品。 +- [x] 商品状态筛选可与更新状态、批次、店铺、商品 ID 叠加;确认框显示当前商品状态筛选和最终可执行数量。 +- [x] 选择未上架、审核中、状态未知或待检测后,检查/开始更新仍被既有状态预检阻止,不创建 `ApplyWorker`、不打开 Chrome、不提交蝦皮。 +- [x] `tests/test_gui.py` 与商品状态计划测试覆盖筛选交集、确认范围文本、只从当前模型任务取候选,以及异常状态无法绕过预检。 ## 验证 @@ -49,4 +49,7 @@ git diff --check ## 执行记录 -- 待实现。 +- ③筛选行新增「商品状态」下拉,原「状态」改名为「更新状态」;商品状态与批次、店铺、商品 ID、更新状态共同筛选当前 `model.tasks`。 +- 确认检查/开始更新弹窗新增当前商品状态与更新状态范围;启动前仍重新执行 `build_apply_plan()`,异常商品状态不会创建 `ApplyWorker` 或打开 Chrome。 +- 更新路由文档,并补充筛选交集、确认范围文本和未上架商品预检阻断的 GUI 回归测试。 +- 验证通过:`py -3.10 -m unittest discover -s tests`(623 项)、`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 01bff85..aadb41b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -6689,6 +6689,10 @@ class GuiTests(TempDirMixin, unittest.TestCase): ) db.mark_failed(tasks_a[2].id, "apply", "更新失败", path=cfg["db_path"]) db.set_applied(tasks_b[0].id, True, path=cfg["db_path"]) + db.set_product_status(tasks_a[0].id, "normal", path=cfg["db_path"]) + db.set_product_status(tasks_a[1].id, "unlisted", path=cfg["db_path"]) + db.set_product_status(tasks_a[2].id, "reviewing", path=cfg["db_path"]) + db.set_product_status(tasks_b[0].id, "normal", path=cfg["db_path"]) tab = ApplyTab(config=cfg) self.addCleanup(tab.close) @@ -6704,6 +6708,17 @@ class GuiTests(TempDirMixin, unittest.TestCase): [tab.update_mode_combo.itemText(index) for index in range(tab.update_mode_combo.count())], ) self.assertEqual("applyItemFilter", tab.item_filter.objectName()) + self.assertEqual("applyProductStatusFilter", tab.product_status_filter.objectName()) + self.assertEqual( + ["全部商品状态", "架上商品", "未上架", "审核中", "状态未知", "待检测"], + [ + tab.product_status_filter.itemText(index) + for index in range(tab.product_status_filter.count()) + ], + ) + label_texts = [label.text() for label in tab.findChildren(QLabel)] + self.assertIn("商品状态", label_texts) + self.assertIn("更新状态", label_texts) self.assertEqual("停止", tab.stop_update_button.text()) self.assertEqual("重置更新状态", tab.reset_update_button.text()) self.assertTrue(tab.reset_update_button.isHidden()) @@ -6725,6 +6740,24 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_foreground(tab.model, 0, 5, gui.COLOR_PENDING) self.assertEqual("任务 2/4 条", tab.summary_label.text()) + tab.product_status_filter.setCurrentIndex( + tab.product_status_filter.findData("normal") + ) + self.assertEqual(1, tab.model.rowCount()) + self.assertEqual("51100639510", tab.model.index(0, 1).data()) + tab.product_status_filter.setCurrentIndex( + tab.product_status_filter.findData("unlisted") + ) + self.assertEqual(1, tab.model.rowCount()) + self.assertEqual("51100639511", tab.model.index(0, 1).data()) + with mock.patch("app.gui.QMessageBox.warning") as warning, mock.patch( + "app.gui.run_worker" + ) as run_worker: + tab.preview_update() + self.assertEqual("商品状态不允许更新", warning.call_args[0][1]) + run_worker.assert_not_called() + tab.product_status_filter.setCurrentIndex(tab.product_status_filter.findData("all")) + tab.status_filter.setCurrentIndex(tab.status_filter.findData("failed")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("失败", tab.model.index(0, 5).data()) @@ -6758,6 +6791,14 @@ class GuiTests(TempDirMixin, unittest.TestCase): "商品ID:51100639512", tab._confirmation_message(list(tab.model.tasks)), ) + self.assertIn( + "商品状态:全部商品状态", + tab._confirmation_message(list(tab.model.tasks)), + ) + self.assertIn( + "更新状态:全部状态", + tab._confirmation_message(list(tab.model.tasks)), + ) self.assert_removed(temp_dir)