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