diff --git a/app/gui.py b/app/gui.py index 563c71f..d398cfa 100644 --- a/app/gui.py +++ b/app/gui.py @@ -951,11 +951,21 @@ if QT_IMPORT_ERROR is None: ("全部状态", "all"), ] - def __init__(self, parent=None, db_path=None, config=None, status_callback=None): + def __init__( + self, + parent=None, + db_path=None, + config=None, + status_callback=None, + open_accounts_callback=None, + ): super().__init__(parent) self.config = appconfig.load_config() if config is None else config self.db_path = _database_path(db_path, self.config) self.status_callback = status_callback + self.open_accounts_callback = open_accounts_callback + self.apply_worker = None + self.apply_thread = None self.batch_filter = QComboBox() self.batch_filter.setObjectName("applyBatchFilter") @@ -1012,6 +1022,7 @@ if QT_IMPORT_ERROR is None: self.status_filter.currentIndexChanged.connect(self.refresh_tasks) 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.refresh_tasks() @@ -1051,7 +1062,13 @@ if QT_IMPORT_ERROR is None: ) def start_update(self, checked=False): - tasks = list(self.model.tasks) + if self.apply_thread is not None: + self._set_status("更新正在进行...") + return + tasks = [ + task for task in self.model.tasks + if self._is_actionable_task(task) + ] if not tasks: self._set_status("当前筛选结果没有可更新任务") return @@ -1065,8 +1082,31 @@ if QT_IMPORT_ERROR is None: if answer != QMessageBox.Yes: self._set_status("已取消开始更新") return - self._set_status( - f"已确认更新范围:{len(tasks)} 条;实际更新执行将在 T-402 接入" + worker = ApplyWorker(tasks, db_path=self.db_path, config=self.config) + worker.progress.connect(self._on_apply_progress) + worker.row_updated.connect(self._on_apply_row_updated) + worker.log.connect(self._set_status) + worker.failed.connect(self._on_apply_failed) + worker.finished.connect(self._on_apply_finished) + worker.cancelled.connect(self._on_apply_cancelled) + thread = run_worker(worker, thread_name="ApplyWorker", start=False) + thread.finished.connect(lambda: self._forget_apply_thread(thread)) + self.apply_worker = worker + self.apply_thread = thread + self._set_apply_running(True) + self._set_status(f"开始更新:{len(tasks)} 条") + thread.start() + + def stop_update(self, checked=False): + if self.apply_worker is not None: + self.apply_worker.cancel() + self._set_status("正在停止更新...") + + def _is_actionable_task(self, task): + return ( + getattr(task, "stage", None) == "generated" + and getattr(task, "status", None) in {"success", "pending", "failed"} + and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)) ) def _populate_batch_filter(self, batches, selected_batch): @@ -1152,6 +1192,91 @@ 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.refresh_button.setEnabled(not running) + self.batch_filter.setEnabled(not running) + self.shop_filter.setEnabled(not running) + self.status_filter.setEnabled(not running) + self.write_back_button.setEnabled(False) + + def _forget_apply_thread(self, thread): + if self.apply_thread is thread: + self.apply_thread = None + self.apply_worker = None + + def _on_apply_progress(self, payload): + self._set_status("更新进度:" + self._apply_progress_text(payload)) + + def _on_apply_row_updated(self, task_id, fields): + self.refresh_tasks() + + def _on_apply_failed(self, task_id, error): + self._set_status(f"任务 {task_id} 更新失败:{error}") + + def _on_apply_finished(self, payload): + self._set_apply_running(False) + self.refresh_tasks() + if payload.get("blocked"): + self._show_apply_blocked(payload) + return + self._set_status("更新完成:" + self._apply_progress_text(payload)) + + def _on_apply_cancelled(self, payload): + self._set_apply_running(False) + self.refresh_tasks() + self._set_status("更新已停止:" + self._apply_progress_text(payload)) + + def _apply_progress_text(self, payload): + return "完成{done}/{total},成功{applied},略过{skipped},失败{failed}".format( + done=payload.get("done", 0), + total=payload.get("total", 0), + applied=payload.get("applied", 0), + skipped=payload.get("skipped", 0), + failed=payload.get("failed", 0), + ) + + def _show_apply_blocked(self, payload): + lines = ["更新前检查未通过。"] + if payload.get("no_accounts"): + lines.append("当前没有配置账号。") + not_running = payload.get("not_running") or [] + if not_running: + lines.append( + "以下账号 Chrome 未启动或调试端口不可访问:" + + "、".join(self._account_label(item) for item in not_running) + ) + logged_out = payload.get("logged_out") or [] + if logged_out: + lines.append( + "以下账号未登录 Shopee:" + + "、".join(self._account_label(item) for item in logged_out) + ) + self._show_account_guide("\n".join(lines)) + + def _show_account_guide(self, message): + full_message = ( + f"{message}\n\n" + "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。" + ) + QMessageBox.warning(self, "账号未就绪", full_message) + self._set_status(full_message.replace("\n", " ")) + if self.open_accounts_callback is not None: + self.open_accounts_callback() + + def _account_label(self, item): + if isinstance(item, dict): + name = item.get("account_name") or item.get("alias") or "" + alias = item.get("alias") or "" + reason = item.get("reason") + else: + name = getattr(item, "account_name", "") or getattr(item, "alias", "") + alias = getattr(item, "alias", "") + reason = getattr(item, "reason", None) + label = f"{name}({alias})" if alias and name != alias else (name or alias) + return f"{label}: {reason}" if reason else label + class CollectTab(QWidget): """Tab 1: import Excel files and list imported tasks.""" @@ -1684,6 +1809,183 @@ if QT_IMPORT_ERROR is None: self.row_updated.emit(int(task_id), dict(fields or {})) + class ApplyWorker(BaseWorker): + """Apply generated title/cover changes to Shopee one task at a time.""" + + def __init__(self, tasks, db_path=None, config=None, preflight=True): + super().__init__() + self.tasks = list(tasks) + self.db_path = db_path + self.config = config + self.preflight = preflight + + def execute(self): + account_rows = accounts.list_accounts(path=self.db_path, config=self.config) + account_by_alias = { + str(account.alias).strip(): account + for account in account_rows + if str(account.alias).strip() + } + eligible = [task for task in self.tasks if self._is_actionable_task(task)] + total = len(eligible) + applied = 0 + skipped = 0 + failed = 0 + done = 0 + + if self.preflight: + blocked = self._preflight_block(eligible, account_rows, account_by_alias) + if blocked: + blocked.update( + { + "ok": False, + "blocked": True, + "total": total, + "done": 0, + "applied": 0, + "skipped": 0, + "failed": 0, + } + ) + return blocked + + for task in eligible: + if self.should_cancel(): + break + account = account_by_alias.get(str(task.alias).strip()) + if account is None: + skipped += 1 + done += 1 + reason = "别名未匹配账号" + db.mark_skipped(task.id, reason, path=self.db_path) + self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) + self._emit_progress(done, total, applied, skipped, failed) + continue + + try: + db.mark_running(task.id, "apply", path=self.db_path) + self.row_updated.emit(task.id, {"status": "running", "last_error": None}) + result = editor.apply_task(account, task) + committed = bool(result.get("committed")) and not result.get("error") + error = result.get("error") + if committed: + db.set_applied(task.id, True, path=self.db_path) + applied += 1 + self.row_updated.emit( + task.id, + { + "stage": "applied", + "status": "success", + "committed": 1, + "last_error": None, + }, + ) + else: + failed += 1 + error = error or "更新未提交" + db.set_applied(task.id, False, error, path=self.db_path) + self.failed.emit(task.id, str(error)) + self.row_updated.emit( + task.id, + {"status": "failed", "last_error": str(error), "committed": 0}, + ) + except Exception as exc: + failed += 1 + error = str(exc) or exc.__class__.__name__ + db.set_applied(task.id, False, error, path=self.db_path) + self.failed.emit(task.id, error) + self.row_updated.emit( + task.id, + {"status": "failed", "last_error": error, "committed": 0}, + ) + finally: + done += 1 + self._emit_progress(done, total, applied, skipped, failed) + + return { + "ok": failed == 0, + "total": total, + "done": done, + "applied": applied, + "skipped": skipped, + "failed": failed, + } + + def _is_actionable_task(self, task): + return ( + getattr(task, "stage", None) == "generated" + and getattr(task, "status", None) in {"success", "pending", "failed"} + and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)) + ) + + def _preflight_block(self, eligible, account_rows, account_by_alias): + if not account_rows: + return { + "reason": "NO_ACCOUNTS", + "no_accounts": True, + } + required_accounts = [] + seen_aliases = set() + for task in eligible: + alias = str(task.alias).strip() + account = account_by_alias.get(alias) + if account is not None and alias not in seen_aliases: + required_accounts.append(account) + seen_aliases.add(alias) + not_running = [] + logged_out = [] + for account in required_accounts: + if not chrome.is_running(account.debug_port): + not_running.append(self._account_payload(account, "CDP 端口未响应")) + continue + status = self._login_status(account) + if not status.get("logged_in"): + logged_out.append( + self._account_payload(account, self._login_skip_reason(status)) + ) + if not_running or logged_out: + return { + "reason": "ACCOUNT_NOT_READY", + "not_running": not_running, + "logged_out": logged_out, + } + return None + + def _account_payload(self, account, reason=None): + payload = { + "account_name": account.account_name, + "alias": account.alias, + "debug_port": account.debug_port, + } + if reason: + payload["reason"] = reason + return payload + + def _emit_progress(self, done, total, applied, skipped, failed): + self.progress.emit( + { + "done": done, + "total": total, + "applied": applied, + "skipped": skipped, + "failed": failed, + } + ) + + def _login_status(self, account): + try: + return accounts.detect_login(account, path=self.db_path, config=self.config) + except Exception as exc: + return { + "logged_in": False, + "reason": f"LOGIN_CHECK_FAILED: {exc}", + } + + def _login_skip_reason(self, status): + reason = status.get("reason") + return f"账号未登录: {reason}" if reason else "账号未登录" + + class CollectWorker(BaseWorker): """Collect old title and cover for imported tasks.""" @@ -2207,6 +2509,7 @@ if QT_IMPORT_ERROR is None: db_path=self.db_path, config=self.config, status_callback=self.statusBar().showMessage, + open_accounts_callback=lambda: self.open_accounts_tab(), ) if title == "④ 账号管理": return AccountsTab( diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 898420a..69c92c6 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -70,7 +70,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-401 | Tab③ 列出已生成任务 + 按批次/店铺/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE | -| T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未登录引导保护;未确认时不调用 apply | TODO | +| T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未登录引导保护;未确认时不调用 apply | DONE | | T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | TODO | ## Phase 5 · 设置与收尾 diff --git a/docs/api.md b/docs/api.md index 733b977..e75c06c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -292,9 +292,10 @@ main() -> int # 创建 QApplication + MainWindow class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config 便于测试 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览 -class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 开始更新确认弹窗(T-401) +class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 确认后串行执行更新 class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/mark_failed + 进度 +class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> editor.apply_task -> db.set_applied/mark_skipped class WriteBackWorker(BaseWorker) # ① 后台回写:excel.write_back(batch_id) 写旧标题/旧封面到原 Excel class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态 @@ -305,7 +306,7 @@ TAB_TITLES: list[str] # 固定 Tab 顺序 TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态 ``` -`MainWindow` 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新shopee筛选列表与开始更新确认、④ 账号管理;⑤ 仍为空壳,业务控件按后续 Tab 任务逐步填充。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 +`MainWindow` 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新shopee筛选列表与确认后串行更新、④ 账号管理;⑤ 仍为空壳,业务控件按后续 Tab 任务逐步填充。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:5 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。 @@ -342,15 +343,20 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。 - 进度标签显示:`标题x/n · 封面x/n · 失败z`;每条封面生成成功后立即 `db.set_generated()`,失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 -③ 更新shopee当前要点(T-401): +③ 更新shopee当前要点(T-401/T-402): - `ApplyTab` 顶部筛选栏包含:批次、店铺、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名。 - ③ 只列出已具备新标题/新封面、可进入更新阶段的任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务。 - 状态筛选支持:已生成(默认,`stage=generated` 且 `status=success/pending`)、失败、已更新、略过、全部状态。 - 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。 - 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库。 -- 点击「开始更新」会弹窗展示批次/店铺/状态/任务数和提交线上风险;用户点否/取消时不执行、不改库;用户点是时当前版本只记录“已确认范围”,实际串行 `apply_task()` 执行留给 T-402。 +- 点击「开始更新」会弹窗展示批次/店铺/状态/任务数和提交线上风险;用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 串行执行更新。 +- `ApplyWorker` 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面、状态为 `success/pending/failed` 的任务;已更新和略过记录仅查看,不会再次提交。 +- 更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态。 +- 预检通过后逐条 `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`,单条失败继续下一条。 +- 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。 - ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。 +- 更新结果回写原 Excel 留给 T-403。 ## workers 模块(`app/workers.py`,已建,PySide6) @@ -384,6 +390,7 @@ run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread - 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。 - 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。 - ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。 +- `ApplyWorker` 串行调用 `editor.apply_task()`,逐条 `set_applied()`,失败继续;账号未就绪时整体阻断并引导④,不进入逐条提交。 - `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。 ## 启动入口 diff --git a/docs/current-state.md b/docs/current-state.md index 0957f0a..617f69b 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,10 +6,10 @@ ## 当前快照 - 日期:2026-06-27 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/开始更新确认弹窗、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期/excel 导入/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表与确认弹窗/gui ④ 账号管理/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期/excel 导入/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、`ApplyWorker` 串行更新与账号预检/gui ④ 账号管理/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -32,7 +32,7 @@ | `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),保留作人工回归与探查参考;见 `prototypes/README.md` | | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | | `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 | -| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表与开始更新确认弹窗;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式 | +| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式 | | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | | `app/editor.py` | 已有 | T-001/T-103/T-205b 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、更新按钮、apply_task | @@ -42,7 +42,7 @@ | `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | -| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | | `app/excel.py` | 已有 | T-201/T-204 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | @@ -60,15 +60,15 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)。 - 正在进行:无。 -- 下一个可领取任务:**T-402(串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库)**。 +- 下一个可领取任务:**T-403(回写结果到原 Excel + 结束弹窗汇总)**。 ## 当前已知限制 - ① 采集依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205 已在采集前拦截未配置账号、Chrome 未启动、未登录,并引导去④账号管理,但不会无提示批量启动所有账号 Chrome。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测。 -- T-401 已完成③的筛选列表和开始更新确认弹窗;真正打开 Shopee 商品页、换标题/封面并点击「更新」提交线上留给 T-402。 +- T-402 已完成③确认后串行更新与逐条写库;更新结果回写原 Excel、结束弹窗成功/失败/略过汇总留给 T-403。 ## 当前可运行内容 diff --git a/docs/routes.md b/docs/routes.md index 0d3ec98..040149b 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -104,8 +104,9 @@ - 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。 - 点击「开始更新」后弹窗展示本次筛选条件、任务数量和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。 - 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。 -- 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error)。 -- 全部完成 → 把新标题/新封面/更新状态批量回写原 Excel;弹窗汇总。 +- 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务。 +- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。 +- 更新结果回写原 Excel 与结束弹窗汇总留给 T-403。 ## ④ 账号管理 @@ -135,7 +136,7 @@ │ ② AI生成:提示词 → 生成新标题/新封面(无逐条审核) │ -③ 更新shopee:对已生成任务点击开始更新 → 弹窗确认 → 换标题+封面 → 点「更新」提交 → 回写结果 +③ 更新shopee:对已生成任务点击开始更新 → 弹窗确认 → 换标题+封面 → 点「更新」提交 → T-403 回写结果 ``` - 未配账号 / Chrome 未启动 / 未登录:① ③ 的执行按钮禁用或执行前提示去 ④。 @@ -149,10 +150,11 @@ | `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 | | `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 | | `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度已接入 `GenerateWorker` | -| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + 开始更新确认已接入;换标题+封面+提交由 T-402 接入,回写由 T-403 接入 | +| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + 开始更新确认 + 确认后串行更新已接入;回写由 T-403 接入 | | `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式 | | `SettingsTab(QWidget)` | ⑤ | AI/目录/Chrome 配置 | | `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 | | `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals | +| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、串行调用 `editor.apply_task()`、逐条 `set_applied()`,失败继续 | > 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。 diff --git a/progress.md b/progress.md index 90e02b1..92453c1 100644 --- a/progress.md +++ b/progress.md @@ -528,3 +528,13 @@ - 测试:`tests/test_gui.py` 覆盖 MainWindow 挂载③、③筛选列表、失败/已更新状态展示、开始更新确认弹窗,以及确认/取消均不调用 `editor.apply_task()`、不改 DB。 - 文档:`docs/06-tasks.md` 将 T-401 标为 DONE;同步 `docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-402。 - 验证:`python -m unittest discover -s tests -p "test_gui.py"` 通过(24 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(79 tests)。 + +## 【2026-06-27】T-402 Tab③ 确认后串行更新 + +- 状态:DONE +- 变更:`app/gui.py` 新增 `ApplyWorker`,③点击「开始更新」并确认后才创建后台 worker;worker 只处理当前筛选结果里 `stage=generated` 且已有新标题或新封面的可执行任务,串行调用 `editor.apply_task()`。 +- 执行策略:更新前先做账号就绪预检;无账号、对应账号 Chrome 未启动或未登录时整体阻断并弹窗引导去④账号管理,不调用 `apply_task()`、不写失败状态。预检通过后逐条 `mark_running(..., "apply")`、执行更新、`set_applied()` 写库;成功推进 `stage=applied/status=success/committed=1`,失败保留原 stage 并写 `status=failed/last_error`,单条失败继续。 +- GUI:③运行中禁用筛选/刷新/开始按钮,启用「停止」;停止使用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。未匹配账号任务逐条 `mark_skipped()`,原因 `别名未匹配账号`。结果回写 Excel 和结束弹窗汇总留给 T-403。 +- 测试:`tests/test_gui.py` 调整 T-401 确认测试为“取消不启动”和“确认启动 ApplyWorker”;新增 `ApplyWorker` 成功/失败/略过串行写库、Chrome 未启动预检阻断、③阻断弹窗引导④的覆盖。 +- 文档:`docs/06-tasks.md` 将 T-402 标为 DONE;同步 `docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-403。 +- 验证:`python -m unittest discover -s tests -p test_gui.py` 通过(28 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(83 tests)。 diff --git a/tests/test_gui.py b/tests/test_gui.py index d14b0a0..2598c7f 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -21,6 +21,7 @@ from app.gui import ( AccountDialog, AccountsTab, ApplyTab, + ApplyWorker, CollectWorker, CollectTab, GenerateWorker, @@ -346,7 +347,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_apply_tab_start_update_requires_confirmation_without_running_apply(self): + 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) accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) @@ -385,13 +386,64 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("已取消开始更新", statuses[-1]) apply_task.assert_not_called() + self.assert_removed(temp_dir) + + def test_apply_tab_start_update_starts_apply_worker_after_confirmation(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"]) + statuses = [] + tab = ApplyTab(config=cfg, status_callback=statuses.append) + self.addCleanup(tab.close) + + class FakeSignal: + def __init__(self): + self.callbacks = [] + + def connect(self, callback): + self.callbacks.append(callback) + + class FakeThread: + def __init__(self): + self.finished = FakeSignal() + self.started = False + + def start(self): + self.started = True + + fake_thread = FakeThread() with mock.patch( "app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes, - ), mock.patch("app.gui.editor.apply_task") as apply_task: + ), mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker, \ + mock.patch("app.gui.editor.apply_task") as apply_task: tab.start_update() - self.assertIn("已确认更新范围:1 条", statuses[-1]) + run_worker.assert_called_once() + self.assertIsInstance(tab.apply_worker, ApplyWorker) + self.assertIs(tab.apply_thread, fake_thread) + self.assertTrue(fake_thread.started) + self.assertFalse(tab.start_update_button.isEnabled()) + self.assertTrue(tab.stop_update_button.isEnabled()) + self.assertEqual("开始更新:1 条", statuses[-1]) apply_task.assert_not_called() unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] self.assertEqual("generated", unchanged.stage) @@ -399,6 +451,168 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_apply_worker_applies_success_failure_and_unmatched_serially(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) + accounts.create_account("副店", "alias-b", debug_port=9223, 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-b", + "item_id": "51100639511", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 4, + "account_name": "Excel未知", + "alias": "missing", + "item_id": "51100639512", + }, + ], + path=cfg["db_path"], + ) + for task in db.list_tasks(batch_id=batch_id, path=cfg["db_path"]): + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"]) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + applied_aliases = [] + progress = [] + rows = [] + + def fake_apply(account, task): + applied_aliases.append(account.alias) + if account.alias == "alias-a": + return {"committed": True, "error": None} + if account.alias == "alias-b": + return {"committed": False, "error": "UPDATE_DISABLED"} + raise AssertionError(account.alias) + + with mock.patch("app.gui.chrome.is_running", return_value=True), \ + mock.patch( + "app.gui.accounts.detect_login", + return_value={"logged_in": True, "reason": None}, + ), mock.patch("app.gui.editor.apply_task", side_effect=fake_apply): + worker = ApplyWorker(tasks, db_path=cfg["db_path"], config=cfg) + worker.progress.connect(progress.append) + worker.row_updated.connect(lambda task_id, fields: rows.append((task_id, fields))) + summary = worker.execute() + + self.assertEqual(["alias-a", "alias-b"], applied_aliases) + self.assertEqual( + {"ok": False, "total": 3, "done": 3, "applied": 1, "skipped": 1, "failed": 1}, + summary, + ) + self.assertEqual( + {"done": 3, "total": 3, "applied": 1, "skipped": 1, "failed": 1}, + progress[-1], + ) + updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + by_alias = {task.alias: task for task in updated} + self.assertEqual("applied", by_alias["alias-a"].stage) + self.assertEqual("success", by_alias["alias-a"].status) + self.assertEqual(1, by_alias["alias-a"].committed) + self.assertEqual("generated", by_alias["alias-b"].stage) + self.assertEqual("failed", by_alias["alias-b"].status) + self.assertEqual("UPDATE_DISABLED", by_alias["alias-b"].last_error) + self.assertEqual(0, by_alias["alias-b"].committed) + self.assertEqual("skipped", by_alias["missing"].status) + self.assertEqual("别名未匹配账号", by_alias["missing"].last_error) + self.assertTrue(any(fields.get("stage") == "applied" for _task_id, fields in rows)) + self.assertTrue(any(fields.get("status") == "failed" for _task_id, fields in rows)) + + self.assert_removed(temp_dir) + + def test_apply_worker_preflight_blocks_when_chrome_not_running(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"]) + tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + + with mock.patch("app.gui.chrome.is_running", return_value=False) as is_running, \ + mock.patch("app.gui.accounts.detect_login") as detect_login, \ + mock.patch("app.gui.editor.apply_task") as apply_task: + summary = ApplyWorker(tasks, db_path=cfg["db_path"], config=cfg).execute() + + self.assertTrue(summary["blocked"]) + self.assertEqual("ACCOUNT_NOT_READY", summary["reason"]) + self.assertEqual("alias-a", summary["not_running"][0]["alias"]) + is_running.assert_called_once_with(9222) + detect_login.assert_not_called() + apply_task.assert_not_called() + unchanged = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + self.assertEqual("generated", unchanged.stage) + self.assertEqual("success", unchanged.status) + self.assertIsNone(unchanged.last_error) + + self.assert_removed(temp_dir) + + def test_apply_tab_blocked_preflight_guides_to_accounts_tab(self): + with self.make_temp_dir() as temp_dir: + opened = [] + statuses = [] + tab = ApplyTab( + config=self.make_config(temp_dir), + status_callback=statuses.append, + open_accounts_callback=lambda: opened.append(True), + ) + self.addCleanup(tab.close) + payload = { + "blocked": True, + "not_running": [ + { + "account_name": "主店", + "alias": "alias-a", + "reason": "CDP 端口未响应", + } + ], + "logged_out": [], + } + + with mock.patch("app.gui.QMessageBox.warning") as warning: + tab._on_apply_finished(payload) + + message = warning.call_args[0][2] + self.assertIn("Chrome 未启动", message) + self.assertIn("④ 账号管理", message) + self.assertEqual([True], opened) + self.assertIn("④ 账号管理", statuses[-1]) + + self.assert_removed(temp_dir) + def test_generate_tab_lists_tasks_and_filters_by_shop_status_and_batch(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)