feat: 完成T-402确认后串行更新

③更新shopee在开始更新确认后创建ApplyWorker,按当前筛选结果串行调用editor.apply_task并逐条set_applied落库。

新增更新前账号就绪预检:无账号、Chrome未启动或未登录时整体阻断并引导到④账号管理,未确认或预检失败均不调用apply。

补充GUI开始/停止/进度刷新以及成功、失败、未匹配账号继续处理的单元测试,同步任务看板、API、流程和当前状态文档。
This commit is contained in:
chengma
2026-06-27 17:13:18 +08:00
parent 5a63af4620
commit 04ff06a2e3
7 changed files with 560 additions and 24 deletions
+307 -4
View File
@@ -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(