From fa101c16ce914c553bce7a472e1a0522536cedc0 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 20 Jul 2026 10:32:04 +0800 Subject: [PATCH] feat(generate): filter tasks by product status --- app/gui/tabs/generate.py | 17 ++++++++++++++- docs/routes.md | 4 ++-- docs/tasks/T-671.md | 17 +++++++++------ tests/test_gui.py | 47 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index ccf782e..a413f55 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -1144,6 +1144,10 @@ class GenerateTab(QWidget): self.status_filter.setObjectName("statusFilter") for label, value in self.STATUS_FILTERS: self.status_filter.addItem(label, value) + self.product_status_filter = QComboBox() + self.product_status_filter.setObjectName("generateProductStatusFilter") + for label, value in product_status.PRODUCT_STATUS_FILTER_ITEMS: + self.product_status_filter.addItem(label, value) self.refresh_button = QPushButton("刷新") self.open_image_dir_button = QPushButton("打开图片文件夹") self.open_image_dir_button.setObjectName("openImageDirButton") @@ -1158,7 +1162,9 @@ class GenerateTab(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) filter_layout.addWidget(self.open_image_dir_button) @@ -1240,6 +1246,7 @@ class GenerateTab(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.open_image_dir_button.clicked.connect(self.open_image_directory) @@ -2103,6 +2110,7 @@ class GenerateTab(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.title_template_combo.setEnabled(not running) self.new_title_template_button.setEnabled(not running) @@ -2411,6 +2419,7 @@ class GenerateTab(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() self._populate_batch_filter(batches, selected_batch) selected_batch = self.batch_filter.currentData() @@ -2421,6 +2430,7 @@ class GenerateTab(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: @@ -2524,6 +2534,11 @@ class GenerateTab(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 diff --git a/docs/routes.md b/docs/routes.md index 3486fd2..e0458fb 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -106,12 +106,12 @@ - **标题提示词**:模板下拉(读 `data/prompts/title/*.txt`)+「新建 / 保存模板 / 模板操作(另存为、重命名、删除)」+ 多行输入 + 下方工具条「插入旧标题」(插 `{旧标题}`)/「保存标题提示词」(写 `data/title_prompt.txt`)。启动时自动加载 `title_prompt.txt` 回显,不自动用模板覆盖工作文本;用户选择模板时才把模板内容载入输入框。若标题提示词包含 `{旧标题}`,生成前替换为当前任务旧标题且不重复追加旧标题块;不包含时保持旧行为自动追加旧标题。 - **封面提示词**:模板控件压缩到一行(读 `data/prompts/cover/*.txt`,新建/保存模板/另存为/重命名/删除)+ 多行输入 + 下方工具条「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。 - 变量:标题提示词本阶段只支持 `{旧标题}`;封面提示词支持 `{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。 -- 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 +- 右上:按导入批次 / 店铺 / 商品ID / 商品状态 / 生成状态筛选任务;商品状态位于生成状态左侧,选项与①一致,按原始快照区分架上商品、未上架、审核中、状态未知和待检测;生成状态继续表示待生成、已生成、失败、略过、已更新。商品状态筛选只缩小当前列表,商品ID输入框按包含匹配 `item_id`,清空表示全部。 - 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。 - 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。 - 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。开始前先从真实候选重新分组商品状态,弹出与①共用的纵向范围确认框:默认「生成架上商品」(即仅检测结果为正常的商品),「生成全部商品」为警示橙色描边,还会处理未上架、审核中、状态未知商品,可能额外消耗点数;未上架、审核中、状态未知默认不入队、不请求 AI、不消耗点数,用户明确选择全部范围才可入队。商品状态功能上线前的历史空状态会启动时迁移为默认正常;上线后仍为空的记录按未知处理。范围确认不跨轮记忆,取消或候选在确认期间变化均不启动生成;无异常候选时该警示选项禁用。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。 - 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **设置**;②只暴露本轮生成标题/封面/图文的内容模式。设置不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。 -- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。 +- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。即使用户通过商品状态筛选查看异常商品,也必须在范围确认框主动选择「生成全部商品」才会入队;默认「生成架上商品」不会因筛选而放宽。 - 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。 - **无逐条人工审核环节**;新标题默认使用 AI 输出,但允许对已生成且未提交线上的单行做本地微调。封面画廊内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条生图;用户翻看完后用状态筛选「待生成」并点击「开始生成」批量补封面。生成完即可进入 ③,③ 开始更新前会做批量确认。 diff --git a/docs/tasks/T-671.md b/docs/tasks/T-671.md index 6480be1..a0b5f58 100644 --- a/docs/tasks/T-671.md +++ b/docs/tasks/T-671.md @@ -1,7 +1,7 @@ --- id: T-671 title: AI生成商品状态筛选与生成状态命名 -status: TODO +status: DONE phase: 7 deps: [T-669] created: 2026-07-20 @@ -23,11 +23,11 @@ created: 2026-07-20 ## 验收标准 -- [ ] ②筛选行显示「商品状态」和「生成状态」两个明确标签,商品状态位于生成状态左侧。 -- [ ] 商品状态下拉能分别筛出架上商品、未上架、审核中、状态未知和待检测,并可与生成状态、批次、店铺、商品 ID 筛选叠加。 -- [ ] 商品状态筛选不会改变 SQLite 数据、标题/封面生成结果或进度状态。 -- [ ] 筛选异常商品后点击开始生成,默认范围仍不执行异常商品;只有用户在确认框主动选择「生成全部商品」才会执行。 -- [ ] `tests/test_gui.py` 覆盖控件顺序和中文标签、筛选交集、NULL/unknown 区分,以及异常筛选下生成范围确认仍生效。 +- [x] ②筛选行显示「商品状态」和「生成状态」两个明确标签,商品状态位于生成状态左侧。 +- [x] 商品状态下拉能分别筛出架上商品、未上架、审核中、状态未知和待检测,并可与生成状态、批次、店铺、商品 ID 筛选叠加。 +- [x] 商品状态筛选不会改变 SQLite 数据、标题/封面生成结果或进度状态。 +- [x] 筛选异常商品后点击开始生成,默认范围仍不执行异常商品;只有用户在确认框主动选择「生成全部商品」才会执行。 +- [x] `tests/test_gui.py` 覆盖控件顺序和中文标签、筛选交集、NULL/unknown 区分,以及异常筛选下生成范围确认仍生效。 ## 验证 @@ -47,4 +47,7 @@ git diff --check ## 执行记录 -- 待实现。 +- ②筛选行新增「商品状态」下拉,原「状态」改名为「生成状态」;商品状态使用 T-669 的共享选项和原始空值识别,与批次、店铺、商品 ID、生成状态叠加筛选。 +- 开始生成仍只从当前筛选后的 `model.tasks` 取候选,并继续执行既有商品状态范围确认;异常商品在默认「生成架上商品」范围内不入队。 +- 更新路由文档,并补充控件标签、组合筛选、待检测/状态未知与默认安全计划的 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 511d2c6..01bff85 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -20,6 +20,7 @@ from app import ( db, image_paths, image_studio, + product_status, prompts, update_check, update_installer, @@ -35,6 +36,7 @@ from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QComboBox, + QLabel, QLineEdit, QListView, QPlainTextEdit, @@ -3343,6 +3345,10 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("打开图片文件夹", tab.open_image_dir_button.text()) self.assertEqual("openImageDirButton", tab.open_image_dir_button.objectName()) self.assertIn("当前批次文件夹", tab.open_image_dir_button.toolTip()) + self.assertEqual("generateProductStatusFilter", tab.product_status_filter.objectName()) + label_texts = [label.text() for label in tab.findChildren(QLabel)] + self.assertIn("商品状态", label_texts) + self.assertIn("生成状态", label_texts) self.assertEqual( ["只生成标题", "只生成封面", "生成标题和封面"], [tab.generate_mode_combo.itemText(index) for index in range(tab.generate_mode_combo.count())], @@ -5439,6 +5445,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab._set_generate_running(True) self.assertFalse(tab.reset_generate_button.isEnabled()) + self.assertFalse(tab.product_status_filter.isEnabled()) self.assertFalse(tab.title_template_combo.isEnabled()) self.assertFalse(tab.new_title_template_button.isEnabled()) self.assertFalse(tab.save_title_template_button.isEnabled()) @@ -8089,12 +8096,23 @@ class GuiTests(TempDirMixin, unittest.TestCase): db.set_collected(tasks_a[2].id, "旧标题C", "old-c.jpg", path=cfg["db_path"]) db.set_generated(tasks_a[2].id, "新标题C", "new-c.jpg", path=cfg["db_path"]) db.mark_failed(tasks_b[0].id, "generate", "生成失败", 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_b[0].id, "unknown", path=cfg["db_path"]) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(None)) self.assertEqual("generateItemFilter", tab.item_filter.objectName()) + self.assertEqual("generateProductStatusFilter", tab.product_status_filter.objectName()) + self.assertEqual( + ["全部商品状态", "架上商品", "未上架", "审核中", "状态未知", "待检测"], + [ + tab.product_status_filter.itemText(index) + for index in range(tab.product_status_filter.count()) + ], + ) self.assertEqual(4, tab.model.rowCount()) self.assertEqual("主店", tab.model.index(0, 0).data()) self.assertEqual("51100639510", tab.model.index(0, 1).data()) @@ -8110,6 +8128,35 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertTrue(all(tab.model.index(row, 4).data() == "待生成" for row in range(2))) 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()) + normal_plan = product_status.build_generation_plan( + tab._generation_candidates("title"), + "title", + "normal_only", + ) + self.assertEqual(1, len(normal_plan["execution_tasks"])) + tab.product_status_filter.setCurrentIndex( + tab.product_status_filter.findData("unlisted") + ) + self.assertEqual(1, tab.model.rowCount()) + unlisted_plan = product_status.build_generation_plan( + tab._generation_candidates("title"), + "title", + "normal_only", + ) + self.assertEqual([], unlisted_plan["execution_tasks"]) + tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) + tab.product_status_filter.setCurrentIndex( + tab.product_status_filter.findData("unchecked") + ) + self.assertEqual(1, tab.model.rowCount()) + self.assertEqual("51100639512", tab.model.index(0, 1).data()) + tab.product_status_filter.setCurrentIndex(tab.product_status_filter.findData("all")) + tab.shop_filter.setCurrentIndex(tab.shop_filter.findData("alias-b")) self.assertEqual(1, tab.model.rowCount()) self.assertEqual("副店", tab.model.index(0, 0).data())