feat: 完成T-403结果回写与汇总
新增excel.write_back_results,按原Excel文件、工作表和行号回写新标题、新封面图片路径与更新状态。 ③更新完成后自动用WriteBackWorker(mode=results)回写结果,并弹窗汇总成功、失败、略过数量;文件被占用时提示关闭Excel后手动重试。 补充Excel结果回写、GUI自动回写/汇总/锁文件提示和模块契约测试,同步任务看板、API、流程和当前状态文档。
This commit is contained in:
+59
-1
@@ -32,7 +32,10 @@ OUTPUT_HEADERS = {
|
||||
"update_status": "更新状态",
|
||||
}
|
||||
OLD_WRITE_BACK_FIELDS = ("old_title", "old_cover_path")
|
||||
RESULT_WRITE_BACK_FIELDS = ("new_title", "new_cover_path", "update_status")
|
||||
WRITEABLE_STAGES = {"collected", "generated", "applied"}
|
||||
RESULT_WRITEABLE_STAGES = {"generated", "applied"}
|
||||
RESULT_WRITEABLE_STATUSES = {"failed", "skipped", "cancelled"}
|
||||
LOCK_WINERRORS = {5, 32, 33}
|
||||
LOCK_ERRNOS = {13, 16}
|
||||
|
||||
@@ -310,7 +313,21 @@ def _filter_tasks_by_excel_path(tasks, excel_path):
|
||||
return filtered
|
||||
|
||||
|
||||
def _is_result_fields(fields) -> bool:
|
||||
return any(field in RESULT_WRITE_BACK_FIELDS for field in fields)
|
||||
|
||||
|
||||
def _has_write_back_data(task, fields) -> bool:
|
||||
if _is_result_fields(fields):
|
||||
if getattr(task, "stage", None) in RESULT_WRITEABLE_STAGES:
|
||||
return True
|
||||
if getattr(task, "status", None) in RESULT_WRITEABLE_STATUSES:
|
||||
return True
|
||||
return any(
|
||||
getattr(task, field, None) not in (None, "")
|
||||
for field in ("new_title", "new_cover_path")
|
||||
if field in fields
|
||||
)
|
||||
if getattr(task, "stage", None) in WRITEABLE_STAGES:
|
||||
return True
|
||||
return any(getattr(task, field, None) not in (None, "") for field in fields)
|
||||
@@ -367,6 +384,32 @@ def _ensure_output_columns(sheet, fields):
|
||||
return columns
|
||||
|
||||
|
||||
def _update_status(task) -> str:
|
||||
error = getattr(task, "last_error", None)
|
||||
status = getattr(task, "status", None)
|
||||
stage = getattr(task, "stage", None)
|
||||
committed = bool(getattr(task, "committed", 0))
|
||||
if stage == "applied" and committed:
|
||||
return "成功"
|
||||
if status == "failed":
|
||||
return f"失败:{error}" if error else "失败"
|
||||
if status == "skipped":
|
||||
return f"略过:{error}" if error else "略过"
|
||||
if status == "running":
|
||||
return "处理中"
|
||||
if status == "cancelled":
|
||||
return f"已取消:{error}" if error else "已取消"
|
||||
if stage == "generated":
|
||||
return "待更新"
|
||||
return ""
|
||||
|
||||
|
||||
def _field_value(task, field):
|
||||
if field == "update_status":
|
||||
return _update_status(task)
|
||||
return getattr(task, field, None) or ""
|
||||
|
||||
|
||||
def _write_tasks_to_workbook(workbook, tasks, fields) -> int:
|
||||
rows = 0
|
||||
missing_sheets = []
|
||||
@@ -387,7 +430,7 @@ def _write_tasks_to_workbook(workbook, tasks, fields) -> int:
|
||||
sheet.cell(
|
||||
row=int(task.source_row),
|
||||
column=columns[field],
|
||||
).value = getattr(task, field, None) or ""
|
||||
).value = _field_value(task, field)
|
||||
rows += 1
|
||||
|
||||
if missing_sheets:
|
||||
@@ -450,6 +493,21 @@ def write_back(batch_id, excel_path=None, path=None) -> dict:
|
||||
)
|
||||
|
||||
|
||||
def write_back_results(batch_id, excel_path=None, path=None) -> dict:
|
||||
"""Write generated title/cover and update status back to the original Excel file."""
|
||||
|
||||
_require_openpyxl()
|
||||
fields = RESULT_WRITE_BACK_FIELDS
|
||||
tasks = _filter_tasks_by_excel_path(_batch_tasks(batch_id, path=path), excel_path)
|
||||
groups = _group_by_source_file(tasks, fields)
|
||||
return _write_result(
|
||||
batch_id,
|
||||
groups,
|
||||
lambda source_path: source_path,
|
||||
fields,
|
||||
)
|
||||
|
||||
|
||||
def _is_excel_output_path(path_value) -> bool:
|
||||
return _excel_suffix(path_value) in {".xlsx", ".xlsm"}
|
||||
|
||||
|
||||
+206
-9
@@ -966,6 +966,9 @@ if QT_IMPORT_ERROR is None:
|
||||
self.open_accounts_callback = open_accounts_callback
|
||||
self.apply_worker = None
|
||||
self.apply_thread = None
|
||||
self.result_write_back_worker = None
|
||||
self.result_write_back_thread = None
|
||||
self.last_apply_summary = None
|
||||
|
||||
self.batch_filter = QComboBox()
|
||||
self.batch_filter.setObjectName("applyBatchFilter")
|
||||
@@ -1023,6 +1026,7 @@ if QT_IMPORT_ERROR is None:
|
||||
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.write_back_button.clicked.connect(self.write_back_results)
|
||||
|
||||
self.refresh_tasks()
|
||||
|
||||
@@ -1060,6 +1064,7 @@ if QT_IMPORT_ERROR is None:
|
||||
self.summary_label.setText(
|
||||
f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条"
|
||||
)
|
||||
self._update_write_back_button()
|
||||
|
||||
def start_update(self, checked=False):
|
||||
if self.apply_thread is not None:
|
||||
@@ -1102,6 +1107,13 @@ if QT_IMPORT_ERROR is None:
|
||||
self.apply_worker.cancel()
|
||||
self._set_status("正在停止更新...")
|
||||
|
||||
def write_back_results(self, checked=False):
|
||||
batch_ids = self._active_batch_ids()
|
||||
if not batch_ids:
|
||||
self._set_status("没有可回写结果的批次")
|
||||
return
|
||||
self._start_result_write_back(batch_ids, auto=False)
|
||||
|
||||
def _is_actionable_task(self, task):
|
||||
return (
|
||||
getattr(task, "stage", None) == "generated"
|
||||
@@ -1199,13 +1211,27 @@ if QT_IMPORT_ERROR is None:
|
||||
self.batch_filter.setEnabled(not running)
|
||||
self.shop_filter.setEnabled(not running)
|
||||
self.status_filter.setEnabled(not running)
|
||||
self.write_back_button.setEnabled(False)
|
||||
self._update_write_back_button()
|
||||
|
||||
def _set_result_write_back_running(self, running):
|
||||
self.start_update_button.setEnabled(not 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 if running else bool(self._active_batch_ids()))
|
||||
|
||||
def _forget_apply_thread(self, thread):
|
||||
if self.apply_thread is thread:
|
||||
self.apply_thread = None
|
||||
self.apply_worker = None
|
||||
|
||||
def _forget_result_write_back_thread(self, thread):
|
||||
if self.result_write_back_thread is thread:
|
||||
self.result_write_back_thread = None
|
||||
self.result_write_back_worker = None
|
||||
self._update_write_back_button()
|
||||
|
||||
def _on_apply_progress(self, payload):
|
||||
self._set_status("更新进度:" + self._apply_progress_text(payload))
|
||||
|
||||
@@ -1221,7 +1247,19 @@ if QT_IMPORT_ERROR is None:
|
||||
if payload.get("blocked"):
|
||||
self._show_apply_blocked(payload)
|
||||
return
|
||||
self._set_status("更新完成:" + self._apply_progress_text(payload))
|
||||
self.last_apply_summary = dict(payload)
|
||||
message = "更新完成:" + self._apply_progress_text(payload)
|
||||
batch_ids = payload.get("batch_ids") or self._active_batch_ids()
|
||||
if payload.get("done", 0) > 0 and batch_ids:
|
||||
if self._start_result_write_back(
|
||||
batch_ids,
|
||||
auto=True,
|
||||
apply_summary=payload,
|
||||
):
|
||||
self._set_status(f"{message},正在自动回写结果到 Excel...")
|
||||
return
|
||||
self._set_status(message)
|
||||
self._show_apply_summary(payload)
|
||||
|
||||
def _on_apply_cancelled(self, payload):
|
||||
self._set_apply_running(False)
|
||||
@@ -1277,6 +1315,121 @@ if QT_IMPORT_ERROR is None:
|
||||
label = f"{name}({alias})" if alias and name != alias else (name or alias)
|
||||
return f"{label}: {reason}" if reason else label
|
||||
|
||||
def _active_batch_ids(self):
|
||||
selected_batch = self.batch_filter.currentData()
|
||||
if selected_batch:
|
||||
return [selected_batch]
|
||||
batch_ids = []
|
||||
for task in self.model.tasks:
|
||||
batch_id = getattr(task, "batch_id", None)
|
||||
if batch_id and batch_id not in batch_ids:
|
||||
batch_ids.append(batch_id)
|
||||
return batch_ids
|
||||
|
||||
def _update_write_back_button(self):
|
||||
if getattr(self, "write_back_button", None) is None:
|
||||
return
|
||||
enabled = (
|
||||
self.apply_thread is None
|
||||
and self.result_write_back_thread is None
|
||||
and bool(self._active_batch_ids())
|
||||
)
|
||||
self.write_back_button.setEnabled(enabled)
|
||||
|
||||
def _start_result_write_back(self, batch_ids, auto=False, apply_summary=None):
|
||||
if self.result_write_back_thread is not None:
|
||||
self._set_status("Excel 结果回写正在进行...")
|
||||
return False
|
||||
worker = WriteBackWorker(batch_ids, db_path=self.db_path, mode="results")
|
||||
worker.failed.connect(
|
||||
lambda task_id, error, auto=auto, apply_summary=apply_summary:
|
||||
self._on_result_write_back_failed(
|
||||
task_id,
|
||||
error,
|
||||
auto=auto,
|
||||
apply_summary=apply_summary,
|
||||
)
|
||||
)
|
||||
worker.finished.connect(
|
||||
lambda payload, auto=auto, apply_summary=apply_summary:
|
||||
self._on_result_write_back_finished(
|
||||
payload,
|
||||
auto=auto,
|
||||
apply_summary=apply_summary,
|
||||
)
|
||||
)
|
||||
thread = run_worker(worker, thread_name="ResultWriteBackWorker", start=False)
|
||||
thread.finished.connect(lambda: self._forget_result_write_back_thread(thread))
|
||||
self.result_write_back_worker = worker
|
||||
self.result_write_back_thread = thread
|
||||
self._set_result_write_back_running(True)
|
||||
self._set_status("正在自动回写更新结果到 Excel..." if auto else "正在回写更新结果到 Excel...")
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def _on_result_write_back_failed(self, task_id, error, auto=False, apply_summary=None):
|
||||
message = f"Excel {'自动' if auto else ''}回写更新结果失败:{error}"
|
||||
if "被占用" in str(error):
|
||||
message += "\n请关闭原 Excel 后点击「回写结果到 Excel」手动重试;SQLite 已保留更新结果。"
|
||||
if auto and apply_summary:
|
||||
message = self._apply_summary_message(apply_summary, error=message)
|
||||
QMessageBox.warning(self, "回写结果到 Excel", message)
|
||||
self._set_status(message.replace("\n", " "))
|
||||
|
||||
def _on_result_write_back_finished(self, payload, auto=False, apply_summary=None):
|
||||
self._set_result_write_back_running(False)
|
||||
self.refresh_tasks()
|
||||
if payload.get("ok") is False:
|
||||
error = payload.get("error") or "未知错误"
|
||||
retry_hint = ",可点击「回写结果到 Excel」手动重试" if auto else ""
|
||||
self._set_status(f"Excel {'自动' if auto else ''}回写更新结果失败:{error}{retry_hint}")
|
||||
return
|
||||
self._set_status(
|
||||
"Excel {prefix}回写更新结果完成:文件{files},行{rows}".format(
|
||||
prefix="自动" if auto else "",
|
||||
files=payload.get("files", 0),
|
||||
rows=payload.get("rows", 0),
|
||||
)
|
||||
)
|
||||
if auto and apply_summary:
|
||||
self._show_apply_summary(apply_summary, write_back_payload=payload)
|
||||
elif not auto:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"回写结果到 Excel",
|
||||
"结果回写完成:文件{files},行{rows}".format(
|
||||
files=payload.get("files", 0),
|
||||
rows=payload.get("rows", 0),
|
||||
),
|
||||
)
|
||||
|
||||
def _show_apply_summary(self, apply_summary, write_back_payload=None):
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"更新完成",
|
||||
self._apply_summary_message(apply_summary, write_back_payload),
|
||||
)
|
||||
|
||||
def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None):
|
||||
lines = [
|
||||
"更新完成。",
|
||||
"成功:{applied},失败:{failed},略过:{skipped}".format(
|
||||
applied=apply_summary.get("applied", 0),
|
||||
failed=apply_summary.get("failed", 0),
|
||||
skipped=apply_summary.get("skipped", 0),
|
||||
),
|
||||
]
|
||||
if write_back_payload:
|
||||
lines.append(
|
||||
"Excel 回写:文件{files},行{rows}".format(
|
||||
files=write_back_payload.get("files", 0),
|
||||
rows=write_back_payload.get("rows", 0),
|
||||
)
|
||||
)
|
||||
if error:
|
||||
lines.append(str(error))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class CollectTab(QWidget):
|
||||
"""Tab 1: import Excel files and list imported tasks."""
|
||||
@@ -1827,6 +1980,7 @@ if QT_IMPORT_ERROR is None:
|
||||
if str(account.alias).strip()
|
||||
}
|
||||
eligible = [task for task in self.tasks if self._is_actionable_task(task)]
|
||||
batch_ids = self._batch_ids(eligible)
|
||||
total = len(eligible)
|
||||
applied = 0
|
||||
skipped = 0
|
||||
@@ -1845,6 +1999,7 @@ if QT_IMPORT_ERROR is None:
|
||||
"applied": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"batch_ids": batch_ids,
|
||||
}
|
||||
)
|
||||
return blocked
|
||||
@@ -1909,6 +2064,7 @@ if QT_IMPORT_ERROR is None:
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"batch_ids": batch_ids,
|
||||
}
|
||||
|
||||
def _is_actionable_task(self, task):
|
||||
@@ -1985,6 +2141,14 @@ if QT_IMPORT_ERROR is None:
|
||||
reason = status.get("reason")
|
||||
return f"账号未登录: {reason}" if reason else "账号未登录"
|
||||
|
||||
def _batch_ids(self, tasks):
|
||||
batch_ids = []
|
||||
for task in tasks:
|
||||
batch_id = getattr(task, "batch_id", None)
|
||||
if batch_id and batch_id not in batch_ids:
|
||||
batch_ids.append(batch_id)
|
||||
return batch_ids
|
||||
|
||||
|
||||
class CollectWorker(BaseWorker):
|
||||
"""Collect old title and cover for imported tasks."""
|
||||
@@ -2176,20 +2340,21 @@ if QT_IMPORT_ERROR is None:
|
||||
|
||||
|
||||
class WriteBackWorker(BaseWorker):
|
||||
"""Write collected old fields back to Excel in a background thread."""
|
||||
"""Write Excel fields back in a background thread."""
|
||||
|
||||
def __init__(self, batch_id, db_path=None, excel_path=None):
|
||||
def __init__(self, batch_id, db_path=None, excel_path=None, mode="old"):
|
||||
super().__init__()
|
||||
self.batch_id = batch_id
|
||||
self.db_path = db_path
|
||||
self.excel_path = excel_path
|
||||
self.mode = mode
|
||||
|
||||
def execute(self):
|
||||
result = excel.write_back(
|
||||
self.batch_id,
|
||||
excel_path=self.excel_path,
|
||||
path=self.db_path,
|
||||
)
|
||||
results = [
|
||||
self._write_one(batch_id)
|
||||
for batch_id in self._batch_ids()
|
||||
]
|
||||
result = results[0] if len(results) == 1 else self._combined_result(results)
|
||||
self.progress.emit(
|
||||
{
|
||||
"done": result.get("rows", 0),
|
||||
@@ -2199,6 +2364,38 @@ if QT_IMPORT_ERROR is None:
|
||||
)
|
||||
return result
|
||||
|
||||
def _batch_ids(self):
|
||||
if isinstance(self.batch_id, (list, tuple, set)):
|
||||
return list(self.batch_id)
|
||||
return [self.batch_id]
|
||||
|
||||
def _write_one(self, batch_id):
|
||||
if self.mode == "results":
|
||||
return excel.write_back_results(
|
||||
batch_id,
|
||||
excel_path=self.excel_path,
|
||||
path=self.db_path,
|
||||
)
|
||||
return excel.write_back(
|
||||
batch_id,
|
||||
excel_path=self.excel_path,
|
||||
path=self.db_path,
|
||||
)
|
||||
|
||||
def _combined_result(self, results):
|
||||
written_files = []
|
||||
for result in results:
|
||||
for file_path in result.get("written_files", []):
|
||||
if file_path not in written_files:
|
||||
written_files.append(file_path)
|
||||
return {
|
||||
"ok": all(result.get("ok", False) for result in results),
|
||||
"batch_id": [result.get("batch_id") for result in results],
|
||||
"files": sum(result.get("files", 0) for result in results),
|
||||
"rows": sum(result.get("rows", 0) for result in results),
|
||||
"written_files": written_files,
|
||||
}
|
||||
|
||||
|
||||
class AccountLoginCheckWorker(BaseWorker):
|
||||
def __init__(self, account, db_path=None, config=None, timeout=8):
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T-401 | Tab③ 列出已生成任务 + 按批次/店铺/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE |
|
||||
| T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未登录引导保护;未确认时不调用 apply | DONE |
|
||||
| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | TODO |
|
||||
| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE |
|
||||
|
||||
## Phase 5 · 设置与收尾
|
||||
|
||||
|
||||
+11
-5
@@ -88,7 +88,7 @@ SQLite 连接规则:
|
||||
- `connect()` 必须设置 `PRAGMA foreign_keys=ON`、`journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL`。
|
||||
- DB 写入短事务、单条提交;Excel 回写失败不回滚 DB。
|
||||
|
||||
## excel 模块(`app/excel.py`,导入与旧字段回写已建;结果回写待 T-403,依赖 openpyxl)
|
||||
## excel 模块(`app/excel.py`,导入与回写已建,依赖 openpyxl)
|
||||
|
||||
```python
|
||||
class ExcelError(RuntimeError): ...
|
||||
@@ -121,9 +121,13 @@ write_back(batch_id, excel_path=None, path=None) -> dict
|
||||
# 只写 stage 已到 collected/generated/applied 或已有旧字段值的任务;无可写任务时返回 rows=0
|
||||
# 原文件被占用(锁) → 抛 ExcelError,调用方提示“请关闭后重试”,或改用 export_copy
|
||||
# -> {"ok": True, "batch_id": str, "files": int, "rows": int, "written_files": [abs_path, ...]}
|
||||
write_back_results(batch_id, excel_path=None, path=None) -> dict
|
||||
# 把【新标题/新封面图片路径/更新状态】批量回写到【原 Excel】
|
||||
# 更新状态:成功 / 失败:原因 / 略过:原因 / 待更新
|
||||
# 原文件被占用(锁) → 抛 ExcelError;SQLite 更新结果不回滚,GUI 提示关闭后重试
|
||||
# -> {"ok": True, "batch_id": str, "files": int, "rows": int, "written_files": [abs_path, ...]}
|
||||
export_copy(batch_id, out_dir_or_path, path=None) -> dict
|
||||
# 退路:另存带旧字段的副本,不动原文件;目录输出时生成 *_cmshopee回写.xlsx
|
||||
# T-403 会在此基础上扩展新标题/新封面/更新状态回写。
|
||||
```
|
||||
|
||||
列模板见 [架构 5.3](04-architecture.md);别名以“别名”列为权威,必须与 ④ 账号管理中的账号别名一致。`shopee待处理任务模板.xlsx` 是可提交的标准空模板;运营填写后的 Excel 副本属于业务数据,不提交。
|
||||
@@ -296,7 +300,7 @@ 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 WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
|
||||
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
|
||||
class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态
|
||||
class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果
|
||||
@@ -343,7 +347,7 @@ 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/T-402):
|
||||
③ 更新shopee当前要点(T-401/T-402/T-403):
|
||||
|
||||
- `ApplyTab` 顶部筛选栏包含:批次、店铺、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前更新候选任务别名并优先显示匹配账号名。
|
||||
- ③ 只列出已具备新标题/新封面、可进入更新阶段的任务:`stage=generated/applied`,或已有新字段且 `status=failed/skipped` 的任务。
|
||||
@@ -356,7 +360,8 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式:
|
||||
- 预检通过后逐条 `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。
|
||||
- 更新完成后自动调用 `WriteBackWorker(mode="results")` → `excel.write_back_results()`,把新标题、新封面路径、更新状态写回原 Excel;原文件被占用时提示关闭后点击「回写结果到 Excel」手动重试,SQLite 更新结果不回滚。
|
||||
- 自动结果回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数;若没有可回写批次,也会弹出更新汇总。
|
||||
|
||||
## workers 模块(`app/workers.py`,已建,PySide6)
|
||||
|
||||
@@ -391,6 +396,7 @@ run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread
|
||||
- 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。
|
||||
- ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。
|
||||
- `ApplyWorker` 串行调用 `editor.apply_task()`,逐条 `set_applied()`,失败继续;账号未就绪时整体阻断并引导④,不进入逐条提交。
|
||||
- `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。
|
||||
- `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③ 更新列表筛选与开始更新确认、T-402 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③ 确认后串行更新、T-403 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筛选栏/任务列表/开始更新确认弹窗/确认后 `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 改动仍需测试商品手动验证。
|
||||
- 生产代码:已建立 `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、更新结果回写原 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`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏;`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/T-402 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式 |
|
||||
| `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/T-403 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式 |
|
||||
| `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,8 +42,8 @@
|
||||
| `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/T-402 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers |
|
||||
| `app/excel.py` | 已有 | T-201/T-204 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;支持原文件被占用时另存副本 |
|
||||
| `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/T-403 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers |
|
||||
| `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 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-402(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-403(Tab③ 结果回写与结束汇总)。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:**T-403(回写结果到原 Excel + 结束弹窗汇总)**。
|
||||
- 下一个可领取任务:**T-501(Tab⑤ 设置 · AI 模型管理 UI)**。
|
||||
|
||||
## 当前已知限制
|
||||
|
||||
- ① 采集依赖对应账号 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-402 已完成③确认后串行更新与逐条写库;更新结果回写原 Excel、结束弹窗成功/失败/略过汇总留给 T-403。
|
||||
- T-403 已完成③更新结果回写与结束汇总;真实 Shopee 更新仍需在测试商品上做谨慎端到端冒烟。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+5
-3
@@ -106,7 +106,8 @@
|
||||
- 对确认后的**已生成(generated)任务**执行:打开编辑页换标题+换封面 → 点「更新」提交。
|
||||
- 串行、单条失败继续;每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务。
|
||||
- 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动或未登录时整体阻断并引导去④账号管理,不进入逐条提交。
|
||||
- 更新结果回写原 Excel 与结束弹窗汇总留给 T-403。
|
||||
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
|
||||
- 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。
|
||||
|
||||
## ④ 账号管理
|
||||
|
||||
@@ -136,7 +137,7 @@
|
||||
│
|
||||
② AI生成:提示词 → 生成新标题/新封面(无逐条审核)
|
||||
│
|
||||
③ 更新shopee:对已生成任务点击开始更新 → 弹窗确认 → 换标题+封面 → 点「更新」提交 → T-403 回写结果
|
||||
③ 更新shopee:对已生成任务点击开始更新 → 弹窗确认 → 换标题+封面 → 点「更新」提交 → 回写结果 → 弹窗汇总
|
||||
```
|
||||
|
||||
- 未配账号 / Chrome 未启动 / 未登录:① ③ 的执行按钮禁用或执行前提示去 ④。
|
||||
@@ -150,11 +151,12 @@
|
||||
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
|
||||
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
|
||||
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度已接入 `GenerateWorker` |
|
||||
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + 开始更新确认 + 确认后串行更新已接入;回写由 T-403 接入 |
|
||||
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + 开始更新确认 + 确认后串行更新 + 结果回写与结束汇总 |
|
||||
| `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()`,失败继续 |
|
||||
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
|
||||
|
||||
> 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。
|
||||
|
||||
+10
@@ -538,3 +538,13 @@
|
||||
- 测试:`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)。
|
||||
|
||||
## 【2026-06-27】T-403 Tab③ 结果回写与结束汇总
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:`app/excel.py` 新增 `write_back_results()`,按 `source_file_abs/source_sheet/source_row` 把新标题、新封面图片路径、更新状态写回原 Excel;更新状态输出为“成功 / 失败:原因 / 略过:原因 / 待更新”。`write_back()` 仍保持旧标题/旧封面回写给①使用。
|
||||
- GUI:`ApplyTab` 在 `ApplyWorker` 完成后自动启动 `WriteBackWorker(mode="results")` 回写结果;成功后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数;文件被占用时提示关闭原 Excel 后点击「回写结果到 Excel」手动重试,SQLite 更新结果不回滚。③的「回写结果到 Excel」按钮作为手动重试入口。
|
||||
- Worker:`WriteBackWorker` 增加 `mode`,默认 `old` 兼容①旧字段回写,`results` 调用 `excel.write_back_results()`;支持单批次或批次列表。
|
||||
- 测试:`tests/test_excel.py` 覆盖结果字段与三种更新状态写回;`tests/test_gui.py` 覆盖③自动结果回写、结束汇总弹窗、文件锁手动重试提示、`WriteBackWorker(mode="results")` 调用;模块契约加入 `write_back_results`。
|
||||
- 文档:`docs/06-tasks.md` 将 T-403 标为 DONE;同步 `docs/api.md`、`docs/routes.md`、`docs/current-state.md`,下一个可领取任务更新为 T-501。
|
||||
- 验证:`python -m unittest discover -s tests -p test_excel.py` 通过(8 tests);`python -m unittest discover -s tests -p test_gui.py` 通过(32 tests);`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(88 tests)。
|
||||
|
||||
@@ -252,6 +252,55 @@ class ExcelImportTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_write_back_results_writes_new_fields_and_update_status(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
excel_path = os.path.join(temp_dir, "input.xlsx")
|
||||
self.save_workbook(
|
||||
excel_path,
|
||||
[
|
||||
(
|
||||
"待处理任务",
|
||||
[
|
||||
["账号名", "别名", "商品id"],
|
||||
["主店", "alias-a", "51100639510"],
|
||||
["副店", "alias-b", "51100639511"],
|
||||
["未知", "missing", "51100639512"],
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
result = excel.import_tasks([excel_path], path=db_path)
|
||||
tasks = db.list_tasks(batch_id=result["batch_id"], path=db_path)
|
||||
for task in tasks:
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=db_path)
|
||||
db.set_generated(
|
||||
task.id,
|
||||
"新标题" + task.item_id[-2:],
|
||||
rf"D:\images\{task.item_id}_new.jpg",
|
||||
path=db_path,
|
||||
)
|
||||
refreshed = db.list_tasks(batch_id=result["batch_id"], path=db_path)
|
||||
db.set_applied(refreshed[0].id, True, path=db_path)
|
||||
db.set_applied(refreshed[1].id, False, "UPDATE_DISABLED", path=db_path)
|
||||
db.mark_skipped(refreshed[2].id, "别名未匹配账号", path=db_path)
|
||||
|
||||
summary = excel.write_back_results(result["batch_id"], path=db_path)
|
||||
|
||||
self.assertEqual(True, summary["ok"])
|
||||
self.assertEqual(1, summary["files"])
|
||||
self.assertEqual(3, summary["rows"])
|
||||
first = self.row_values_by_header(excel_path, row_number=2)
|
||||
second = self.row_values_by_header(excel_path, row_number=3)
|
||||
third = self.row_values_by_header(excel_path, row_number=4)
|
||||
self.assertEqual("新标题10", first["新标题"])
|
||||
self.assertEqual(r"D:\images\51100639510_new.jpg", first["新封面图片路径"])
|
||||
self.assertEqual("成功", first["更新状态"])
|
||||
self.assertEqual("失败:UPDATE_DISABLED", second["更新状态"])
|
||||
self.assertEqual("略过:别名未匹配账号", third["更新状态"])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_export_copy_writes_copy_without_touching_original(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
|
||||
+114
-1
@@ -515,7 +515,15 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assertEqual(["alias-a", "alias-b"], applied_aliases)
|
||||
self.assertEqual(
|
||||
{"ok": False, "total": 3, "done": 3, "applied": 1, "skipped": 1, "failed": 1},
|
||||
{
|
||||
"ok": False,
|
||||
"total": 3,
|
||||
"done": 3,
|
||||
"applied": 1,
|
||||
"skipped": 1,
|
||||
"failed": 1,
|
||||
"batch_ids": [batch_id],
|
||||
},
|
||||
summary,
|
||||
)
|
||||
self.assertEqual(
|
||||
@@ -538,6 +546,97 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_tab_auto_starts_result_write_back_and_shows_summary(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
db.init_db(cfg["db_path"])
|
||||
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"])
|
||||
db.set_applied(task.id, True, path=cfg["db_path"])
|
||||
statuses = []
|
||||
tab = ApplyTab(config=cfg, status_callback=statuses.append)
|
||||
self.addCleanup(tab.close)
|
||||
payload = {
|
||||
"done": 1,
|
||||
"total": 1,
|
||||
"applied": 1,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"batch_ids": [batch_id],
|
||||
}
|
||||
|
||||
with mock.patch.object(tab, "_start_result_write_back", return_value=True) as start_write_back:
|
||||
tab._on_apply_finished(payload)
|
||||
|
||||
start_write_back.assert_called_once_with(
|
||||
[batch_id],
|
||||
auto=True,
|
||||
apply_summary=payload,
|
||||
)
|
||||
self.assertIn("正在自动回写结果到 Excel", statuses[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_tab_result_write_back_finished_shows_completion_popup(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
statuses = []
|
||||
tab = ApplyTab(config=self.make_config(temp_dir), status_callback=statuses.append)
|
||||
self.addCleanup(tab.close)
|
||||
summary = {"applied": 2, "failed": 1, "skipped": 1}
|
||||
write_back_payload = {"ok": True, "files": 1, "rows": 4}
|
||||
|
||||
with mock.patch("app.gui.QMessageBox.information") as info:
|
||||
tab._on_result_write_back_finished(
|
||||
write_back_payload,
|
||||
auto=True,
|
||||
apply_summary=summary,
|
||||
)
|
||||
|
||||
message = info.call_args[0][2]
|
||||
self.assertIn("成功:2,失败:1,略过:1", message)
|
||||
self.assertIn("Excel 回写:文件1,行4", message)
|
||||
self.assertIn("Excel 自动回写更新结果完成", statuses[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_apply_tab_result_write_back_locked_file_message_points_to_manual_retry(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
statuses = []
|
||||
tab = ApplyTab(config=self.make_config(temp_dir), status_callback=statuses.append)
|
||||
self.addCleanup(tab.close)
|
||||
summary = {"applied": 1, "failed": 0, "skipped": 0}
|
||||
|
||||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||||
tab._on_result_write_back_failed(
|
||||
-1,
|
||||
"Excel 文件被占用,请关闭后重试: input.xlsx",
|
||||
auto=True,
|
||||
apply_summary=summary,
|
||||
)
|
||||
|
||||
message = warning.call_args[0][2]
|
||||
self.assertIn("成功:1,失败:0,略过:0", message)
|
||||
self.assertIn("点击「回写结果到 Excel」手动重试", message)
|
||||
self.assertIn("手动重试", statuses[-1])
|
||||
|
||||
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)
|
||||
@@ -1246,6 +1345,20 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
path="db.sqlite",
|
||||
)
|
||||
|
||||
def test_write_back_worker_calls_result_write_back(self):
|
||||
with mock.patch(
|
||||
"app.gui.excel.write_back_results",
|
||||
return_value={"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2},
|
||||
) as write_back_results:
|
||||
summary = WriteBackWorker("batch-1", db_path="db.sqlite", mode="results").execute()
|
||||
|
||||
self.assertEqual({"ok": True, "batch_id": "batch-1", "files": 1, "rows": 2}, summary)
|
||||
write_back_results.assert_called_once_with(
|
||||
"batch-1",
|
||||
excel_path=None,
|
||||
path="db.sqlite",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -21,7 +21,7 @@ def import_or_skip(module_name):
|
||||
class PendingModuleContractTests(unittest.TestCase):
|
||||
def test_excel_contract_when_module_exists(self):
|
||||
excel = import_or_skip("app.excel")
|
||||
for name in ("import_tasks", "match_summary", "write_back", "export_copy"):
|
||||
for name in ("import_tasks", "match_summary", "write_back", "write_back_results", "export_copy"):
|
||||
self.assertTrue(callable(getattr(excel, name, None)), name)
|
||||
|
||||
def test_prompts_contract_when_module_exists(self):
|
||||
|
||||
Reference in New Issue
Block a user