feat: 完成T-403结果回写与汇总

新增excel.write_back_results,按原Excel文件、工作表和行号回写新标题、新封面图片路径与更新状态。

③更新完成后自动用WriteBackWorker(mode=results)回写结果,并弹窗汇总成功、失败、略过数量;文件被占用时提示关闭Excel后手动重试。

补充Excel结果回写、GUI自动回写/汇总/锁文件提示和模块契约测试,同步任务看板、API、流程和当前状态文档。
This commit is contained in:
chengma
2026-06-27 17:28:33 +08:00
parent 04ff06a2e3
commit 3bc1776d07
10 changed files with 465 additions and 30 deletions
+206 -9
View File
@@ -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):