feat(apply): open product page on left double click
This commit is contained in:
@@ -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):
|
def _item_id(task):
|
||||||
value = _get(task, "item_id", "itemid", "product_id")
|
value = _get(task, "item_id", "itemid", "product_id")
|
||||||
if not value:
|
if not value:
|
||||||
@@ -815,6 +836,9 @@ def _wait_ready(cdp, timeout=60):
|
|||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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))
|
invalid_text = _product_unavailable_toast_text(read_page_toasts(cdp))
|
||||||
if invalid_text:
|
if invalid_text:
|
||||||
raise EditorError(product_unavailable_error_message(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
|
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:
|
def read_title(cdp) -> str:
|
||||||
"""Read the current Shopee title input value."""
|
"""Read the current Shopee title input value."""
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ if QT_IMPORT_ERROR is None:
|
|||||||
ProductSuiteGenerateWorker,
|
ProductSuiteGenerateWorker,
|
||||||
ProductSuiteHistoryExportWorker,
|
ProductSuiteHistoryExportWorker,
|
||||||
ProductSuiteImportImagesWorker,
|
ProductSuiteImportImagesWorker,
|
||||||
|
ProductTabOpenWorker,
|
||||||
WriteBackWorker,
|
WriteBackWorker,
|
||||||
)
|
)
|
||||||
from .tabs.accounts import AccountDialog, AccountsTab
|
from .tabs.accounts import AccountDialog, AccountsTab
|
||||||
|
|||||||
+85
-2
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QModelIndex, Signal
|
||||||
|
|
||||||
from ... import product_status
|
from ... import product_status
|
||||||
from ..models import ApplyTaskTableModel
|
from ..models import ApplyTaskTableModel
|
||||||
from ..widgets import *
|
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):
|
def ApplyWorker(*args, **kwargs):
|
||||||
@@ -15,6 +21,27 @@ def ApplyWorker(*args, **kwargs):
|
|||||||
def WriteBackWorker(*args, **kwargs):
|
def WriteBackWorker(*args, **kwargs):
|
||||||
return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *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):
|
class ApplyTab(QWidget):
|
||||||
"""Tab 3: list generated tasks and confirm the update scope."""
|
"""Tab 3: list generated tasks and confirm the update scope."""
|
||||||
|
|
||||||
@@ -53,6 +80,8 @@ class ApplyTab(QWidget):
|
|||||||
self.apply_thread = None
|
self.apply_thread = None
|
||||||
self.result_write_back_worker = None
|
self.result_write_back_worker = None
|
||||||
self.result_write_back_thread = 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.last_apply_summary = None
|
||||||
|
|
||||||
self.batch_filter = QComboBox()
|
self.batch_filter = QComboBox()
|
||||||
@@ -95,7 +124,7 @@ class ApplyTab(QWidget):
|
|||||||
) = _build_empty_state_card("applyEmptyStateCard")
|
) = _build_empty_state_card("applyEmptyStateCard")
|
||||||
if self.open_accounts_callback is not None:
|
if self.open_accounts_callback is not None:
|
||||||
self.empty_state_button.clicked.connect(self.open_accounts_callback)
|
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.model = ApplyTaskTableModel(self.task_table)
|
||||||
self.task_table.setModel(self.model)
|
self.task_table.setModel(self.model)
|
||||||
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
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.update_mode_combo.currentIndexChanged.connect(self._on_update_mode_changed)
|
||||||
self.stop_update_button.clicked.connect(self.stop_update)
|
self.stop_update_button.clicked.connect(self.stop_update)
|
||||||
self.reset_update_button.clicked.connect(self.reset_apply_status)
|
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.task_table.customContextMenuRequested.connect(self.show_task_context_menu)
|
||||||
self.write_back_button.clicked.connect(self.write_back_results)
|
self.write_back_button.clicked.connect(self.write_back_results)
|
||||||
|
|
||||||
@@ -444,6 +474,59 @@ class ApplyTab(QWidget):
|
|||||||
self._set_status(message, level="success")
|
self._set_status(message, level="success")
|
||||||
QMessageBox.information(self, "删除本条记录", message)
|
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):
|
def reset_apply_status(self, checked=False):
|
||||||
if self.apply_thread is not None or self.result_write_back_thread is not None:
|
if self.apply_thread is not None or self.result_write_back_thread is not None:
|
||||||
self._set_status("更新或回写正在进行,不能重置")
|
self._set_status("更新或回写正在进行,不能重置")
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ except ModuleNotFoundError: # pragma: no cover - GUI import guard
|
|||||||
from .. import (
|
from .. import (
|
||||||
ai,
|
ai,
|
||||||
appconfig,
|
appconfig,
|
||||||
|
chrome,
|
||||||
cmhub_models,
|
cmhub_models,
|
||||||
|
editor,
|
||||||
image_studio,
|
image_studio,
|
||||||
image_studio_export,
|
image_studio_export,
|
||||||
image_studio_generation,
|
image_studio_generation,
|
||||||
@@ -1237,6 +1239,81 @@ class GenerateWorker(BaseWorker):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return
|
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):
|
class ApplyWorker(BaseWorker):
|
||||||
"""Apply generated title/cover changes, optionally previewing or grouping by account."""
|
"""Apply generated title/cover changes, optionally previewing or grouping by account."""
|
||||||
|
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
- 分批更新停止语义为协作式停止:点击停止后设置取消标记;当前正在执行的商品跑到安全边界后写库结束,不再开始新商品,也不进入下一批。未开始任务保持原状态,后续可继续。
|
- 分批更新停止语义为协作式停止:点击停止后设置取消标记;当前正在执行的商品跑到安全边界后写库结束,不再开始新商品,也不进入下一批。未开始任务保持原状态,后续可继续。
|
||||||
- T-404a 已在③提供「重置更新状态」:仅当前选中单条,保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 以便重复测试上传/提交;若 `committed=1`,必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交,并保留 committed 历史事实/运行日志。
|
- 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` 自行关闭。
|
- 若商品页是本轮程序自动新建,`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 登录检测
|
### 6.4 登录检测
|
||||||
|
|
||||||
@@ -514,7 +515,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
| WebSocket Origin | `websocket-client` `suppress_origin=True` |
|
| WebSocket Origin | `websocket-client` `suppress_origin=True` |
|
||||||
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。①采集和商品套图只读商品页关闭本轮自动新建 target 后,最多等待 2 秒确认 target 从 `/json` 消失;超时只记诊断,不覆盖成功结果,复用的用户已有 tab 不关闭。③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 |
|
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。①采集和商品套图只读商品页关闭本轮自动新建 target 后,最多等待 2 秒确认 target 从 `/json` 消失;超时只记诊断,不覆盖成功结果,复用的用户已有 tab 不关闭。③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 |
|
||||||
| 登录 target 竞态 | `/json/close/<target_id>` 返回成功不代表 target 已立即从 `/json` 消失。登录检测不得固定使用枚举到的第一个 Shopee page;连接或 Cookie API 因 target 销毁失败时应快速重选有效页面。只有 Cookie API 成功返回且确实缺会话 Cookie 才是 `NO_SESSION_COOKIE`;一次都未成功读取是 `LOGIN_CHECK_TARGET_UNAVAILABLE` |
|
| 登录 target 竞态 | `/json/close/<target_id>` 返回成功不代表 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 的就绪快照;超时错误必须指出具体缺失组件,不能只报泛化超时 |
|
| 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、未登录、页面超时、风控等其他失败仍显示“失败” |
|
| 商品页错误 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 |
|
| 商品状态警示 | 编辑页状态只读取 `.eds-alert.eds-alert--warning` 内的 `.eds-alert-title/.eds-alert-desc`,不得使用 `data-v-*`。`審核中/审核中` → `reviewing`,`您的商品未上架` → `unlisted`;已知促销字段编辑限制不构成商品状态;无状态横幅 → `normal`;其他未识别 warning、DOM 异常或非法响应 → `unknown`。多个横幅仍优先取审核中/未上架;只保存归一化文本摘要,不保存整段 HTML |
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ is_logged_in(account) -> bool # login_status(...).logged_in;重
|
|||||||
install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间
|
install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间
|
||||||
read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断
|
read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断
|
||||||
open_product(account, item_id) -> CDP # 连端口、导航商品页、等业务字段就绪;标记该 tab 是否本轮自动新建;失败时返回缺失组件/有效错误 toast,并清理本轮自动新建的失败 tab
|
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
|
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 不关闭、不等待。
|
- `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 秒。
|
- ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 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。
|
- `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/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` 属于封面上传错误,必须立即返回明确失败,不继续等超时。
|
- 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 ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
|
||||||
class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过
|
class CollectWorker(BaseWorker) # ① 后台采集:范围 normal_only/all + 账号预检 -> editor.collect -> 状态独立落库,采集或略过
|
||||||
class GenerateWorker(BaseWorker) # ② 后台生成:确认后的 normal_only/all 精确任务 -> ai.generate_batch -> 写库 + 进度
|
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 ApplyWorker(BaseWorker) # ③ 后台更新:再次拒绝非正常状态 -> 账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
|
||||||
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
||||||
class AIModelTestWorker(BaseWorker) # 设置 后台测试 AI 模型连接:appconfig.test_ai_model
|
class AIModelTestWorker(BaseWorker) # 设置 后台测试 AI 模型连接:appconfig.test_ai_model
|
||||||
@@ -560,6 +563,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
|||||||
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
|
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
|
||||||
- `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务,并在执行层再次拒绝商品状态不是 `normal` 的记录:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
|
- `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务,并在执行层再次拒绝商品状态不是 `normal` 的记录:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
|
||||||
- ③任务表右键先选中鼠标命中行,保留「重置更新状态」,并以分隔线提供「删除本条记录」。删除调用 `db.delete_task()`,运行中禁用;已提交线上时确认框明确本地删除不回滚蝦皮。成功后刷新①②③,不修改 Excel、图片、账号 Chrome 或线上商品。
|
- ③任务表右键先选中鼠标命中行,保留「重置更新状态」,并以分隔线提供「删除本条记录」。删除调用 `db.delete_task()`,运行中禁用;已提交线上时确认框明确本地删除不回滚蝦皮。成功后刷新①②③,不修改 Excel、图片、账号 Chrome 或线上商品。
|
||||||
|
- ③任务表仅左键双击命中行才创建 `ProductTabOpenWorker`。该 Worker 只检查当前别名账号与其已运行的 Chrome/CDP,不自动启动 Chrome、不自动登录;现有精确商品页只聚焦,缺失时才新建前台页。右键双击不触发,更新或结果回写运行中不启动;成功/失败通过状态栏显示中文结果,不写任务、Excel、图片或运行日志。
|
||||||
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
|
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
|
||||||
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
|
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 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`,单条失败继续下一条。
|
- 预检通过后默认串行;若 `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`,单条失败继续下一条。
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
- 「开始更新」是③的主操作按钮,视觉上必须强于其他批处理按钮。
|
- 「开始更新」是③的主操作按钮,视觉上必须强于其他批处理按钮。
|
||||||
- 「重置更新状态」从底部批处理按钮移到任务表右键菜单/高级入口,仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交;不得静默清除 committed 历史事实。
|
- 「重置更新状态」从底部批处理按钮移到任务表右键菜单/高级入口,仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交;不得静默清除 committed 历史事实。
|
||||||
- ③任务表右键保留「重置更新状态」,并以分隔线增加「删除本条记录」。删除只对鼠标命中的一条本地记录生效,复用任务级软删除;更新或结果回写运行中禁用。确认内容须说明商品、店铺、批次及不删除 Excel、图片、账号 Chrome 和蝦皮线上商品;已提交线上记录额外提示本地删除不回滚线上。
|
- ③任务表右键保留「重置更新状态」,并以分隔线增加「删除本条记录」。删除只对鼠标命中的一条本地记录生效,复用任务级软删除;更新或结果回写运行中禁用。确认内容须说明商品、店铺、批次及不删除 Excel、图片、账号 Chrome 和蝦皮线上商品;已提交线上记录额外提示本地删除不回滚线上。
|
||||||
|
- ③任务表**左键双击**某行可人工查看该商品:在对应账号 Chrome 中,已存在同商品详情页时只切到前台,不刷新、不重新导航;没有才新建一个前台详情页并等待就绪。右键双击不触发此行为。账号不存在、Chrome/CDP 未启动、未登录或页面失败只在状态栏显示中文引导前往④账号管理,不自动启动 Chrome 或登录;查看不会读写任务、Excel、图片、AI 或线上商品。新建页失败只关闭本轮新建 tab,成功页和用户既有页都保留。
|
||||||
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
|
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
|
||||||
- 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。
|
- 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: T-675
|
id: T-675
|
||||||
title: 更新蝦皮左键双击打开或聚焦商品详情页
|
title: 更新蝦皮左键双击打开或聚焦商品详情页
|
||||||
status: TODO
|
status: DONE
|
||||||
phase: 7
|
phase: 7
|
||||||
deps: [T-672]
|
deps: [T-672]
|
||||||
created: 2026-07-20
|
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`。
|
||||||
|
|||||||
@@ -611,6 +611,89 @@ class EditorLoginTests(unittest.TestCase):
|
|||||||
fake.sent,
|
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):
|
def test_open_product_marks_auto_created_tab(self):
|
||||||
fake = FakeProductCDP("ws-new")
|
fake = FakeProductCDP("ws-new")
|
||||||
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
|
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
|
||||||
|
|||||||
@@ -124,6 +124,19 @@ class FakeGenerateWorker:
|
|||||||
FakeGenerateWorker.instances.append(self)
|
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:
|
class FakeThumbnailLoader:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.submissions = []
|
self.submissions = []
|
||||||
@@ -9212,6 +9225,76 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
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):
|
def test_collect_tab_can_filter_unmatched_tasks_from_summary_bar(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
cfg = self.make_config(temp_dir)
|
cfg = self.make_config(temp_dir)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from app.gui.workers import (
|
|||||||
ProductSuiteAiWriteWorker,
|
ProductSuiteAiWriteWorker,
|
||||||
ProductSuiteGenerateWorker,
|
ProductSuiteGenerateWorker,
|
||||||
ProductSuiteHistoryExportWorker,
|
ProductSuiteHistoryExportWorker,
|
||||||
|
ProductTabOpenWorker,
|
||||||
CollectWorker,
|
CollectWorker,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,6 +121,46 @@ class WorkerTests(unittest.TestCase):
|
|||||||
self.assertEqual([(-1, "模拟失败")], failed)
|
self.assertEqual([(-1, "模拟失败")], failed)
|
||||||
self.assertEqual([{"ok": False, "error": "模拟失败"}], finished)
|
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):
|
def test_collect_worker_scope_skips_non_normal_without_overwriting_old_content(self):
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
|||||||
Reference in New Issue
Block a user