From 7dbfcb5a24c7bcaa6909d2620957b79e8814eb76 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 20 Jul 2026 11:22:26 +0800 Subject: [PATCH] feat(apply): open product page on left double click --- app/editor.py | 63 +++++++++++++++++++++++++++ app/gui/__init__.py | 1 + app/gui/tabs/apply.py | 87 +++++++++++++++++++++++++++++++++++++- app/gui/workers.py | 77 +++++++++++++++++++++++++++++++++ docs/04-architecture.md | 3 +- docs/api.md | 4 ++ docs/routes.md | 1 + docs/tasks/T-675.md | 7 ++- tests/test_editor_login.py | 83 ++++++++++++++++++++++++++++++++++++ tests/test_gui.py | 83 ++++++++++++++++++++++++++++++++++++ tests/test_workers.py | 41 ++++++++++++++++++ 11 files changed, 445 insertions(+), 5 deletions(-) diff --git a/app/editor.py b/app/editor.py index ec8854e..fd0ffc8 100644 --- a/app/editor.py +++ b/app/editor.py @@ -429,6 +429,27 @@ def _product_url(account, item_id): ) +def _find_exact_product_tab(account, item_id, host): + """只返回该账号已打开且商品路径精确匹配的 target。""" + + expected = urlparse(_product_url(account, item_id)) + expected_host = expected.netloc.lower() + expected_path = expected.path.rstrip("/") + for target in http_get("/json", host=host): + if target.get("type") != "page" or not target.get("webSocketDebuggerUrl"): + continue + try: + current = urlparse(str(target.get("url") or "")) + except Exception: + continue + if ( + current.netloc.lower() == expected_host + and current.path.rstrip("/") == expected_path + ): + return target + return None + + def _item_id(task): value = _get(task, "item_id", "itemid", "product_id") if not value: @@ -815,6 +836,9 @@ def _wait_ready(cdp, timeout=60): return True except Exception: pass + current_url = str(last_state.get("current_url") or _current_url(cdp) or "") + if _is_login_url(current_url): + raise EditorError("账号未登录,请先到④账号管理人工登录蝦皮") invalid_text = _product_unavailable_toast_text(read_page_toasts(cdp)) if invalid_text: raise EditorError(product_unavailable_error_message(invalid_text)) @@ -1105,6 +1129,45 @@ def open_product(account, item_id, on_step=None, bring_to_front=True) -> CDP: raise +def open_or_focus_product_tab(account, item_id): + """聚焦现有精确商品页,或新建商品页且不刷新用户已有页面。 + + 此方法不能复用 ``open_product``:真实更新需要重新导航,人工查看不能 + 刷新用户正在编辑、尚未保存的商品页。 + """ + + host = _cdp_host(account) + item_id = str(item_id) + url = _product_url(account, item_id) + target = _find_exact_product_tab(account, item_id, host) + created_by_app = target is None + if target is None: + target = create_tab_info(url, host=host, background=False) + + cdp = CDP(target["webSocketDebuggerUrl"]) + cdp.target_id = target.get("id") + cdp.created_by_app = created_by_app + cdp.cdp_host = host + try: + _ensure_page_domains(cdp) + if created_by_app: + install_toast_observer(cdp) + cdp.send("Page.bringToFront") + if created_by_app: + _wait_ready(cdp) + return { + "item_id": item_id, + "created": created_by_app, + "target_id": target.get("id"), + } + except Exception: + if created_by_app: + _close_open_product_failure(cdp) + raise + finally: + cdp.close() + + def read_title(cdp) -> str: """Read the current Shopee title input value.""" diff --git a/app/gui/__init__.py b/app/gui/__init__.py index 082918f..be3a67b 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -34,6 +34,7 @@ if QT_IMPORT_ERROR is None: ProductSuiteGenerateWorker, ProductSuiteHistoryExportWorker, ProductSuiteImportImagesWorker, + ProductTabOpenWorker, WriteBackWorker, ) from .tabs.accounts import AccountDialog, AccountsTab diff --git a/app/gui/tabs/apply.py b/app/gui/tabs/apply.py index 479c746..0ae4fa0 100644 --- a/app/gui/tabs/apply.py +++ b/app/gui/tabs/apply.py @@ -2,10 +2,16 @@ from __future__ import annotations +from PySide6.QtCore import QModelIndex, Signal + from ... import product_status from ..models import ApplyTaskTableModel from ..widgets import * -from ..workers import ApplyWorker as _RealApplyWorker, WriteBackWorker as _RealWriteBackWorker +from ..workers import ( + ApplyWorker as _RealApplyWorker, + ProductTabOpenWorker as _RealProductTabOpenWorker, + WriteBackWorker as _RealWriteBackWorker, +) def ApplyWorker(*args, **kwargs): @@ -15,6 +21,27 @@ def ApplyWorker(*args, **kwargs): def WriteBackWorker(*args, **kwargs): return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs) + +def ProductTabOpenWorker(*args, **kwargs): + return _call_package_attr( + "ProductTabOpenWorker", _RealProductTabOpenWorker, *args, **kwargs + ) + + +class _ApplyTaskTableView(QTableView): + """任务表仅向外发出左键双击事件,供人工查看商品页。""" + + leftDoubleClicked = Signal(QModelIndex) + + def mouseDoubleClickEvent(self, event): + super().mouseDoubleClickEvent(event) + if event.button() != Qt.LeftButton: + return + index = self.indexAt(event.position().toPoint()) + if index.isValid(): + self.leftDoubleClicked.emit(index) + + class ApplyTab(QWidget): """Tab 3: list generated tasks and confirm the update scope.""" @@ -53,6 +80,8 @@ class ApplyTab(QWidget): self.apply_thread = None self.result_write_back_worker = None self.result_write_back_thread = None + self.product_tab_open_worker = None + self.product_tab_open_thread = None self.last_apply_summary = None self.batch_filter = QComboBox() @@ -95,7 +124,7 @@ class ApplyTab(QWidget): ) = _build_empty_state_card("applyEmptyStateCard") if self.open_accounts_callback is not None: self.empty_state_button.clicked.connect(self.open_accounts_callback) - self.task_table = QTableView() + self.task_table = _ApplyTaskTableView() self.model = ApplyTaskTableModel(self.task_table) self.task_table.setModel(self.model) self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows) @@ -168,6 +197,7 @@ class ApplyTab(QWidget): self.update_mode_combo.currentIndexChanged.connect(self._on_update_mode_changed) self.stop_update_button.clicked.connect(self.stop_update) self.reset_update_button.clicked.connect(self.reset_apply_status) + self.task_table.leftDoubleClicked.connect(self.open_or_focus_selected_product) self.task_table.customContextMenuRequested.connect(self.show_task_context_menu) self.write_back_button.clicked.connect(self.write_back_results) @@ -444,6 +474,59 @@ class ApplyTab(QWidget): self._set_status(message, level="success") QMessageBox.information(self, "删除本条记录", message) + def open_or_focus_selected_product(self, index): + if self.apply_thread is not None or self.result_write_back_thread is not None: + self._set_status("更新或回写正在进行,暂不能打开商品详情页", level="warning") + return + if self.product_tab_open_thread is not None: + self._set_status("正在打开商品详情页,请稍候", level="info") + return + if not index.isValid(): + self._set_status("请选择要查看的商品记录", level="warning") + return + self.task_table.selectRow(index.row()) + self.task_table.setCurrentIndex(index) + task = self.model.task_at(index.row()) + if task is None: + self._set_status("未找到要查看的商品记录", level="warning") + return + worker = ProductTabOpenWorker( + task.alias, + task.item_id, + db_path=self.db_path, + config=self.config, + ) + worker.finished.connect(self._on_product_tab_open_finished) + worker.failed.connect(self._on_product_tab_open_failed) + thread = run_worker(worker, thread_name="ProductTabOpenWorker", start=False) + thread.finished.connect(lambda: self._forget_product_tab_open_thread(thread)) + self.product_tab_open_worker = worker + self.product_tab_open_thread = thread + self._set_status(f"正在打开商品 {task.item_id} 的详情页...", level="info") + thread.start() + + def _on_product_tab_open_finished(self, payload): + if not payload.get("ok"): + self._set_status( + payload.get("message") or "打开商品详情页失败,请检查账号登录状态。", + level="warning", + ) + return + action = "已打开新页面" if payload.get("created") else "已聚焦现有页面" + account_name = payload.get("account_name") or payload.get("alias") or "账号" + self._set_status( + f"商品 {payload.get('item_id')}:{account_name}{action}", + level="success", + ) + + def _on_product_tab_open_failed(self, task_id, error): + self._set_status("打开商品详情页失败,请检查账号 Chrome 和登录状态。", level="danger") + + def _forget_product_tab_open_thread(self, thread): + if self.product_tab_open_thread is thread: + self.product_tab_open_thread = None + self.product_tab_open_worker = None + 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/workers.py b/app/gui/workers.py index d1176d6..9add4a3 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -15,7 +15,9 @@ except ModuleNotFoundError: # pragma: no cover - GUI import guard from .. import ( ai, appconfig, + chrome, cmhub_models, + editor, image_studio, image_studio_export, image_studio_generation, @@ -1237,6 +1239,81 @@ class GenerateWorker(BaseWorker): except Exception: return +class ProductTabOpenWorker(BaseWorker): + """后台打开单个商品详情页供人工查看,不修改本地任务数据。""" + + def __init__(self, alias, item_id, db_path=None, config=None): + super().__init__() + self.alias = str(alias or "").strip() + self.item_id = str(item_id or "").strip() + self.db_path = db_path + self.config = config + + def execute(self): + if not self.alias: + return self._blocked("ACCOUNT_NOT_FOUND", "任务未关联账号,请先检查导入数据。") + if not self.item_id: + return self._blocked("ITEM_ID_EMPTY", "任务缺少商品ID,无法打开商品详情页。") + try: + account = db.get_account_by_alias(self.alias, path=self.db_path) + except Exception: + return self._blocked( + "ACCOUNT_LOOKUP_FAILED", + "账号信息读取失败,请先到④账号管理检查账号配置。", + ) + if account is None: + return self._blocked( + "ACCOUNT_NOT_FOUND", + f"未找到账号「{self.alias}」,请先到④账号管理配置并登录。", + ) + if not chrome.is_running(account.debug_port): + return self._blocked( + "CHROME_NOT_RUNNING", + f"账号「{account.account_name}」的 Chrome 未启动,请先到④账号管理启动并人工登录蝦皮。", + account=account, + ) + try: + result = editor.open_or_focus_product_tab(account, self.item_id) + except editor.EditorError as exc: + return self._blocked( + "OPEN_PRODUCT_FAILED", + self._editor_error_message(exc, account), + account=account, + ) + except Exception: + return self._blocked( + "CDP_UNAVAILABLE", + f"无法连接账号「{account.account_name}」的 Chrome,请先到④账号管理确认已启动并登录。", + account=account, + ) + return { + "ok": True, + "alias": account.alias, + "account_name": account.account_name, + "item_id": self.item_id, + "created": bool(result.get("created")), + "target_id": result.get("target_id"), + } + + def _blocked(self, reason, message, account=None): + return { + "ok": False, + "reason": reason, + "message": message, + "alias": getattr(account, "alias", self.alias), + "account_name": getattr(account, "account_name", ""), + "item_id": self.item_id, + } + + def _editor_error_message(self, exc, account): + detail = diagnostics.redact_log_text(str(exc) or "") + if "商品失效" in detail: + return detail + if "登录" in detail: + return f"账号「{account.account_name}」未登录,请先到④账号管理人工登录蝦皮。" + return "打开商品详情页失败,请先到④账号管理确认 Chrome 已启动、已人工登录,并检查商品状态。" + + class ApplyWorker(BaseWorker): """Apply generated title/cover changes, optionally previewing or grouping by account.""" diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 6b5cb11..f750bce 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -497,6 +497,7 @@ data/images///__new. # AI 生成的新 - 分批更新停止语义为协作式停止:点击停止后设置取消标记;当前正在执行的商品跑到安全边界后写库结束,不再开始新商品,也不进入下一批。未开始任务保持原状态,后续可继续。 - T-404a 已在③提供「重置更新状态」:仅当前选中单条,保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 以便重复测试上传/提交;若 `committed=1`,必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交,并保留 committed 历史事实/运行日志。 - 若商品页是本轮程序自动新建,`apply_task()` 结束时成功/失败都关闭该商品编辑页;成功提交后关闭前等待 2 秒,便于 Shopee 成功状态渲染。复用用户已有 tab 时只断开 CDP,不关闭页面。`open_product` 内部打开失败的新建 tab 仍由 `open_product` 自行关闭。 +- ③人工查看采用独立 `open_or_focus_product_tab()`,不能复用 `open_product()`:按账号地区域名和 `/portal/product/<商品ID>` 精确查找 target,命中时仅 `Page.bringToFront`,不得 `Page.navigate` 或等待重载;缺失时才新建前台 target、安装 toast 监听并按商品页就绪规则等待。该入口只在后台 Worker 中执行,先确认账号存在且 Chrome/CDP 已运行,不自动启动/登录;完成只关闭 CDP WebSocket,不关闭成功页或复用页,不读写 SQLite、Excel、图片、AI 或线上商品。新建页失败仅关闭该自动 target。 ### 6.4 登录检测 @@ -514,7 +515,7 @@ data/images///__new. # AI 生成的新 | WebSocket Origin | `websocket-client` `suppress_origin=True` | | 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。①采集和商品套图只读商品页关闭本轮自动新建 target 后,最多等待 2 秒确认 target 从 `/json` 消失;超时只记诊断,不覆盖成功结果,复用的用户已有 tab 不关闭。③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 | | 登录 target 竞态 | `/json/close/` 返回成功不代表 target 已立即从 `/json` 消失。登录检测不得固定使用枚举到的第一个 Shopee page;连接或 Cookie API 因 target 销毁失败时应快速重选有效页面。只有 Cookie API 成功返回且确实缺会话 Cookie 才是 `NO_SESSION_COOKIE`;一次都未成功读取是 `LOGIN_CHECK_TARGET_UNAVAILABLE` | -| 前台激活 | ①采集和商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | +| 前台激活 | ①采集和商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;③左键双击人工查看也以前台聚焦为目标,但命中已有精确商品页时仅聚焦、绝不导航或刷新。后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | | SPA 就绪 | 不用 load 事件;轮询“唯一商品名称输入框 + 至少一张主图 itembox + 唯一主图上传输入框”三者都在。脚本返回标题命中数、主图/上传入口状态和当前 URL 的就绪快照;超时错误必须指出具体缺失组件,不能只报泛化超时 | | 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。明确商品失效/不存在/无权限类 toast 即使已经隐藏,也优先成为 `open_product` 失败原因并驱动①阶段列显示“商品失效”;其他普通 toast 只有仍可见且属于当前页面 URL 时,才以“页面提示(可能无关)”附加在就绪快照后。已隐藏的物流、备货、库存、价格等编辑校验提示不得覆盖真正缺失的就绪组件;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” | | 商品状态警示 | 编辑页状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得使用 `data-v-*`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`;已知促销字段编辑限制不构成商品状态;无状态横幅 → `normal`;其他未识别 warning、DOM 异常或非法响应 → `unknown`。多个横幅仍优先取审核中/未上架;只保存归一化文本摘要,不保存整段 HTML | diff --git a/docs/api.md b/docs/api.md index bfd112e..10b5bf0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -266,6 +266,7 @@ is_logged_in(account) -> bool # login_status(...).logged_in;重 install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间 read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断 open_product(account, item_id) -> CDP # 连端口、导航商品页、等业务字段就绪;标记该 tab 是否本轮自动新建;失败时返回缺失组件/有效错误 toast,并清理本轮自动新建的失败 tab +open_or_focus_product_tab(account, item_id) -> dict # ③人工查看:精确匹配已开商品路径时只聚焦、不导航;缺失时新建前台 tab 并等就绪,始终断开 CDP、不改任务 # 采集(只读) read_product_status(cdp) -> dict # {product_status, product_status_note, product_status_error};仅读 EDS warning,不保存 HTML @@ -297,6 +298,7 @@ apply_task(account, task, close_success_tab=False) -> dict - `collect()` 先读取状态再决定是否读取标题/封面。`task.collection_scope=normal_only` 时,未上架、审核中、状态未知返回 `collection_skipped=True` 和中文原因,不下载封面、不覆盖旧内容;`all` 时四类状态都继续采集。两种策略都重新读取页面状态,不能用历史状态预过滤。结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。 - ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。 +- `open_or_focus_product_tab()` 仅供③左键双击人工查看:按当前账号区域域名与 `/portal/product/<商品ID>` 精确匹配既有 page target,命中后只调用 `Page.bringToFront`,不得执行 `Page.navigate`、刷新或等待页面重载;未命中才前台新建并按既有就绪/toast 规则等待。无论成功路径均只断开 CDP WebSocket、保留浏览器 tab;失败时只关闭本轮新建 tab,绝不关闭复用的用户 tab,也不读写任务、Excel、图片或线上商品。 - `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。 - T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。 - T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,在 `images` 业务字段内同一个主图 manager 中先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。 @@ -465,6 +467,7 @@ class ProductSuiteTab(QWidget) # 商品套图:多任务、原图 class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建 class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过 class GenerateWorker(BaseWorker) # ② 后台生成:确认后的 normal_only/all 精确任务 -> ai.generate_batch -> 写库 + 进度 +class ProductTabOpenWorker(BaseWorker) # ③ 手工查看:检查已有账号/Chrome 后调用 editor.open_or_focus_product_tab,不写任务数据、不自动启动或登录 class ApplyWorker(BaseWorker) # ③ 后台更新:再次拒绝非正常状态 -> 账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel class AIModelTestWorker(BaseWorker) # 设置 后台测试 AI 模型连接:appconfig.test_ai_model @@ -560,6 +563,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。 - `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务,并在执行层再次拒绝商品状态不是 `normal` 的记录:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。 - ③任务表右键先选中鼠标命中行,保留「重置更新状态」,并以分隔线提供「删除本条记录」。删除调用 `db.delete_task()`,运行中禁用;已提交线上时确认框明确本地删除不回滚蝦皮。成功后刷新①②③,不修改 Excel、图片、账号 Chrome 或线上商品。 +- ③任务表仅左键双击命中行才创建 `ProductTabOpenWorker`。该 Worker 只检查当前别名账号与其已运行的 Chrome/CDP,不自动启动 Chrome、不自动登录;现有精确商品页只聚焦,缺失时才新建前台页。右键双击不触发,更新或结果回写运行中不启动;成功/失败通过状态栏显示中文结果,不写任务、Excel、图片或运行日志。 - 检查本轮更新:不做账号登录预检,不调用 `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 27974a9..28fa34c 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -152,6 +152,7 @@ - 「开始更新」是③的主操作按钮,视觉上必须强于其他批处理按钮。 - 「重置更新状态」从底部批处理按钮移到任务表右键菜单/高级入口,仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交;不得静默清除 committed 历史事实。 - ③任务表右键保留「重置更新状态」,并以分隔线增加「删除本条记录」。删除只对鼠标命中的一条本地记录生效,复用任务级软删除;更新或结果回写运行中禁用。确认内容须说明商品、店铺、批次及不删除 Excel、图片、账号 Chrome 和蝦皮线上商品;已提交线上记录额外提示本地删除不回滚线上。 +- ③任务表**左键双击**某行可人工查看该商品:在对应账号 Chrome 中,已存在同商品详情页时只切到前台,不刷新、不重新导航;没有才新建一个前台详情页并等待就绪。右键双击不触发此行为。账号不存在、Chrome/CDP 未启动、未登录或页面失败只在状态栏显示中文引导前往④账号管理,不自动启动 Chrome 或登录;查看不会读写任务、Excel、图片、AI 或线上商品。新建页失败只关闭本轮新建 tab,成功页和用户既有页都保留。 - 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。 - 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。 diff --git a/docs/tasks/T-675.md b/docs/tasks/T-675.md index 066c022..7076191 100644 --- a/docs/tasks/T-675.md +++ b/docs/tasks/T-675.md @@ -1,7 +1,7 @@ --- id: T-675 title: 更新蝦皮左键双击打开或聚焦商品详情页 -status: TODO +status: DONE phase: 7 deps: [T-672] created: 2026-07-20 @@ -58,4 +58,7 @@ git diff --check ## 执行记录 -- 待实现。 +- 新增 `editor.open_or_focus_product_tab()`:按账号区域域名与 `/portal/product/<商品ID>` 精确匹配已有 page target;命中时仅聚焦,不导航或刷新;缺失时前台新建并复用既有就绪/toast 失败处理。完成后始终断开 CDP WebSocket,成功页与用户既有页均保留;失败时仅清理本轮新建 target。 +- 新增 `ProductTabOpenWorker` 与③任务表左键双击入口:只检查已存在账号与已运行 Chrome/CDP,不自动启动或登录,不读写任务、Excel、图片、AI、运行日志或线上商品;右键双击不触发,更新或结果回写期间给出中文状态提示。 +- 已更新 `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md` 的人工查看页面约定。 +- 验证通过:`py -3.10 -m unittest discover -s tests -p "test_editor_login.py"`(64 项)、`test_workers.py`(16 项)、`test_gui.py`(209 项)、全量 `py -3.10 -m unittest discover -s tests`(633 项)、`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_editor_login.py b/tests/test_editor_login.py index ae7c75b..cda0045 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -611,6 +611,89 @@ class EditorLoginTests(unittest.TestCase): fake.sent, ) + def test_open_or_focus_product_tab_reuses_exact_target_without_navigation(self): + fake = FakeProductCDP("ws-existing") + existing = { + "id": "target-existing", + "type": "page", + "url": "https://seller.shopee.tw/portal/product/51100639510?draft=1", + "webSocketDebuggerUrl": "ws-existing", + } + with mock.patch("app.editor.http_get", return_value=[existing]), mock.patch( + "app.editor.create_tab_info" + ) as create_tab_info, mock.patch( + "app.editor.CDP", return_value=fake + ), mock.patch("app.editor._ensure_page_domains"): + result = editor.open_or_focus_product_tab( + {"debug_port": 9222}, "51100639510" + ) + + self.assertEqual( + {"item_id": "51100639510", "created": False, "target_id": "target-existing"}, + result, + ) + create_tab_info.assert_not_called() + self.assertIn(("Page.bringToFront", {}), fake.sent) + self.assertNotIn( + ( + "Page.navigate", + { + "url": ( + "https://seller.shopee.tw/portal/product/51100639510" + "?pageEntry=product_list&ignore-html-cache=1" + ) + }, + ), + fake.sent, + ) + self.assertTrue(fake.closed) + + def test_open_or_focus_product_tab_ignores_non_exact_product_target(self): + fake = FakeProductCDP("ws-new") + non_exact = { + "id": "target-other", + "type": "page", + "url": "https://seller.shopee.tw/portal/product/511006395101", + "webSocketDebuggerUrl": "ws-other", + } + with mock.patch("app.editor.http_get", return_value=[non_exact]), mock.patch( + "app.editor.create_tab_info", + return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"}, + ) as create_tab_info, mock.patch( + "app.editor.CDP", return_value=fake + ), mock.patch("app.editor._ensure_page_domains"), mock.patch( + "app.editor._wait_ready", return_value=True + ) as wait_ready: + result = editor.open_or_focus_product_tab( + {"debug_port": 9222}, "51100639510" + ) + + self.assertTrue(result["created"]) + create_tab_info.assert_called_once_with( + "https://seller.shopee.tw/portal/product/51100639510" + "?pageEntry=product_list&ignore-html-cache=1", + host="127.0.0.1:9222", + background=False, + ) + wait_ready.assert_called_once_with(fake) + self.assertTrue(fake.closed) + + def test_open_or_focus_product_tab_cleans_only_new_target_on_failure(self): + fake = FakeProductCDP("ws-new") + with mock.patch("app.editor.http_get", return_value=[]), mock.patch( + "app.editor.create_tab_info", + return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"}, + ), mock.patch("app.editor.CDP", return_value=fake), mock.patch( + "app.editor._ensure_page_domains" + ), mock.patch( + "app.editor._wait_ready", side_effect=editor.EditorError("商品失效:测试") + ), mock.patch("app.editor.close_tab", return_value=True) as close_tab: + with self.assertRaises(editor.EditorError): + editor.open_or_focus_product_tab({"debug_port": 9222}, "bad-item") + + close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") + self.assertTrue(fake.closed) + def test_open_product_marks_auto_created_tab(self): fake = FakeProductCDP("ws-new") with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch( diff --git a/tests/test_gui.py b/tests/test_gui.py index 31661c6..f1e7b05 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -124,6 +124,19 @@ class FakeGenerateWorker: FakeGenerateWorker.instances.append(self) +class FakeProductTabOpenWorker: + instances = [] + + def __init__(self, alias, item_id, db_path=None, config=None): + self.alias = alias + self.item_id = item_id + self.db_path = db_path + self.config = config + self.failed = DummySignal() + self.finished = DummySignal() + FakeProductTabOpenWorker.instances.append(self) + + class FakeThumbnailLoader: def __init__(self): self.submissions = [] @@ -9212,6 +9225,76 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_apply_tab_left_double_click_opens_product_without_right_double_click(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": "主店", + "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) + tab.resize(1000, 600) + tab.show() + QApplication.processEvents() + index = tab.model.index(0, 0) + rect = tab.task_table.visualRect(index) + self.assertTrue(rect.isValid()) + FakeProductTabOpenWorker.instances.clear() + + with mock.patch( + "app.gui.ProductTabOpenWorker", FakeProductTabOpenWorker + ), mock.patch("app.gui.run_worker", return_value=FakeThread()) as run_worker: + QTest.mouseDClick( + tab.task_table.viewport(), Qt.RightButton, pos=rect.center() + ) + QApplication.processEvents() + self.assertEqual([], FakeProductTabOpenWorker.instances) + + tab.task_table.leftDoubleClicked.emit(index) + self.assertEqual(1, len(FakeProductTabOpenWorker.instances)) + worker = FakeProductTabOpenWorker.instances[0] + self.assertEqual("alias-a", worker.alias) + self.assertEqual("51100639510", worker.item_id) + self.assertEqual(cfg["db_path"], worker.db_path) + self.assertEqual("ProductTabOpenWorker", run_worker.call_args.kwargs["thread_name"]) + self.assertTrue(tab.product_tab_open_thread.started) + self.assertIn("正在打开商品 51100639510", statuses[-1]) + + worker.finished.emit( + { + "ok": True, + "item_id": "51100639510", + "account_name": "主店", + "created": False, + } + ) + self.assertIn("主店已聚焦现有页面", statuses[-1]) + self.assertIsNotNone(db.get_task(task.id, path=cfg["db_path"])) + + tab.apply_thread = object() + tab.task_table.leftDoubleClicked.emit(index) + self.assertIn("更新或回写正在进行", statuses[-1]) + self.assertEqual(1, len(FakeProductTabOpenWorker.instances)) + + 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) diff --git a/tests/test_workers.py b/tests/test_workers.py index 1115f37..f9d1961 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -24,6 +24,7 @@ from app.gui.workers import ( ProductSuiteAiWriteWorker, ProductSuiteGenerateWorker, ProductSuiteHistoryExportWorker, + ProductTabOpenWorker, CollectWorker, ) @@ -120,6 +121,46 @@ class WorkerTests(unittest.TestCase): self.assertEqual([(-1, "模拟失败")], failed) self.assertEqual([{"ok": False, "error": "模拟失败"}], finished) + def test_product_tab_open_worker_focuses_product_without_task_write(self): + account = SimpleNamespace( + alias="alias-a", account_name="主店", debug_port=9222 + ) + with mock.patch( + "app.gui.workers.db.get_account_by_alias", return_value=account + ) as get_account, mock.patch( + "app.gui.workers.chrome.is_running", return_value=True + ) as is_running, mock.patch( + "app.gui.workers.editor.open_or_focus_product_tab", + return_value={"created": False, "target_id": "target-existing"}, + ) as open_tab, mock.patch("app.gui.workers.db.set_generated") as set_generated: + result = ProductTabOpenWorker( + "alias-a", "51100639510", db_path="test.db" + ).execute() + + self.assertTrue(result["ok"]) + self.assertFalse(result["created"]) + self.assertEqual("主店", result["account_name"]) + get_account.assert_called_once_with("alias-a", path="test.db") + is_running.assert_called_once_with(9222) + open_tab.assert_called_once_with(account, "51100639510") + set_generated.assert_not_called() + + def test_product_tab_open_worker_reports_missing_chrome_without_starting_it(self): + account = SimpleNamespace( + alias="alias-a", account_name="主店", debug_port=9222 + ) + with mock.patch( + "app.gui.workers.db.get_account_by_alias", return_value=account + ), mock.patch( + "app.gui.workers.chrome.is_running", return_value=False + ), mock.patch("app.gui.workers.editor.open_or_focus_product_tab") as open_tab: + result = ProductTabOpenWorker("alias-a", "51100639510").execute() + + self.assertFalse(result["ok"]) + self.assertEqual("CHROME_NOT_RUNNING", result["reason"]) + self.assertIn("④账号管理", result["message"]) + open_tab.assert_not_called() + def test_collect_worker_scope_skips_non_normal_without_overwriting_old_content(self): with tempfile.TemporaryDirectory() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db")