From da0a42090c0ef6b28a1dd624e6de01b9bf020b63 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 17 Jul 2026 17:32:43 +0800 Subject: [PATCH] feat: skip incomplete apply tasks before update --- app/gui/tabs/apply.py | 93 ++++++++++++++++++++---- docs/04-architecture.md | 2 +- docs/api.md | 10 +-- docs/routes.md | 8 +-- docs/tasks/T-659.md | 7 +- tests/test_gui.py | 152 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 248 insertions(+), 24 deletions(-) diff --git a/app/gui/tabs/apply.py b/app/gui/tabs/apply.py index d4834b2..cc46409 100644 --- a/app/gui/tabs/apply.py +++ b/app/gui/tabs/apply.py @@ -253,8 +253,10 @@ class ApplyTab(QWidget): return update_cfg = self._shopee_update_config() update_mode = self._current_update_mode() - content_error = self._update_content_error(tasks, update_mode) - if content_error: + content_plan = self._partition_update_content_tasks(tasks, update_mode) + executable_tasks = content_plan["executable"] + if not executable_tasks: + content_error = self._update_content_error(content_plan, update_mode) QMessageBox.warning(self, "更新内容未生成", content_error) self._set_status(content_error.replace("\n", " ")) return @@ -262,7 +264,11 @@ class ApplyTab(QWidget): answer = QMessageBox.question( self, "确认检查本轮更新" if dry_run else "确认开始更新", - self._confirmation_message(tasks, dry_run=dry_run), + self._confirmation_message( + executable_tasks, + dry_run=dry_run, + content_plan=content_plan, + ), QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) @@ -271,7 +277,7 @@ class ApplyTab(QWidget): return batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) worker = ApplyWorker( - tasks, + executable_tasks, db_path=self.db_path, config=self.config, dry_run=dry_run, @@ -294,11 +300,19 @@ class ApplyTab(QWidget): self.apply_worker = worker self.apply_thread = thread self._set_apply_running(True) - self._reset_run_log(tasks, dry_run=dry_run, batch_size=batch_size) + self._reset_run_log( + executable_tasks, + dry_run=dry_run, + batch_size=batch_size, + content_plan=content_plan, + ) + skip_status = self._content_skip_status(content_plan) if dry_run: - self._set_status(f"开始检查本轮更新:{len(tasks)} 条") + self._set_status(f"开始检查本轮更新:{len(executable_tasks)} 条{skip_status}") else: - self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行") + self._set_status( + f"开始更新:{len(executable_tasks)} 条,按每批最多 {batch_size} 条执行{skip_status}" + ) thread.start() def stop_update(self, checked=False): @@ -480,7 +494,7 @@ class ApplyTab(QWidget): return task.status == "skipped" return True - def _confirmation_message(self, tasks, dry_run=False): + def _confirmation_message(self, tasks, dry_run=False, content_plan=None): update_cfg = self._shopee_update_config() update_mode = self._current_update_mode() update_mode_text = self._update_mode_label(update_mode) @@ -501,6 +515,8 @@ class ApplyTab(QWidget): if dry_run else "即将按当前筛选结果分批更新蝦皮线上商品。\n\n" ) + skipped_text = self._content_skip_text(content_plan) + skipped_block = f"\n{skipped_text}\n" if skipped_text else "" return ( intro + f"批次:{self._batch_filter_label()}\n" @@ -509,7 +525,9 @@ class ApplyTab(QWidget): + f"状态:{self._status_label()}\n" + f"更新内容:{update_mode_text}\n" + f"任务数:{len(tasks)}\n" - + f"预计批次:{batch_count}\n\n" + + f"预计批次:{batch_count}\n" + + skipped_block + + "\n" + "执行设置:" + f"每批最大更新条数={batch_size}," + "成功后自动关闭程序新开编辑页," @@ -581,7 +599,7 @@ class ApplyTab(QWidget): self._set_status(f"更新内容已设置为:{self._update_mode_label(update_mode)}") return True - def _update_content_error(self, tasks, update_mode): + def _partition_update_content_tasks(self, tasks, update_mode): missing_title = [ task for task in tasks if appconfig.update_mode_includes_title(update_mode) @@ -592,10 +610,24 @@ class ApplyTab(QWidget): if appconfig.update_mode_includes_cover(update_mode) and not str(getattr(task, "new_cover_path", "") or "").strip() ] - if not missing_title and not missing_cover: + skipped = {id(task) for task in missing_title + missing_cover} + return { + "executable": [task for task in tasks if id(task) not in skipped], + "missing_title": missing_title, + "missing_cover": missing_cover, + } + + def _update_content_error(self, tasks_or_plan, update_mode): + if isinstance(tasks_or_plan, dict): + content_plan = tasks_or_plan + else: + content_plan = self._partition_update_content_tasks(tasks_or_plan, update_mode) + if content_plan["executable"]: return None lines = [] mode_text = self._update_mode_label(update_mode) + missing_title = content_plan["missing_title"] + missing_cover = content_plan["missing_cover"] if missing_title: lines.append(f"当前筛选结果中有 {len(missing_title)} 条缺少新标题,不能执行“{mode_text}”。") lines.append("请先回到②AI生成选择“只生成标题”或“生成标题和封面”。") @@ -608,6 +640,38 @@ class ApplyTab(QWidget): lines.append(f"示例商品ID:{self._sample_item_ids(missing_cover)}") return "\n".join(lines) + def _content_skip_text(self, content_plan): + if not content_plan: + return "" + missing_title = content_plan.get("missing_title", []) + missing_cover = content_plan.get("missing_cover", []) + skipped_count = len({id(task) for task in missing_title + missing_cover}) + if not skipped_count: + return "" + lines = [f"本轮将跳过 {skipped_count} 条缺少所选更新内容的记录:"] + if missing_title: + lines.append( + f"缺少新标题:{len(missing_title)} 条(示例商品ID:{self._sample_item_ids(missing_title)})" + ) + if missing_cover: + lines.append( + f"缺少新封面:{len(missing_cover)} 条(示例商品ID:{self._sample_item_ids(missing_cover)})" + ) + return "\n".join(lines) + + def _content_skip_status(self, content_plan): + if not content_plan: + return "" + missing_title = content_plan.get("missing_title", []) + missing_cover = content_plan.get("missing_cover", []) + skipped_count = len({id(task) for task in missing_title + missing_cover}) + if not skipped_count: + return "" + return ( + f",跳过 {skipped_count} 条(缺标题 {len(missing_title)} / " + f"缺封面 {len(missing_cover)})" + ) + def _sample_item_ids(self, tasks): values = [ str(getattr(task, "item_id", "") or "").strip() @@ -711,13 +775,18 @@ class ApplyTab(QWidget): def _show_current_run_log_empty(self): self.run_log_view.setPlainText("本轮日志会在开始运行后显示") - def _reset_run_log(self, tasks, dry_run=False, batch_size=1): + def _reset_run_log(self, tasks, dry_run=False, batch_size=1, content_plan=None): self.run_log_view.clear() action = "检查" if dry_run else "更新" mode_text = self._update_mode_label(self._current_update_mode()) self._append_run_log( f"本轮{action}开始:任务 {len(tasks)} 条,更新内容:{mode_text},每批 {batch_size} 条" ) + skip_status = self._content_skip_status(content_plan) + if skip_status: + self._append_run_log( + f"本轮预检:可执行 {len(tasks)} 条{skip_status};缺失记录未进入本轮执行。" + ) def _load_latest_run_log(self): try: diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 680c83d..9721a0e 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -464,7 +464,7 @@ data/images///__new. # AI 生成的新 ### 6.3 应用更新(③ Tab) - ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。 -- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,先按当前模式检查当前筛选任务是否已经具备 `new_title` / `new_cover_path`;缺少所选内容时直接中文弹窗阻断,不创建 `ApplyWorker`、不打开 Chrome、不写失败状态。 +- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,先按当前模式分出具备 `new_title` / `new_cover_path` 的可执行任务与缺失记录;有可执行任务时缺失记录只在确认框和本轮日志中列为预检跳过,不创建其 `ApplyWorker` 执行项、不打开其 Chrome、不写其失败状态。仅当全部记录缺少所选内容时,才弹「更新内容未生成」并整体阻断。 - ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。 - 弹确认前先读取 `data/config.json` 的 `shopee_update` 执行参数。普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。 - 用户在③确认弹窗点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。 diff --git a/docs/api.md b/docs/api.md index 2e8f458..9ef5ded 100644 --- a/docs/api.md +++ b/docs/api.md @@ -449,7 +449,7 @@ main() -> int # 创建 QApplication + MainWindow class MainWindow(QMainWindow) # QTabWidget: ①、②、③、账号管理、设置、商品套图;支持注入 db_path/config/config_path/ai_models_path 便于测试 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志 -class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志 +class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 缺失内容预检剔除 + 检查本轮更新 + 确认后分批真实更新 + 运行日志 class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪 class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果 class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建 @@ -504,7 +504,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 分辨率下拉固定 `512/1k/2k/4k`;普通默认 cmhub 模式下,返回超时标签只读展示实际等待口径「标题 600 秒 / 图片 900 秒」,分辨率只控制生成图片尺寸。direct 兼容路径仍使用 `resolution_timeouts[resolution]`。 - 路径与端口读写 `config.json`,并按 3 个组件一组排列:默认调试端口、调试端口范围、Chrome 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。 - 蝦皮更新执行读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:每批最大更新条数、同时更新蝦皮账号。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。 -- 更新内容默认只更新标题。③ 左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面,开始前必须先通过缺失内容校验,并弹窗确认后才会创建更新 worker。 +- 更新内容默认只更新标题。③ 左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面;开始前先按所选内容剔除缺失记录,仍有可执行记录时在确认框说明跳过项后创建更新 worker,全部缺失时才弹窗中止。 ① 导入采集当前要点(T-202/T-202b): @@ -540,14 +540,14 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 ③ 更新蝦皮当前要点(T-303b/T-401/T-402/T-403): - `ApplyTab` 顶部筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部。 -- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前再按③「更新内容」模式校验是否缺少 `new_title` 或 `new_cover_path`。 +- ③ 只列出已生成或可查看的更新候选任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务;真正开始更新前再按③「更新内容」模式分出可执行任务与缺少 `new_title` / `new_cover_path` 的预检跳过记录。 - 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。 - 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。 - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。 - 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、更新内容、会更新字段和略过原因。 -- 点击「开始更新」先按③「更新内容」模式校验当前筛选任务:只更新标题必须有 `new_title`,只更新封面必须有 `new_cover_path`,更新标题和封面必须两者都有;缺失时弹「更新内容未生成」并阻断,不打开 Chrome、不写失败状态。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/任务总数、每批最大条数、预计批次数、提交线上风险和当前执行设置。 +- 点击「开始更新」先按③「更新内容」模式预检当前筛选任务:只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新标题和封面要求两者都有。部分缺失时将这些记录从本轮 `ApplyWorker` 任务中剔除,在确认框、状态栏摘要和本轮日志列出缺标题/缺封面条数与示例商品ID;不打开其 Chrome、不写其失败状态或 Excel。只有全部记录缺失时才弹「更新内容未生成」并阻断。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前可执行任务超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/可执行任务数、预检跳过项、每批最大条数、预计批次数、提交线上风险和当前执行设置。 - 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。 -- `ApplyWorker` 只处理当前筛选结果里 `stage=generated`、状态为 `success/pending/failed`,且满足当前 `update_mode` 所需内容的任务;只更新标题时不替换封面,只更新封面时不改标题。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。 +- `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。 - 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。 - 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。 - 预检通过后默认串行;若 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。 diff --git a/docs/routes.md b/docs/routes.md index fb98566..d8f24ec 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -129,9 +129,9 @@ - 顶部**按批次 / 店铺 / 商品ID / 状态筛选**(与 ①②一致);商品ID输入框按包含匹配 `item_id`,清空表示全部;「开始更新」作用于**当前筛选结果**,是一道范围控制。 - 店铺筛选:建议**逐店铺更新**(每店铺需先启动其 Chrome 并登录)。 - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 -- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前先按当前模式检查 `new_title` / `new_cover_path`,缺失时中文弹窗阻断,不打开 Chrome、不改任务状态。 +- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前先按当前模式检查 `new_title` / `new_cover_path`。有合格记录时,缺失记录从本轮 worker 任务中剔除,确认框与日志列出缺标题/缺封面条数和示例商品ID;仅全部记录缺失时才中文弹窗阻断,不打开 Chrome、不改任务状态。 - 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。 -- 缺失内容校验通过后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前筛选总数超过该值时不阻断,而是自动分批执行。 +- 完成缺失内容预检并剔除不合格记录后,点击「开始更新」读取设置中的 `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前可执行任务超过该值时不阻断,而是自动分批执行。 - 弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、执行设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 - 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。 - 对确认后的**已生成(generated)任务**按当前更新内容执行:打开编辑页换标题和/或换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。 @@ -178,7 +178,7 @@ - 路径与端口(T-501b/T-506/T-539/T-580 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、Chrome 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。 - 蝦皮更新执行(T-580 已接入):组件组改为 3 个组件一组;普通设置页只保留「每批最大更新条数」和「同时更新蝦皮账号(1..5)」两个执行参数。`1` 表示逐个账号串行,`2..5` 表示按账号分组并行;“dry-run”不作为用户可见开关,改到③成为「检查本轮更新」按钮;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。 - ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。 - - ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再弹确认框。 +- ③ 点击「开始更新」会先按「更新内容」剔除缺失内容记录并在确认框说明;仅无可执行记录时中止。 ## 商品套图 @@ -242,7 +242,7 @@ | `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 | | `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 | | `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成内容」下拉接入 `GenerateWorker` | -| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 | +| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 | | `AccountsTab(QWidget)` | 账号管理 | 账号增删改、启动登录、检测登录、生成快捷方式;首次启动复用初始卖家中心页,启动中防重复触发,登录检测把 Shopee accounts 登录页判为未登录 | | `SettingsTab(QWidget)` | 设置 | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 | | `ProductSuiteTab(QWidget)` | 商品套图 | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 | diff --git a/docs/tasks/T-659.md b/docs/tasks/T-659.md index 49abb68..5a68fe3 100644 --- a/docs/tasks/T-659.md +++ b/docs/tasks/T-659.md @@ -1,7 +1,7 @@ --- id: T-659 title: 更新蝦皮批次内容预检改为剔除跳过不中止整批 -status: TODO +status: DONE phase: 7 deps: [] created: 2026-07-17 @@ -80,4 +80,7 @@ git diff --check ## 执行记录 -- 待实现。 +- 2026-07-17:`ApplyTab` 新增按更新模式划分可执行任务、缺少新标题任务和缺少新封面任务的预检;有可执行任务时只将其交给 `ApplyWorker`,缺失记录不改 SQLite、Excel 或运行状态。 +- 2026-07-17:正式更新和「检查本轮更新」共用该预检;确认框显示跳过条数、缺失类型和示例商品ID,状态栏与本轮日志显示可执行/跳过摘要。全部任务都缺失时仍沿用「更新内容未生成」弹窗中止。 +- 2026-07-17:补充三种更新模式的剔除口径、正式更新部分跳过和检查模式部分跳过回归测试;同步架构、API 和界面流程文档。 +- 验证通过:`py -3.10 -m unittest tests.test_gui`(190 项)、`py -3.10 -m unittest discover -s tests`(591 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。离屏 GUI 测试有 Qt 字体/插件告警,但无测试失败。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 6220869..19ecbad 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -6647,6 +6647,158 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_apply_tab_partitions_missing_content_by_update_mode(self): + with self.make_temp_dir() as temp_dir: + tab = ApplyTab(config=self.make_config(temp_dir)) + self.addCleanup(tab.close) + tasks = [ + SimpleNamespace(id=1, item_id="51100639510", new_title="新标题", new_cover_path="new.jpg"), + SimpleNamespace(id=2, item_id="51100639511", new_title="新标题", new_cover_path=None), + SimpleNamespace(id=3, item_id="51100639512", new_title=None, new_cover_path="new.jpg"), + SimpleNamespace(id=4, item_id="51100639513", new_title=None, new_cover_path=None), + ] + + title_plan = tab._partition_update_content_tasks(tasks, "title") + self.assertEqual([1, 2], [task.id for task in title_plan["executable"]]) + self.assertEqual([3, 4], [task.id for task in title_plan["missing_title"]]) + self.assertEqual([], title_plan["missing_cover"]) + + cover_plan = tab._partition_update_content_tasks(tasks, "cover") + self.assertEqual([1, 3], [task.id for task in cover_plan["executable"]]) + self.assertEqual([], cover_plan["missing_title"]) + self.assertEqual([2, 4], [task.id for task in cover_plan["missing_cover"]]) + + title_cover_plan = tab._partition_update_content_tasks(tasks, "title_cover") + self.assertEqual([1], [task.id for task in title_cover_plan["executable"]]) + self.assertEqual([3, 4], [task.id for task in title_cover_plan["missing_title"]]) + self.assertEqual([2, 4], [task.id for task in title_cover_plan["missing_cover"]]) + + self.assert_removed(temp_dir) + + def test_apply_tab_skips_missing_content_but_starts_eligible_update(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 3, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639511", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 4, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639512", + }, + ], + path=cfg["db_path"], + ) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + for task in tasks: + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"]) + db.set_generated(tasks[1].id, "新标题", None, path=cfg["db_path"]) + db.set_generated(tasks[2].id, None, "new.jpg", path=cfg["db_path"]) + statuses = [] + tab = ApplyTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + tab.update_mode_combo.setCurrentIndex(tab.update_mode_combo.findData("title_cover")) + thread = FakeThread() + + with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question, \ + mock.patch("app.gui.QMessageBox.warning") as warning, \ + mock.patch("app.gui.run_worker", return_value=thread): + tab.start_update() + + warning.assert_not_called() + message = question.call_args[0][2] + self.assertIn("任务数:1", message) + self.assertIn("本轮将跳过 2 条缺少所选更新内容的记录", message) + self.assertIn("缺少新标题:1 条(示例商品ID:51100639512)", message) + self.assertIn("缺少新封面:1 条(示例商品ID:51100639511)", message) + self.assertEqual([tasks[0].id], [task.id for task in tab.apply_worker.tasks]) + self.assertTrue(thread.started) + self.assertIn("跳过 2 条(缺标题 1 / 缺封面 1)", statuses[-1]) + self.assertIn("缺失记录未进入本轮执行", tab.run_log_view.toPlainText()) + unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + self.assertTrue(all(task.stage == "generated" for task in unchanged)) + self.assertTrue(all(task.status == "success" for task in unchanged)) + + self.assert_removed(temp_dir) + + def test_apply_tab_preview_uses_same_missing_content_skip_rule(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 3, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639511", + }, + ], + path=cfg["db_path"], + ) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + for task in tasks: + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(tasks[0].id, "新标题", "new.jpg", path=cfg["db_path"]) + db.set_generated(tasks[1].id, None, "new.jpg", path=cfg["db_path"]) + statuses = [] + tab = ApplyTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + tab.update_mode_combo.setCurrentIndex(tab.update_mode_combo.findData("title")) + thread = FakeThread() + + with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question, \ + mock.patch("app.gui.QMessageBox.warning") as warning, \ + mock.patch("app.gui.run_worker", return_value=thread): + tab.preview_update() + + warning.assert_not_called() + message = question.call_args[0][2] + self.assertIn("本轮将跳过 1 条缺少所选更新内容的记录", message) + self.assertIn("缺少新标题:1 条(示例商品ID:51100639511)", message) + self.assertEqual([tasks[0].id], [task.id for task in tab.apply_worker.tasks]) + self.assertTrue(tab.apply_worker.dry_run) + self.assertIn("开始检查本轮更新:1 条,跳过 1 条(缺标题 1 / 缺封面 0)", statuses[-1]) + unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + self.assertTrue(all(task.stage == "generated" for task in unchanged)) + self.assertTrue(all(task.status == "success" for task in unchanged)) + + self.assert_removed(temp_dir) + def test_apply_tab_allows_cover_only_result_but_blocks_modes_that_need_title(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)