feat: 完成T-402确认后串行更新
③更新shopee在开始更新确认后创建ApplyWorker,按当前筛选结果串行调用editor.apply_task并逐条set_applied落库。 新增更新前账号就绪预检:无账号、Chrome未启动或未登录时整体阻断并引导到④账号管理,未确认或预检失败均不调用apply。 补充GUI开始/停止/进度刷新以及成功、失败、未匹配账号继续处理的单元测试,同步任务看板、API、流程和当前状态文档。
This commit is contained in:
+307
-4
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user