diff --git a/app/gui/main_window.py b/app/gui/main_window.py index 44d935d..ac98717 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -143,6 +143,7 @@ class MainWindow(QMainWindow): title_prompt_path=appconfig.title_prompt_path(self.config), cover_prompts_dir=appconfig.cover_prompts_dir(self.config), open_accounts_callback=lambda: self.open_accounts_tab(), + refresh_workflow_callback=lambda: self.refresh_task_tabs(), ) if title == "③ 更新蝦皮": return ApplyTab( @@ -151,6 +152,7 @@ class MainWindow(QMainWindow): status_callback=self.show_status, open_accounts_callback=lambda: self.open_accounts_tab(), open_settings_callback=lambda: self.open_settings_tab(), + refresh_workflow_callback=lambda: self.refresh_task_tabs(), ) if title == "账号管理": return AccountsTab( diff --git a/app/gui/tabs/apply.py b/app/gui/tabs/apply.py index e02cfca..479c746 100644 --- a/app/gui/tabs/apply.py +++ b/app/gui/tabs/apply.py @@ -39,6 +39,7 @@ class ApplyTab(QWidget): status_callback=None, open_accounts_callback=None, open_settings_callback=None, + refresh_workflow_callback=None, ): super().__init__(parent) self.config = appconfig.load_config() if config is None else config @@ -47,6 +48,7 @@ class ApplyTab(QWidget): self.status_callback = status_callback self.open_accounts_callback = open_accounts_callback self.open_settings_callback = open_settings_callback + self.refresh_workflow_callback = refresh_workflow_callback self.apply_worker = None self.apply_thread = None self.result_write_back_worker = None @@ -357,18 +359,91 @@ class ApplyTab(QWidget): def show_task_context_menu(self, position): index = self.task_table.indexAt(position) - if index.isValid(): - self.task_table.setCurrentIndex(index) - menu = QMenu(self) + if not index.isValid(): + return + self.task_table.selectRow(index.row()) + self.task_table.setCurrentIndex(index) + task = self.model.task_at(index.row()) + if task is None: + return + menu = QMenu(self.task_table) reset_action = menu.addAction("重置更新状态") reset_action.setEnabled( self.apply_thread is None and self.result_write_back_thread is None - and self._selected_task() is not None + and getattr(task, "status", "") != "running" ) reset_action.triggered.connect(self.reset_apply_status) + menu.addSeparator() + delete_action = menu.addAction("删除本条记录") + delete_action.setEnabled( + self.apply_thread is None + and self.result_write_back_thread is None + and getattr(task, "status", "") != "running" + ) + delete_action.triggered.connect(self.delete_selected_task) menu.exec(self.task_table.viewport().mapToGlobal(position)) + def delete_selected_task(self, checked=False): + if self.apply_thread is not None or self.result_write_back_thread is not None: + self._set_status("更新或回写正在进行,不能删除记录", level="warning") + return + task = self._selected_task() + if task is None: + self._set_status("请选择要删除的记录", level="warning") + return + if getattr(task, "status", "") == "running": + self._set_status("任务正在处理,不能删除", level="warning") + return + batch = db.get_batch(task.batch_id, path=self.db_path) + batch_text = self._batch_label(batch) if batch is not None else task.batch_id + lines = [ + "确定要删除本条本地记录吗?", + f"商品ID:{task.item_id}", + f"店铺:{self.model.account_name_for(task)}({task.alias})", + f"所属批次:{batch_text}", + ] + if int(getattr(task, "committed", 0) or 0) == 1: + lines.extend( + [ + "", + "该商品已提交过蝦皮。本次仅删除本地记录,不会回滚蝦皮线上商品。", + ] + ) + lines.extend( + [ + "", + "删除后,该记录不会出现在①、②、③的列表、筛选、采集、生成、更新或回写范围。", + "不会删除原 Excel、本地图片或账号 Chrome 数据。", + ] + ) + answer = QMessageBox.question( + self, + "删除本条记录", + "\n".join(lines), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + self._set_status("已取消删除本条记录") + return + try: + result = db.delete_task( + task.id, + reason="用户在更新蝦皮页删除单条记录", + path=self.db_path, + ) + except Exception as exc: + QMessageBox.warning(self, "删除本条记录", str(exc)) + self._set_status(f"删除本条记录失败:{exc}", level="danger") + return + self.refresh_tasks() + if self.refresh_workflow_callback is not None: + self.refresh_workflow_callback() + message = f"已删除本地记录:商品 {result['item_id']}" + self._set_status(message, level="success") + QMessageBox.information(self, "删除本条记录", message) + def reset_apply_status(self, checked=False): if self.apply_thread is not None or self.result_write_back_thread is not None: self._set_status("更新或回写正在进行,不能重置") diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index a413f55..9dfcf2f 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -982,6 +982,7 @@ class GenerateTab(QWidget): cover_prompts_dir=None, title_templates_dir=None, open_accounts_callback=None, + refresh_workflow_callback=None, ): super().__init__(parent) self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config @@ -993,6 +994,7 @@ class GenerateTab(QWidget): self.db_path = _database_path(db_path, self.config) self.status_callback = status_callback self.open_accounts_callback = open_accounts_callback + self.refresh_workflow_callback = refresh_workflow_callback self.title_prompt_path = title_prompt_path or appconfig.title_prompt_path(self.config) self.cover_prompts_dir = cover_prompts_dir or appconfig.cover_prompts_dir(self.config) self.title_templates_dir = title_templates_dir or appconfig.title_templates_dir(self.config) @@ -1185,6 +1187,7 @@ class GenerateTab(QWidget): self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.task_table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.task_table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed) + self.task_table.setContextMenuPolicy(Qt.CustomContextMenu) self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) self.task_table.horizontalHeader().setStretchLastSection(False) self.task_table.verticalHeader().setVisible(False) @@ -1271,6 +1274,7 @@ class GenerateTab(QWidget): self.stop_generate_button.clicked.connect(self.stop_generate) self.reset_generate_button.clicked.connect(self.reset_generated_result) self.task_table.leftDoubleClicked.connect(self.show_task_images) + self.task_table.customContextMenuRequested.connect(self.show_task_context_menu) self.refresh_title_templates() self.refresh_cover_templates() @@ -1937,6 +1941,83 @@ class GenerateTab(QWidget): f"已重置生成结果:{len(target_tasks)} 条,内容:{label},run_id={run_id}" ) + def show_task_context_menu(self, position): + index = self.task_table.indexAt(position) + if not index.isValid(): + return + self.task_table.selectRow(index.row()) + self.task_table.setCurrentIndex(index) + task = self.model.task_at(index.row()) + if task is None: + return + menu = QMenu(self.task_table) + delete_action = menu.addAction("删除本条记录") + delete_action.setEnabled( + self.generate_thread is None and getattr(task, "status", "") != "running" + ) + delete_action.triggered.connect(self.delete_selected_task) + menu.exec(self.task_table.viewport().mapToGlobal(position)) + + def delete_selected_task(self, checked=False): + if self.generate_thread is not None: + self._set_status("AI 生成正在进行,不能删除记录", level="warning") + return + task = self._selected_task() + if task is None: + self._set_status("请选择要删除的记录", level="warning") + return + if getattr(task, "status", "") == "running": + self._set_status("任务正在处理,不能删除", level="warning") + return + batch = db.get_batch(task.batch_id, path=self.db_path) + batch_text = self._batch_label(batch) if batch is not None else task.batch_id + lines = [ + "确定要删除本条本地记录吗?", + f"商品ID:{task.item_id}", + f"店铺:{self.model.account_name_for(task)}({task.alias})", + f"所属批次:{batch_text}", + ] + if int(getattr(task, "committed", 0) or 0) == 1: + lines.extend( + [ + "", + "该商品已提交过蝦皮。本次仅删除本地记录,不会回滚蝦皮线上商品。", + ] + ) + lines.extend( + [ + "", + "删除后,该记录不会出现在①、②、③的列表、筛选、采集、生成、更新或回写范围。", + "不会删除原 Excel、本地图片或账号 Chrome 数据。", + ] + ) + answer = QMessageBox.question( + self, + "删除本条记录", + "\n".join(lines), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + self._set_status("已取消删除本条记录") + return + try: + result = db.delete_task( + task.id, + reason="用户在AI生成页删除单条记录", + path=self.db_path, + ) + except Exception as exc: + QMessageBox.warning(self, "删除本条记录", str(exc)) + self._set_status(f"删除本条记录失败:{exc}", level="danger") + return + self.refresh_tasks() + if self.refresh_workflow_callback is not None: + self.refresh_workflow_callback() + message = f"已删除本地记录:商品 {result['item_id']}" + self._set_status(message, level="success") + QMessageBox.information(self, "删除本条记录", message) + def _reset_scope_tasks(self): selection = self.task_table.selectionModel() selected_rows = [] diff --git a/docs/api.md b/docs/api.md index ec6b8da..bfd112e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -544,6 +544,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。 - 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面。只生成封面时不调用生文,任务有新标题则使用新标题,否则使用已采集旧标题作为封面prompt回退;新旧标题都为空才不纳入。候选存在时由 `product_status.build_generation_plan()` 统计正常/未上架/审核中/未知并冻结指纹,弹出与①共用的 `ProductStatusScopeDialog`:默认按钮为“生成架上商品”(`normal_only`,仅将检测结果为正常的商品交给 AI),扩展按钮为“生成全部商品”(`all`,还会处理未上架、审核中和状态未知商品,可能额外消耗点数),后者使用警示橙色的扩大范围样式;历史未采集状态按未知处理,默认不入队。取消或指纹变化时不创建 Worker;无异常候选时 `all` 禁用。`GenerateWorker` 接收最终精确任务列表,并在 `normal_only` 下再次排除非正常任务后才调用 `ai.generate_batch()`。点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;运行日志 options/summary 记录范围、状态计数、范围排除数和计划指纹。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。 - 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` / `db.set_generated_cover()` 组件结果保留,可再次开始生成剩余任务。 +- ②任务表右键先选中鼠标命中行,仅提供「删除本条记录」;确认后调用 `db.delete_task()`,不物理删除 Excel、图片、账号 Chrome 或蝦皮线上商品。生成中或任务 `running` 时禁用;成功后通过主窗口刷新①②③。 - 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, existing_cover_path)` 并进入 generated;封面成功立即调用 `db.set_generated_cover(task_id, new_cover_path)`,只更新封面、不覆盖或伪造新标题。只生成封面得到“有封面、无新标题”时,②标题状态继续待生成、图片状态已生成。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 - T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。 @@ -558,6 +559,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 点击「开始更新」和「检查本轮更新」都先使用同一 `build_apply_plan()` 预检当前筛选任务:仅商品状态 `normal` 继续按③「更新内容」模式检查,只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新标题和封面要求两者都有。未上架、审核中、状态未知和历史 NULL 均不能传入正式或检查模式的 `ApplyWorker`;状态异常与内容缺失在确认框、状态栏摘要和本轮日志分类列出数量与示例商品ID,不打开其 Chrome、不写其失败状态或 Excel,也不计入 worker 成功/略过/失败统计。所有记录被排除时,若存在状态异常则弹「商品状态不允许更新」,否则弹「更新内容未生成」。确认后按 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式` 重新验证计划指纹,变化即中止。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前可执行任务超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/可执行任务数、预检跳过项、每批最大条数、预计批次数、提交线上风险和当前执行设置。 - 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。 - `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务,并在执行层再次拒绝商品状态不是 `normal` 的记录:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。 +- ③任务表右键先选中鼠标命中行,保留「重置更新状态」,并以分隔线提供「删除本条记录」。删除调用 `db.delete_task()`,运行中禁用;已提交线上时确认框明确本地删除不回滚蝦皮。成功后刷新①②③,不修改 Excel、图片、账号 Chrome 或线上商品。 - 检查本轮更新:不做账号登录预检,不调用 `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 bb822ce..27974a9 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -113,6 +113,7 @@ - 生成参数(标题/图片并发数、失败重试、分辨率、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/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。即使用户通过商品状态筛选查看异常商品,也必须在范围确认框主动选择「生成全部商品」才会入队;默认「生成架上商品」不会因筛选而放宽。 - 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。 +- ②任务表右键仅作用于鼠标命中的单条记录,提供「删除本条记录」。确认后调用任务级软删除:记录立即从①②③的列表、筛选和后续采集/生成/更新/回写范围排除;不删除原 Excel、本地图片、账号 Chrome 或蝦皮线上商品。生成中或任务运行中禁用该入口;已提交线上记录的确认框明确提示不回滚线上。 - **无逐条人工审核环节**;新标题默认使用 AI 输出,但允许对已生成且未提交线上的单行做本地微调。封面画廊内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条生图;用户翻看完后用状态筛选「待生成」并点击「开始生成」批量补封面。生成完即可进入 ③,③ 开始更新前会做批量确认。 ## ③ 更新蝦皮 @@ -150,6 +151,7 @@ - 若设置中开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;已进入编辑页后的失败任务和用户原本打开的 tab 不关闭。但如果失败发生在 `open_product()` 内部,程序自动新开的无效商品页 tab 要关闭,避免 Chrome 残留空错误页。 - 「开始更新」是③的主操作按钮,视觉上必须强于其他批处理按钮。 - 「重置更新状态」从底部批处理按钮移到任务表右键菜单/高级入口,仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交;不得静默清除 committed 历史事实。 +- ③任务表右键保留「重置更新状态」,并以分隔线增加「删除本条记录」。删除只对鼠标命中的一条本地记录生效,复用任务级软删除;更新或结果回写运行中禁用。确认内容须说明商品、店铺、批次及不删除 Excel、图片、账号 Chrome 和蝦皮线上商品;已提交线上记录额外提示本地删除不回滚线上。 - 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。 - 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。 diff --git a/docs/tasks/T-674.md b/docs/tasks/T-674.md index 55f2536..f8d0693 100644 --- a/docs/tasks/T-674.md +++ b/docs/tasks/T-674.md @@ -1,7 +1,7 @@ --- id: T-674 title: AI生成与更新蝦皮单条任务右键软删除 -status: TODO +status: DONE phase: 7 deps: [T-670, T-672] created: 2026-07-20 @@ -54,4 +54,8 @@ git diff --check ## 执行记录 -- 待实现。 +- ②任务表启用右键菜单:先选中命中行,仅提供「删除本条记录」;②生成中或任务运行中禁用/拒绝删除。 +- ③右键菜单保留「重置更新状态」,以分隔线增加「删除本条记录」;更新或回写运行中禁用/拒绝删除。 +- 两个 Tab 均复用 `db.delete_task()`,确认框显示商品、店铺、批次与本地软删除边界;已提交线上记录明确提示不回滚蝦皮。成功后经主窗口刷新回调同步①②③。 +- 更新路由/API 文档,并新增②/③右键选中、菜单内容、取消、已提交提示、软删除成功刷新和运行中拦截的 GUI 回归测试。 +- 验证通过:`py -3.10 -m unittest discover -s tests -p "test_gui.py"`(208 项)、`py -3.10 -m unittest discover -s tests -p "test_db.py"`(21 项)、`py -3.10 -m unittest discover -s tests`(627 项)、`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 67df79a..31661c6 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -9026,6 +9026,192 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generate_tab_context_menu_soft_deletes_only_clicked_task(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": row, + "account_name": "主店", + "alias": "alias-a", + "item_id": item_id, + } + for row, item_id in ((2, "51100639510"), (3, "51100639511")) + ], + path=cfg["db_path"], + ) + first, second = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + for task in (first, second): + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) + db.set_applied(first.id, True, path=cfg["db_path"]) + statuses = [] + refresh_calls = [] + tab = GenerateTab( + config=cfg, + status_callback=statuses.append, + refresh_workflow_callback=lambda: refresh_calls.append(True), + ) + self.addCleanup(tab.close) + tab.resize(1000, 600) + tab.show() + QApplication.processEvents() + first_rect = tab.task_table.visualRect(tab.model.index(0, 0)) + self.assertTrue(first_rect.isValid()) + + class FakeMenu: + instances = [] + + def __init__(self, parent=None): + self.actions = [] + FakeMenu.instances.append(self) + + def addAction(self, text): + action = SimpleNamespace( + text=text, + enabled=True, + triggered=DummySignal(), + ) + action.setEnabled = lambda enabled: setattr(action, "enabled", bool(enabled)) + self.actions.append(action) + return action + + def exec(self, position): + return None + + with mock.patch("app.gui.tabs.generate.QMenu", FakeMenu): + tab.show_task_context_menu(first_rect.center()) + self.assertEqual(0, tab.task_table.currentIndex().row()) + self.assertEqual(["删除本条记录"], [action.text for action in FakeMenu.instances[0].actions]) + self.assertTrue(FakeMenu.instances[0].actions[0].enabled) + + with mock.patch( + "app.gui.tabs.generate.QMessageBox.question", + return_value=gui.QMessageBox.No, + ) as question: + tab.delete_selected_task() + self.assertIsNotNone(db.get_task(first.id, path=cfg["db_path"])) + self.assertIn("不会回滚蝦皮线上商品", question.call_args[0][2]) + self.assertIn("已取消删除本条记录", statuses[-1]) + + with mock.patch( + "app.gui.tabs.generate.QMessageBox.question", + return_value=gui.QMessageBox.Yes, + ), mock.patch("app.gui.tabs.generate.QMessageBox.information") as information: + tab.delete_selected_task() + self.assertIsNone(db.get_task(first.id, path=cfg["db_path"])) + self.assertEqual([second.id], [task.id for task in tab.model.tasks]) + self.assertEqual([True], refresh_calls) + self.assertIn("已删除本地记录:商品 51100639510", information.call_args[0][2]) + + tab.generate_thread = object() + tab.delete_selected_task() + self.assertIn("AI 生成正在进行,不能删除记录", statuses[-1]) + + self.assert_removed(temp_dir) + + def test_apply_tab_context_menu_keeps_reset_and_soft_deletes_clicked_task(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": row, + "account_name": "主店", + "alias": "alias-a", + "item_id": item_id, + } + for row, item_id in ((2, "51100639510"), (3, "51100639511")) + ], + path=cfg["db_path"], + ) + first, second = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + for task in (first, second): + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) + db.set_applied(first.id, True, path=cfg["db_path"]) + statuses = [] + refresh_calls = [] + tab = ApplyTab( + config=cfg, + status_callback=statuses.append, + refresh_workflow_callback=lambda: refresh_calls.append(True), + ) + self.addCleanup(tab.close) + tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) + tab.resize(1000, 600) + tab.show() + QApplication.processEvents() + first_rect = tab.task_table.visualRect(tab.model.index(0, 0)) + self.assertTrue(first_rect.isValid()) + + class FakeMenu: + instances = [] + + def __init__(self, parent=None): + self.actions = [] + self.separator_count = 0 + FakeMenu.instances.append(self) + + def addAction(self, text): + action = SimpleNamespace( + text=text, + enabled=True, + triggered=DummySignal(), + ) + action.setEnabled = lambda enabled: setattr(action, "enabled", bool(enabled)) + self.actions.append(action) + return action + + def addSeparator(self): + self.separator_count += 1 + + def exec(self, position): + return None + + with mock.patch("app.gui.tabs.apply.QMenu", FakeMenu): + tab.show_task_context_menu(first_rect.center()) + self.assertEqual(0, tab.task_table.currentIndex().row()) + menu = FakeMenu.instances[0] + self.assertEqual(["重置更新状态", "删除本条记录"], [action.text for action in menu.actions]) + self.assertEqual(1, menu.separator_count) + self.assertTrue(all(action.enabled for action in menu.actions)) + + with mock.patch( + "app.gui.tabs.apply.QMessageBox.question", + return_value=gui.QMessageBox.No, + ) as question: + tab.delete_selected_task() + self.assertIsNotNone(db.get_task(first.id, path=cfg["db_path"])) + self.assertIn("不会回滚蝦皮线上商品", question.call_args[0][2]) + + with mock.patch( + "app.gui.tabs.apply.QMessageBox.question", + return_value=gui.QMessageBox.Yes, + ), mock.patch("app.gui.tabs.apply.QMessageBox.information") as information: + tab.delete_selected_task() + self.assertIsNone(db.get_task(first.id, path=cfg["db_path"])) + self.assertEqual([second.id], [task.id for task in tab.model.tasks]) + self.assertEqual([True], refresh_calls) + self.assertIn("已删除本地记录:商品 51100639510", information.call_args[0][2]) + + tab.apply_thread = object() + tab.delete_selected_task() + self.assertIn("更新或回写正在进行,不能删除记录", statuses[-1]) + + self.assert_removed(temp_dir) + def test_collect_tab_can_filter_unmatched_tasks_from_summary_bar(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)