From f7ea9ec0fa0aaeeed14fbc0f8f2207b4b8e313b0 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 30 Jun 2026 11:50:49 +0800 Subject: [PATCH] feat: add selected task reset actions --- app/db.py | 75 +++++++++++++++++++++ app/gui.py | 163 ++++++++++++++++++++++++++++++++++++++++++++++ docs/api.md | 16 +++-- docs/routes.md | 8 ++- tests/test_db.py | 52 +++++++++++++++ tests/test_gui.py | 106 ++++++++++++++++++++++++++++++ 6 files changed, 412 insertions(+), 8 deletions(-) diff --git a/app/db.py b/app/db.py index 000a6f8..c9fe8e9 100644 --- a/app/db.py +++ b/app/db.py @@ -518,6 +518,16 @@ def list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, co return _fetch_all(database, sql, params, Task) +def get_task(task_id, path=None, conn=None): + with _connection(conn, path) as database: + return _fetch_one( + database, + "SELECT * FROM tasks WHERE id = ?", + (int(task_id),), + Task, + ) + + def mark_running(task_id, phase, path=None, conn=None) -> None: _attempt_field(phase) with _connection(conn, path) as database: @@ -639,6 +649,71 @@ def set_applied(task_id, committed, error=None, path=None, conn=None) -> None: ) +def reset_generated(task_id, delete_file=False, path=None, conn=None) -> dict: + """Clear local AI result and move the task back to collected state.""" + + with _connection(conn, path) as database: + before = get_task(task_id, conn=database) + if before is None: + raise DbError(f"任务不存在: {task_id}") + new_cover_path = before.new_cover_path + deleted_file = None + if delete_file: + raise DbError("重置生成结果默认不删除本地新封面文件,请手动确认后清理") + now = _now() + with database: + database.execute( + """ + UPDATE tasks + SET new_title = NULL, + new_cover_path = NULL, + stage = 'collected', + status = 'success', + last_error = NULL, + generated_at = NULL, + updated_at = ? + WHERE id = ? + """, + (now, int(task_id)), + ) + after = get_task(task_id, conn=database) + return { + "task_id": int(task_id), + "before": before, + "after": after, + "new_cover_path": new_cover_path, + "deleted_file": deleted_file, + } + + +def reset_apply_status(task_id, path=None, conn=None) -> dict: + """Move a generated/applied local task back to pending update state.""" + + with _connection(conn, path) as database: + before = get_task(task_id, conn=database) + if before is None: + raise DbError(f"任务不存在: {task_id}") + now = _now() + with database: + database.execute( + """ + UPDATE tasks + SET stage = 'generated', + status = 'pending', + last_error = NULL, + updated_at = ? + WHERE id = ? + """, + (now, int(task_id)), + ) + after = get_task(task_id, conn=database) + return { + "task_id": int(task_id), + "before": before, + "after": after, + } + + def create_run_log( run_type, dry_run=False, diff --git a/app/gui.py b/app/gui.py index 9f68792..61ae937 100644 --- a/app/gui.py +++ b/app/gui.py @@ -104,6 +104,43 @@ if QT_IMPORT_ERROR is None: return db_path or appconfig.db_path(config) + def _write_reset_run_log(db_path, task, action, message): + run_id = db.create_run_log( + "reset", + total=1, + options={ + "action": action, + "task_id": getattr(task, "id", None), + "alias": getattr(task, "alias", None), + "item_id": getattr(task, "item_id", None), + }, + path=db_path, + ) + db.add_run_log_event( + run_id, + message, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + path=db_path, + ) + db.finish_run_log( + run_id, + status="done", + done=1, + success_count=1, + failed_count=0, + summary_json={ + "action": action, + "task_id": getattr(task, "id", None), + "alias": getattr(task, "alias", None), + "item_id": getattr(task, "item_id", None), + }, + path=db_path, + ) + return run_id + + class TaskTableModel(QAbstractTableModel): """Table model for task rows shared by workflow tabs.""" @@ -450,6 +487,8 @@ if QT_IMPORT_ERROR is None: self.preview_prompt_button = QPushButton("预览") self.generate_button = QPushButton("开始生成") self.stop_generate_button = QPushButton("停止") + self.reset_generate_button = QPushButton("重置生成结果") + self.reset_generate_button.setObjectName("resetGenerateButton") self.stop_generate_button.setEnabled(False) self.progress_label = QLabel("进度:标题0/0 · 封面0/0 · 失败0") @@ -535,6 +574,7 @@ if QT_IMPORT_ERROR is None: bottom_layout.addStretch(1) bottom_layout.addWidget(self.generate_button) bottom_layout.addWidget(self.stop_generate_button) + bottom_layout.addWidget(self.reset_generate_button) layout = QVBoxLayout(self) layout.setContentsMargins(18, 18, 18, 18) @@ -557,6 +597,7 @@ if QT_IMPORT_ERROR is None: self.preview_prompt_button.clicked.connect(self.preview_cover_prompt) self.generate_button.clicked.connect(self.start_generate) self.stop_generate_button.clicked.connect(self.stop_generate) + self.reset_generate_button.clicked.connect(self.reset_generated_result) self.task_table.doubleClicked.connect(self.show_task_images) self.refresh_cover_templates() @@ -773,6 +814,61 @@ if QT_IMPORT_ERROR is None: self.generate_worker.cancel() self._set_status("正在停止 AI 生成...") + def reset_generated_result(self, checked=False): + if self.generate_thread is not None: + self._set_status("AI 生成正在进行,不能重置") + return + task = self._selected_task() + if task is None: + self._set_status("请选择要重置生成结果的任务") + return + has_ai_result = bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)) + if not has_ai_result and getattr(task, "stage", None) not in {"generated", "applied"}: + self._set_status("选中任务没有可重置的生成结果") + return + lines = [ + "确定重置当前选中任务的本地生成结果吗?", + "", + f"商品ID:{task.item_id}", + f"店铺:{self.model.account_name_for(task)}", + "", + "将清空新标题、新封面路径和错误信息,并退回到已采集状态。", + "默认不删除本地新封面文件,不触碰 Shopee,也不会自动回写 Excel。", + ] + if getattr(task, "new_cover_path", None): + lines.append(f"本地新封面文件保留:{task.new_cover_path}") + if getattr(task, "committed", 0): + lines.extend([ + "", + "注意:该记录曾经提交过线上。本地重置不会回滚 Shopee,后续重新生成/更新可能再次提交线上。", + ]) + answer = QMessageBox.question( + self, + "重置生成结果", + "\n".join(lines), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + self._set_status("已取消重置生成结果") + return + try: + db.reset_generated(task.id, path=self.db_path) + message = ( + "action=reset_generated step=db_write result=success " + f"detail=清空AI生成结果 task_id={task.id}" + ) + run_id = _write_reset_run_log(self.db_path, task, "reset_generated", message) + except Exception as exc: + QMessageBox.warning(self, "重置生成结果", str(exc)) + self._set_status(f"重置生成结果失败:{exc}") + return + self.refresh_tasks() + self._append_generate_log(message) + self._set_status( + f"已重置生成结果:商品 {task.item_id},run_id={run_id}" + ) + def show_task_images(self, index): task = self.model.task_at(index.row()) if index.isValid() else self._selected_task() if task is None: @@ -820,6 +916,7 @@ if QT_IMPORT_ERROR is None: def _set_generate_running(self, running): self.generate_button.setEnabled(not running) self.stop_generate_button.setEnabled(running) + self.reset_generate_button.setEnabled(not running) self.refresh_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) @@ -1077,6 +1174,8 @@ if QT_IMPORT_ERROR is None: self.start_update_button = QPushButton("开始更新") self.stop_update_button = QPushButton("停止") + self.reset_update_button = QPushButton("重置更新状态") + self.reset_update_button.setObjectName("resetUpdateButton") self.write_back_button = QPushButton("回写结果到 Excel") self.stop_update_button.setEnabled(False) self.write_back_button.setEnabled(False) @@ -1084,6 +1183,7 @@ if QT_IMPORT_ERROR is None: action_layout = QHBoxLayout() action_layout.addWidget(self.start_update_button) action_layout.addWidget(self.stop_update_button) + action_layout.addWidget(self.reset_update_button) action_layout.addStretch(1) action_layout.addWidget(self.write_back_button) @@ -1104,6 +1204,7 @@ if QT_IMPORT_ERROR is None: self.refresh_button.clicked.connect(self.refresh_tasks) self.start_update_button.clicked.connect(self.start_update) self.stop_update_button.clicked.connect(self.stop_update) + self.reset_update_button.clicked.connect(self.reset_apply_status) self.write_back_button.clicked.connect(self.write_back_results) self.refresh_tasks() @@ -1217,6 +1318,66 @@ if QT_IMPORT_ERROR is None: return self._start_result_write_back(batch_ids, auto=False) + 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("更新或回写正在进行,不能重置") + return + task = self._selected_task() + if task is None: + self._set_status("请选择要重置更新状态的任务") + return + if not (getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)): + self._set_status("选中任务没有新标题或新封面,不能重置为可更新") + return + lines = [ + "确定重置当前选中任务的本地更新状态吗?", + "", + f"商品ID:{task.item_id}", + f"店铺:{self.model.account_name_for(task)}", + "", + "将保留新标题和新封面路径,只把本地状态退回可更新。", + "不会触碰 Shopee,也不会自动回写 Excel。", + ] + if getattr(task, "committed", 0): + lines.extend([ + "", + "注意:该记录已经提交过线上。本地重置不会回滚 Shopee,重复更新会再次提交线上。", + ]) + answer = QMessageBox.question( + self, + "重置更新状态", + "\n".join(lines), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + self._set_status("已取消重置更新状态") + return + try: + db.reset_apply_status(task.id, path=self.db_path) + message = ( + "action=reset_apply_status step=db_write result=success " + f"detail=退回可更新 task_id={task.id}" + ) + run_id = _write_reset_run_log(self.db_path, task, "reset_apply_status", message) + except Exception as exc: + QMessageBox.warning(self, "重置更新状态", str(exc)) + self._set_status(f"重置更新状态失败:{exc}") + return + self.refresh_tasks() + self._append_run_log(message) + self._set_status( + f"已重置更新状态:商品 {task.item_id},run_id={run_id}" + ) + + def _selected_task(self): + index = self.task_table.currentIndex() + if index.isValid(): + return self.model.task_at(index.row()) + if self.model.rowCount() > 0: + return self.model.task_at(0) + return None + def _is_actionable_task(self, task): return ( getattr(task, "stage", None) == "generated" @@ -1378,6 +1539,7 @@ if QT_IMPORT_ERROR is None: def _set_apply_running(self, running): self.start_update_button.setEnabled(not running) self.stop_update_button.setEnabled(running) + self.reset_update_button.setEnabled(not running) self.refresh_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) @@ -1387,6 +1549,7 @@ if QT_IMPORT_ERROR is None: def _set_result_write_back_running(self, running): self.start_update_button.setEnabled(not running) + self.reset_update_button.setEnabled(not running) self.refresh_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) diff --git a/docs/api.md b/docs/api.md index 86426df..8c403b9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -88,6 +88,9 @@ mark_skipped(task_id, reason) -> None # status=skipped,stage 不前 set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1 set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1 set_applied(task_id, committed, error=None) -> None # 成功 stage=applied;失败 status=failed 且 stage 不前进 +# T-404a 已实现:选中记录重置 +reset_generated(task_id, delete_file=False) -> dict # 本地清空 AI 结果并退回 collected;默认不删新封面文件 +reset_apply_status(task_id) -> dict # 本地退回 generated/pending 供重复更新;保留 new_* 与 committed 历史事实 # 运行日志 create_run_log(run_type, dry_run=False, total=0, options=None) -> int finish_run_log(run_id, **fields) -> None # status/done/success_count/skipped_count/failed_count/summary_json @@ -232,11 +235,11 @@ collect(account, task) -> dict # -> {old_title, old_cover_path} # 应用 change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等 replace_cover(cdp, image_win_path, old_cover_path=None) -> dict -# 上传→等CDN→拖第一位;满9张时先确认 old_cover_path 非空且文件存在, +# 上传→等 Shopee CDN 地址→拖第一位;失败返回 upload_state 诊断;满9张时先确认 old_cover_path 非空且文件存在, # 再点第一张删除和可见确认框;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片 -click_update(cdp) -> dict # {clicked, reason};禁用则记失败 +click_update(cdp, confirm_timeout=3) -> dict # {clicked, reason, toasts, confirm?};禁用或 Shopee 二次确认未完成则记失败 apply_task(account, task, close_success_tab=False) -> dict -# 对已生成任务:换标题+换封面+点「更新」提交(调用前必须已通过安全开关和批量确认) +# 对已生成任务:换标题+换封面+点页面「更新」;若出现 Shopee 确认框,只点弹窗主按钮「更新」,不点「立即優化」(调用前必须已通过安全开关和批量确认) # close_success_tab=True 时,仅成功提交且商品页为本轮自动新开时关闭该 tab # -> {committed, error} ``` @@ -248,7 +251,8 @@ apply_task(account, task, close_success_tab=False) -> dict - `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。 - `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。 - `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;用户原本打开的商品 tab 不关闭。 -- ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。 +- ③ 更新流程默认不关闭商品页;若 `close_success_tab=True`,只在提交成功且商品页为本轮自动新开时关闭,失败任务和用户原本打开的 tab 保留现场。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`。 +- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败并保留现场。 - T-502 满 9 张封面删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。 ## ai 模块(`app/ai.py`,已建,外部 AI,通用 HTTP) @@ -384,6 +388,7 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 底部「开始生成」只处理当前筛选结果里 `stage=collected` 的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;通过 `GenerateWorker` 调 `ai.generate_batch()`,先并发标题、再并发封面。 - 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。 - 进度标签显示:`标题x/n · 封面x/n · 失败z`;每条封面生成成功后立即 `db.set_generated()`,失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 +- T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。 ③ 更新shopee当前要点(T-303b/T-401/T-402/T-403): @@ -394,13 +399,14 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库。 - 点击「开始更新」先读取 `shopee_update`:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,弹警告并阻断;通过后才弹窗展示批次/店铺/商品ID/状态/任务数、提交线上风险和当前安全设置。 - 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker`。dry-run 开启时只预览;dry-run 关闭时才可能真实提交。 -- `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交。 +- `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。 - dry-run:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。 - 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态。 - 预检通过后默认串行;若 `parallel_accounts=true` 且 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task, close_success_tab=设置值)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。 - dry-run 和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;③ 页面显示最近运行日志。 - 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;失败和复用的用户已有 tab 不关闭。 - 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。 +- T-404a 已实现:「重置更新状态」读取当前选中单条;确认后保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 供重复更新;`committed=1` 时必须提示线上已提交过且不回滚 Shopee,并保留 committed 历史事实/运行日志。 - ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。 - 更新完成后自动调用 `WriteBackWorker(mode="results")` → `excel.write_back_results()`,把新标题、新封面路径、更新状态写回原 Excel;原文件被占用时提示关闭后点击「回写结果到 Excel」手动重试,SQLite 更新结果不回滚。 - 自动结果回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数;若没有可回写批次,也会弹出更新汇总。 diff --git a/docs/routes.md b/docs/routes.md index 1902a42..bccd735 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -72,7 +72,7 @@ │ │[ ]│ │ (双击某行 → 弹窗看 旧封面 | 新封面) │ │ │ │ │ │ 运行日志:逐条记录标题/封面生成步骤与失败原因 │ │ │ └──────────┘ └───────────────────────────────────────────────┘ │ -│ 进度:标题30/30 · 封面12/30 · 失败1 [▶ 开始生成] [■停止] │ +│ 进度:标题30/30 · 封面12/30 · 失败1 [▶ 开始生成][■停止][重置生成结果] │ └───────────────────────────────────────────────────────────────┘ ``` @@ -85,6 +85,7 @@ - 底部**单个「开始生成」+「停止」**:开始生成 = **先按 `title_concurrency` 并发生成标题,接着按 `image_concurrency` 并发生成封面**;进度实时显示 标题/封面/失败 计数。 - 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、模型/Key)在 **⑤ 设置**,不在本 Tab 重复。 - 每条/每张完成即写库;`run_type=generate` 写 `run_logs/run_log_events`,图片生成失败同时写本地 `logs/cmshopee.log` 脱敏 traceback;「停止」取消未开始项,可再次「开始生成」对剩余继续。 +- 「重置生成结果」仅作用当前选中单条,运行中禁用;确认后只改本地 DB,清空 `new_title/new_cover_path/last_error` 并退回 `collected/success` 供重新生成,默认不删除本地新封面文件。若该记录已提交过线上,必须在确认框提示本地重置不回滚 Shopee。 - **无逐条人工审核环节**;新标题直接用 AI 输出(不可编辑);可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。 ## ③ 更新shopee @@ -97,7 +98,7 @@ │ │ 店铺 商品ID 新标题 新封面 阶段 结果 │ │ │ └───────────────────────────────────────────────────────────┘ │ │ 运行日志:dry-run/真实更新逐条留痕 │ -│ [▶ 开始更新] [■停止] [回写结果到 Excel] │ +│ [▶ 开始更新] [■停止] [重置更新状态] [回写结果到 Excel] │ └───────────────────────────────────────────────────────────────┘ ``` @@ -106,13 +107,14 @@ - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 - 点击「开始更新」先读取 ⑤ `shopee_update` 安全设置:dry-run 关闭且未允许真实提交、超过单次最大条数、包含非测试商品 ID、或包含新封面但未允许封面更新时,直接弹警告并阻断;dry-run 开启时只预览,不进入 Shopee。 - 安全开关通过后,弹窗展示本次筛选条件、任务数量、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 -- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。 +- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。 - 满 9 张封面时,删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。 - 默认串行、单条失败继续;⑤ 可开启多账号并行,不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。 - dry-run 只写运行日志与预览汇总,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel。 - 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;dry-run 和真实更新都会写 `run_logs/run_log_events`。 - 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。 - 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;失败任务和用户原本打开的 tab 不关闭。 +- 「重置更新状态」仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚 Shopee、重复更新会再次提交;不得静默清除 committed 历史事实。 - 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。 - 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。 diff --git a/tests/test_db.py b/tests/test_db.py index 775b5da..bee945d 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -115,6 +115,58 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + + def test_reset_generated_and_apply_status_keep_local_history(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + batch_id = db.create_batch(["input.xlsx"], path=db_path) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "Sheet1", + "source_row": 2, + "account_name": "shop", + "alias": "alias", + "item_id": "51100639510", + } + ], + path=db_path, + ) + task = db.list_tasks(batch_id=batch_id, path=db_path)[0] + new_cover = os.path.join(temp_dir, "new.jpg") + with open(new_cover, "wb") as fh: + fh.write(b"jpeg") + + db.set_collected(task.id, "旧标题", "old.jpg", path=db_path) + db.set_generated(task.id, "新标题", new_cover, path=db_path) + db.set_applied(task.id, True, path=db_path) + + reset_apply = db.reset_apply_status(task.id, path=db_path) + self.assertEqual("applied", reset_apply["before"].stage) + after_apply = reset_apply["after"] + self.assertEqual("generated", after_apply.stage) + self.assertEqual("pending", after_apply.status) + self.assertEqual("新标题", after_apply.new_title) + self.assertEqual(new_cover, after_apply.new_cover_path) + self.assertEqual(1, after_apply.committed) + + reset_generated = db.reset_generated(task.id, path=db_path) + after_generated = reset_generated["after"] + self.assertEqual(new_cover, reset_generated["new_cover_path"]) + self.assertIsNone(reset_generated["deleted_file"]) + self.assertTrue(os.path.exists(new_cover)) + self.assertEqual("collected", after_generated.stage) + self.assertEqual("success", after_generated.status) + self.assertIsNone(after_generated.new_title) + self.assertIsNone(after_generated.new_cover_path) + self.assertIsNone(after_generated.last_error) + self.assertEqual(1, after_generated.committed) + + self.assert_removed(temp_dir) + def test_duplicate_task_and_invalid_update_raise_clear_errors(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_gui.py b/tests/test_gui.py index f6d8fbf..e5b5412 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -509,6 +509,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("保存标题提示词", tab.save_title_button.text()) self.assertEqual("开始生成", tab.generate_button.text()) self.assertEqual("停止", tab.stop_generate_button.text()) + self.assertEqual("重置生成结果", tab.reset_generate_button.text()) self.assertFalse(tab.stop_generate_button.isEnabled()) self.assertEqual("进度:标题0/0 · 封面0/0 · 失败0", tab.progress_label.text()) self.assertEqual("默认", tab.cover_template_combo.currentText()) @@ -595,6 +596,58 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + + def test_generate_tab_resets_selected_generated_result_and_writes_reset_log(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", + } + ], + path=cfg["db_path"], + ) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + new_cover = os.path.join(temp_dir, "new.jpg") + with open(new_cover, "wb") as fh: + fh.write(b"jpeg") + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(task.id, "新标题", new_cover, path=cfg["db_path"]) + statuses = [] + tab = GenerateTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + tab.task_table.selectRow(0) + + with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question: + tab.reset_generated_result() + + message = question.call_args[0][2] + self.assertIn("默认不删除本地新封面文件", message) + self.assertIn(new_cover, message) + updated = db.get_task(task.id, path=cfg["db_path"]) + self.assertEqual("collected", updated.stage) + self.assertEqual("success", updated.status) + self.assertIsNone(updated.new_title) + self.assertIsNone(updated.new_cover_path) + self.assertTrue(os.path.exists(new_cover)) + run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0] + self.assertEqual("done", run_log.status) + self.assertEqual("reset_generated", run_log.options["action"]) + events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) + self.assertTrue(any("action=reset_generated" in event.message for event in events)) + self.assertIn("已重置生成结果", statuses[-1]) + + self.assert_removed(temp_dir) + def test_generate_worker_calls_generate_batch_and_emits_signals(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -847,6 +900,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("开始更新", tab.start_update_button.text()) self.assertEqual("applyItemFilter", tab.item_filter.objectName()) self.assertEqual("停止", tab.stop_update_button.text()) + self.assertEqual("重置更新状态", tab.reset_update_button.text()) self.assertFalse(tab.stop_update_button.isEnabled()) self.assertEqual(2, tab.model.rowCount()) self.assertEqual("主店", tab.model.index(0, 0).data()) @@ -890,6 +944,58 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + + def test_apply_tab_resets_selected_apply_status_and_warns_committed_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": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + 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(task.id, True, path=cfg["db_path"]) + statuses = [] + tab = ApplyTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + tab.status_filter.setCurrentIndex(tab.status_filter.findData("applied")) + tab.task_table.selectRow(0) + + with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question: + tab.reset_apply_status() + + message = question.call_args[0][2] + self.assertIn("已经提交过线上", message) + self.assertIn("本地重置不会回滚 Shopee", message) + self.assertIn("重复更新会再次提交线上", message) + updated = db.get_task(task.id, path=cfg["db_path"]) + self.assertEqual("generated", updated.stage) + self.assertEqual("pending", updated.status) + self.assertEqual("新标题", updated.new_title) + self.assertEqual("new.jpg", updated.new_cover_path) + self.assertEqual(1, updated.committed) + run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0] + self.assertEqual("done", run_log.status) + self.assertEqual("reset_apply_status", run_log.options["action"]) + events = db.list_run_log_events(run_log.id, path=cfg["db_path"]) + self.assertTrue(any("action=reset_apply_status" in event.message for event in events)) + self.assertIn("已重置更新状态", statuses[-1]) + + self.assert_removed(temp_dir) + def test_apply_tab_start_update_requires_confirmation_before_starting_worker(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)