feat: add optional cover generation toggle

This commit is contained in:
chengma
2026-07-02 15:17:11 +08:00
parent f9c6dd3331
commit 21cd02c167
13 changed files with 348 additions and 48 deletions
+37 -1
View File
@@ -194,6 +194,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
"retry", "retry",
"jpg_quality", "jpg_quality",
"resolution", "resolution",
"generate_cover",
} }
} }
) )
@@ -206,6 +207,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
on_error = runtime.get("on_error") on_error = runtime.get("on_error")
title_prompt = _prompt_value(prompts, "title") title_prompt = _prompt_value(prompts, "title")
cover_prompt = _prompt_value(prompts, "cover") cover_prompt = _prompt_value(prompts, "cover")
generate_cover = bool(generation_cfg.get("generate_cover", False))
should_stop = should_stop or (lambda: False) should_stop = should_stop or (lambda: False)
eligible = [ eligible = [
task for task in list(tasks) task for task in list(tasks)
@@ -216,8 +218,11 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
"total": len(eligible), "total": len(eligible),
"title_done": 0, "title_done": 0,
"cover_done": 0, "cover_done": 0,
"cover_total": len(eligible) if generate_cover else 0,
"generated_done": 0,
"failed": 0, "failed": 0,
"cancelled": False, "cancelled": False,
"generate_cover": generate_cover,
} }
_emit_generation_progress(on_progress, summary) _emit_generation_progress(on_progress, summary)
title_results = {} title_results = {}
@@ -281,10 +286,34 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
summary["cancelled"] = True summary["cancelled"] = True
_cancel_pending(futures) _cancel_pending(futures)
try: try:
title_results[task.id] = future.result() new_title = future.result()
title_results[task.id] = new_title
summary["title_done"] += 1 summary["title_done"] += 1
set_step(task, "title_done") set_step(task, "title_done")
_emit_generation_event(on_event, task, "title", "title_done", "success") _emit_generation_event(on_event, task, "title", "title_done", "success")
if not generate_cover:
set_step(task, "db_write")
_emit_generation_event(on_event, task, "title", "db_write", "start")
db.set_generated(task.id, new_title, None, path=db_path)
summary["generated_done"] += 1
if on_task_update is not None:
on_task_update(
task.id,
{
"stage": "generated",
"status": "success",
"new_title": new_title,
"new_cover_path": None,
},
)
_emit_generation_event(
on_event,
task,
"title",
"db_write",
"success",
detail="仅生成标题",
)
except CancelledError: except CancelledError:
summary["cancelled"] = True summary["cancelled"] = True
_emit_generation_event(on_event, task, "title", get_step(task, "title_request"), "cancelled", level="warning") _emit_generation_event(on_event, task, "title", get_step(task, "title_request"), "cancelled", level="warning")
@@ -297,6 +326,11 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
_emit_generation_error(on_error, task, "title", step, exc, error) _emit_generation_error(on_error, task, "title", step, exc, error)
_emit_generation_progress(on_progress, summary) _emit_generation_progress(on_progress, summary)
if not generate_cover:
if summary["cancelled"]:
summary["ok"] = False
return summary
cover_tasks = [ cover_tasks = [
task for task in eligible task for task in eligible
if task.id in title_results if task.id in title_results
@@ -353,6 +387,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
_emit_generation_event(on_event, task, "cover", "db_write", "start") _emit_generation_event(on_event, task, "cover", "db_write", "start")
db.set_generated(task.id, new_title, new_cover_path, path=db_path) db.set_generated(task.id, new_title, new_cover_path, path=db_path)
summary["cover_done"] += 1 summary["cover_done"] += 1
summary["generated_done"] += 1
if on_task_update is not None: if on_task_update is not None:
on_task_update( on_task_update(
task.id, task.id,
@@ -413,6 +448,7 @@ def _runtime_config(runtime):
"retry", "retry",
"jpg_quality", "jpg_quality",
"resolution", "resolution",
"generate_cover",
"resolution_timeouts", "resolution_timeouts",
} }
} }
+1
View File
@@ -28,6 +28,7 @@ DEFAULT_CONFIG = {
"ai": { "ai": {
"default_text_model": "GPT-5.5 文本", "default_text_model": "GPT-5.5 文本",
"default_image_model": "Nano Banana 2", "default_image_model": "Nano Banana 2",
"generate_cover": False,
"title_concurrency": 4, "title_concurrency": 4,
"image_concurrency": 4, "image_concurrency": 4,
"retry": 2, "retry": 2,
+108 -13
View File
@@ -782,13 +782,19 @@ if QT_IMPORT_ERROR is None:
parent=None, parent=None,
db_path=None, db_path=None,
config=None, config=None,
config_path=None,
status_callback=None, status_callback=None,
title_prompt_path=None, title_prompt_path=None,
cover_prompts_dir=None, cover_prompts_dir=None,
open_accounts_callback=None, open_accounts_callback=None,
): ):
super().__init__(parent) super().__init__(parent)
self.config = appconfig.load_config() if config is None else config self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
self.config_path = (
config_path
or self.config.get("config_path")
or appconfig.CONFIG_PATH
)
self.db_path = _database_path(db_path, self.config) self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback self.open_accounts_callback = open_accounts_callback
@@ -829,7 +835,12 @@ if QT_IMPORT_ERROR is None:
self.reset_generate_button = QPushButton("重置生成结果") self.reset_generate_button = QPushButton("重置生成结果")
self.reset_generate_button.setObjectName("resetGenerateButton") self.reset_generate_button.setObjectName("resetGenerateButton")
self.stop_generate_button.setEnabled(False) self.stop_generate_button.setEnabled(False)
self.progress_label = QLabel("进度:标题0/0 · 封面0/0 · 失败0") ai_settings = appconfig.ai_config(self.config)
self.generate_cover_checkbox = QCheckBox("生成封面图片(成本较高)")
self.generate_cover_checkbox.setObjectName("generateCoverCheckbox")
self.generate_cover_checkbox.setToolTip("关闭后只生成标题并保存为可更新,不调用图片模型")
self.generate_cover_checkbox.setChecked(bool(ai_settings.get("generate_cover", False)))
self.progress_label = QLabel("进度:标题0/0 · 图片0/0 · 失败0")
self.title_progress_label = QLabel("标题 0/0") self.title_progress_label = QLabel("标题 0/0")
self.title_progress_label.setObjectName("generateTitleProgressLabel") self.title_progress_label.setObjectName("generateTitleProgressLabel")
self.title_progress_bar = QProgressBar() self.title_progress_bar = QProgressBar()
@@ -951,6 +962,7 @@ if QT_IMPORT_ERROR is None:
button_layout.addWidget(self.reset_generate_button) button_layout.addWidget(self.reset_generate_button)
bottom_layout = QHBoxLayout() bottom_layout = QHBoxLayout()
bottom_layout.addWidget(self.generate_cover_checkbox)
bottom_layout.addLayout(progress_layout, 1) bottom_layout.addLayout(progress_layout, 1)
bottom_layout.addLayout(button_layout) bottom_layout.addLayout(button_layout)
@@ -973,6 +985,7 @@ if QT_IMPORT_ERROR is None:
self.delete_cover_template_action.triggered.connect(self.delete_cover_template) self.delete_cover_template_action.triggered.connect(self.delete_cover_template)
self.insert_title_button.clicked.connect(self.insert_title_placeholder) self.insert_title_button.clicked.connect(self.insert_title_placeholder)
self.preview_prompt_button.clicked.connect(self.preview_cover_prompt) self.preview_prompt_button.clicked.connect(self.preview_cover_prompt)
self.generate_cover_checkbox.toggled.connect(self._on_generate_cover_toggled)
self.generate_button.clicked.connect(self.start_generate) self.generate_button.clicked.connect(self.start_generate)
self.stop_generate_button.clicked.connect(self.stop_generate) self.stop_generate_button.clicked.connect(self.stop_generate)
self.reset_generate_button.clicked.connect(self.reset_generated_result) self.reset_generate_button.clicked.connect(self.reset_generated_result)
@@ -995,6 +1008,44 @@ if QT_IMPORT_ERROR is None:
scroll_bar = self.run_log_view.verticalScrollBar() scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum()) scroll_bar.setValue(scroll_bar.maximum())
def _on_generate_cover_toggled(self, checked):
previous = bool(appconfig.ai_config(self.config).get("generate_cover", False))
if self._save_generate_cover_setting(show_status=True):
return
self.generate_cover_checkbox.blockSignals(True)
self.generate_cover_checkbox.setChecked(previous)
self.generate_cover_checkbox.blockSignals(False)
def _save_generate_cover_setting(self, show_status=True):
generate_cover = bool(self.generate_cover_checkbox.isChecked())
ai_settings = appconfig.ai_config(self.config)
ai_settings["generate_cover"] = generate_cover
payload = {
key: value
for key, value in self.config.items()
if key not in {"config_path", "ai_models_path"}
}
payload["ai"] = ai_settings
try:
saved = appconfig.save_config(payload, path=self.config_path)
except Exception as exc:
self._set_status(f"生成封面开关保存失败:{exc}")
return False
internal = {
key: value
for key, value in self.config.items()
if key in {"config_path", "ai_models_path"}
}
self.config.clear()
self.config.update(saved)
self.config.update(internal)
if self.config_path != appconfig.CONFIG_PATH:
self.config["config_path"] = self.config_path
if show_status:
mode = "会同时生成封面图片" if generate_cover else "只生成标题,不生成图片"
self._set_status(f"AI生成设置已保存:{mode}")
return True
def _load_latest_generate_run_log(self): def _load_latest_generate_run_log(self):
try: try:
logs = db.list_run_logs(limit=1, run_type="generate", path=self.db_path) logs = db.list_run_logs(limit=1, run_type="generate", path=self.db_path)
@@ -1155,6 +1206,9 @@ if QT_IMPORT_ERROR is None:
if self.generate_thread is not None: if self.generate_thread is not None:
self._set_status("AI 生成正在进行...") self._set_status("AI 生成正在进行...")
return return
if not self._save_generate_cover_setting(show_status=False):
return
generate_cover = bool(self.generate_cover_checkbox.isChecked())
tasks = [ tasks = [
task for task in self.model.tasks task for task in self.model.tasks
if getattr(task, "stage", None) == "collected" if getattr(task, "stage", None) == "collected"
@@ -1186,7 +1240,15 @@ if QT_IMPORT_ERROR is None:
self.generate_thread = thread self.generate_thread = thread
self._set_generate_running(True) self._set_generate_running(True)
self._update_generate_progress( self._update_generate_progress(
{"total": len(tasks), "title_done": 0, "cover_done": 0, "failed": 0} {
"total": len(tasks),
"title_done": 0,
"cover_done": 0,
"cover_total": len(tasks) if generate_cover else 0,
"generated_done": 0,
"failed": 0,
"generate_cover": generate_cover,
}
) )
self._set_status(f"开始 AI 生成:{len(tasks)} 条") self._set_status(f"开始 AI 生成:{len(tasks)} 条")
thread.start() thread.start()
@@ -1314,6 +1376,7 @@ if QT_IMPORT_ERROR is None:
self.save_cover_template_as_action.setEnabled(not running) self.save_cover_template_as_action.setEnabled(not running)
self.rename_cover_template_action.setEnabled(not running) self.rename_cover_template_action.setEnabled(not running)
self.delete_cover_template_action.setEnabled(not running) self.delete_cover_template_action.setEnabled(not running)
self.generate_cover_checkbox.setEnabled(not running)
def _forget_generate_thread(self, thread): def _forget_generate_thread(self, thread):
if self.generate_thread is thread: if self.generate_thread is thread:
@@ -1351,13 +1414,14 @@ if QT_IMPORT_ERROR is None:
total = max(0, int(payload.get("total", 0) or 0)) total = max(0, int(payload.get("total", 0) or 0))
title_done = max(0, int(payload.get("title_done", 0) or 0)) title_done = max(0, int(payload.get("title_done", 0) or 0))
cover_done = max(0, int(payload.get("cover_done", 0) or 0)) cover_done = max(0, int(payload.get("cover_done", 0) or 0))
cover_total = self._cover_total_for_progress(payload, total)
failed = max(0, int(payload.get("failed", 0) or 0)) failed = max(0, int(payload.get("failed", 0) or 0))
self.progress_label.setText("进度:" + self._generate_progress_text(payload)) self.progress_label.setText("进度:" + self._generate_progress_text(payload))
self.title_progress_label.setText(f"标题 {title_done}/{total}") self.title_progress_label.setText(f"标题 {title_done}/{total}")
self.cover_progress_label.setText(f"图片 {cover_done}/{total}") self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}")
self.failed_progress_label.setText(f"失败 {failed}") self.failed_progress_label.setText(f"失败 {failed}")
self._set_progress_bar(self.title_progress_bar, title_done, total) self._set_progress_bar(self.title_progress_bar, title_done, total)
self._set_progress_bar(self.cover_progress_bar, cover_done, total) self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total)
def _set_progress_bar(self, bar, done, total): def _set_progress_bar(self, bar, done, total):
maximum = max(1, int(total or 0)) maximum = max(1, int(total or 0))
@@ -1365,10 +1429,19 @@ if QT_IMPORT_ERROR is None:
bar.setRange(0, maximum) bar.setRange(0, maximum)
bar.setValue(value) bar.setValue(value)
def _cover_total_for_progress(self, payload, total):
cover_total = payload.get("cover_total")
if cover_total is None:
cover_total = total if payload.get("generate_cover", True) else 0
return max(0, int(cover_total or 0))
def _generate_progress_text(self, payload): def _generate_progress_text(self, payload):
return "标题{title}/{total} · 封面{cover}/{total} · 失败{failed}".format( total = max(0, int(payload.get("total", 0) or 0))
cover_total = self._cover_total_for_progress(payload, total)
return "标题{title}/{total} · 图片{cover}/{cover_total} · 失败{failed}".format(
title=payload.get("title_done", 0), title=payload.get("title_done", 0),
cover=payload.get("cover_done", 0), cover=payload.get("cover_done", 0),
cover_total=cover_total,
total=payload.get("total", 0), total=payload.get("total", 0),
failed=payload.get("failed", 0), failed=payload.get("failed", 0),
) )
@@ -3294,13 +3367,19 @@ if QT_IMPORT_ERROR is None:
batch_ids = self._batch_ids(eligible) batch_ids = self._batch_ids(eligible)
self._run_id = self._create_run_log(eligible, batch_ids) self._run_id = self._create_run_log(eligible, batch_ids)
ai_cfg = appconfig.ai_config(self.config) ai_cfg = appconfig.ai_config(self.config)
self._log_run_event( generate_cover = bool(ai_cfg.get("generate_cover", False))
"[开始] 本轮生成 {total} 条:标题{total},图片{total};标题并发{title_concurrency},图片并发{image_concurrency}".format( if generate_cover:
start_message = "[开始] 本轮生成 {total} 条:标题{total},图片{total};标题并发{title_concurrency},图片并发{image_concurrency}".format(
total=len(eligible), total=len(eligible),
title_concurrency=ai_cfg.get("title_concurrency", 1), title_concurrency=ai_cfg.get("title_concurrency", 1),
image_concurrency=ai_cfg.get("image_concurrency", 1), image_concurrency=ai_cfg.get("image_concurrency", 1),
) )
else:
start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题并发{title_concurrency}".format(
total=len(eligible),
title_concurrency=ai_cfg.get("title_concurrency", 1),
) )
self._log_run_event(start_message)
try: try:
summary = ai.generate_batch( summary = ai.generate_batch(
self.tasks, self.tasks,
@@ -3313,6 +3392,7 @@ if QT_IMPORT_ERROR is None:
"on_task_update": self._emit_row_update, "on_task_update": self._emit_row_update,
"on_event": self._on_generation_event, "on_event": self._on_generation_event,
"on_error": self._on_generation_error, "on_error": self._on_generation_error,
"generate_cover": generate_cover,
}, },
on_progress=self.progress.emit, on_progress=self.progress.emit,
should_stop=self.should_cancel, should_stop=self.should_cancel,
@@ -3325,8 +3405,11 @@ if QT_IMPORT_ERROR is None:
"total": len(eligible), "total": len(eligible),
"title_done": 0, "title_done": 0,
"cover_done": 0, "cover_done": 0,
"cover_total": len(eligible) if generate_cover else 0,
"generated_done": 0,
"failed": len(eligible), "failed": len(eligible),
"cancelled": self.should_cancel(), "cancelled": self.should_cancel(),
"generate_cover": generate_cover,
} }
self._log_run_event( self._log_run_event(
f"[失败] AI 生成运行失败:{error}", f"[失败] AI 生成运行失败:{error}",
@@ -3368,6 +3451,9 @@ if QT_IMPORT_ERROR is None:
return f"[标题] {self._task_progress_label(task)} 开始生成" return f"[标题] {self._task_progress_label(task)} 开始生成"
if result == "success" and step == "title_done": if result == "success" and step == "title_done":
return f"[标题] {self._task_progress_label(task)} 成功" return f"[标题] {self._task_progress_label(task)} 成功"
if result == "success" and step == "db_write":
suffix = f",{detail}" if detail else ""
return f"[标题] {self._task_progress_label(task)} 已保存{suffix}"
if result == "retry": if result == "retry":
return self._retry_message("标题", task, payload, detail) return self._retry_message("标题", task, payload, detail)
if result == "failed": if result == "failed":
@@ -3435,9 +3521,11 @@ if QT_IMPORT_ERROR is None:
return f"[完成] AI 生成完成:{progress}" return f"[完成] AI 生成完成:{progress}"
def _summary_text(self, summary): def _summary_text(self, summary):
return "标题{title}/{total},图片{cover}/{total},失败{failed}".format( cover_total = summary.get("cover_total", summary.get("total", 0))
return "标题{title}/{total},图片{cover}/{cover_total},失败{failed}".format(
title=summary.get("title_done", 0), title=summary.get("title_done", 0),
cover=summary.get("cover_done", 0), cover=summary.get("cover_done", 0),
cover_total=cover_total,
total=summary.get("total", 0), total=summary.get("total", 0),
failed=summary.get("failed", 0), failed=summary.get("failed", 0),
) )
@@ -3478,6 +3566,7 @@ if QT_IMPORT_ERROR is None:
"resolution": ai_cfg.get("resolution"), "resolution": ai_cfg.get("resolution"),
"title_concurrency": ai_cfg.get("title_concurrency"), "title_concurrency": ai_cfg.get("title_concurrency"),
"image_concurrency": ai_cfg.get("image_concurrency"), "image_concurrency": ai_cfg.get("image_concurrency"),
"generate_cover": ai_cfg.get("generate_cover", False),
}, },
path=self.db_path, path=self.db_path,
) )
@@ -3488,12 +3577,17 @@ if QT_IMPORT_ERROR is None:
if self._run_id is None: if self._run_id is None:
return return
try: try:
done = int(summary.get("cover_done", 0) or 0) + int(summary.get("failed", 0) or 0) generated_done = summary.get("generated_done")
if generated_done is None:
generated_done = summary.get("cover_done", 0)
if not summary.get("generate_cover", True) and not generated_done:
generated_done = summary.get("title_done", 0)
done = int(generated_done or 0) + int(summary.get("failed", 0) or 0)
db.finish_run_log( db.finish_run_log(
self._run_id, self._run_id,
status=status, status=status,
done=done, done=done,
success_count=summary.get("cover_done", 0), success_count=generated_done,
skipped_count=0, skipped_count=0,
failed_count=summary.get("failed", 0), failed_count=summary.get("failed", 0),
summary_json=summary, summary_json=summary,
@@ -6125,13 +6219,13 @@ if QT_IMPORT_ERROR is None:
def __init__(self, db_path=None, config=None, config_path=None, ai_models_path=None): def __init__(self, db_path=None, config=None, config_path=None, ai_models_path=None):
super().__init__() super().__init__()
self.config = appconfig.load_config() if config is None else config self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
self.db_path = _database_path(db_path, self.config)
self.config_path = ( self.config_path = (
config_path config_path
or self.config.get("config_path") or self.config.get("config_path")
or appconfig.CONFIG_PATH or appconfig.CONFIG_PATH
) )
self.db_path = _database_path(db_path, self.config)
self.ai_models_path = ( self.ai_models_path = (
ai_models_path ai_models_path
or self.config.get("ai_models_path") or self.config.get("ai_models_path")
@@ -6162,6 +6256,7 @@ if QT_IMPORT_ERROR is None:
return GenerateTab( return GenerateTab(
db_path=self.db_path, db_path=self.db_path,
config=self.config, config=self.config,
config_path=self.config_path,
status_callback=self.statusBar().showMessage, status_callback=self.statusBar().showMessage,
open_accounts_callback=lambda: self.open_accounts_tab(), open_accounts_callback=lambda: self.open_accounts_tab(),
) )
+1 -1
View File
@@ -42,7 +42,7 @@
| 导入(①) | 导入多个 Excel,按模板解析输入列(账号名/别名/商品id)为任务列表 | P0 | | 导入(①) | 导入多个 Excel,按模板解析输入列(账号名/别名/商品id)为任务列表 | P0 |
| 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 | | 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 |
| 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 | | 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 |
| AI 生成(②) | 输入标题/封面提示词,AI 据提示词+旧标题生成新标题、据提示词+旧封面生成新封面 | P0 | | AI 生成(②) | 输入标题/封面提示词,AI 据提示词+旧标题生成新标题;封面图片按②本轮「生成封面图片(成本较高)」开关可选生成,避免用户无意产生图片模型成本 | P0 |
| 提示词管理(②) | 标题提示词「保存」到 `title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 | | 提示词管理(②) | 标题提示词「保存」到 `title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 |
| 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 | | 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 |
| 更新 shopee(③) | 按批次/店铺/状态筛选;可先「检查本轮更新」确认范围;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务按每批最大条数分批更新 Shopee,逐条换标题+封面并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 | | 更新 shopee(③) | 按批次/店铺/状态筛选;可先「检查本轮更新」确认范围;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务按每批最大条数分批更新 Shopee,逐条换标题+封面并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 |
+11 -8
View File
@@ -52,7 +52,7 @@ imported → collected → generated → applied
- **imported**:openpyxl 解析输入列入库。 - **imported**:openpyxl 解析输入列入库。
- **collected**:只读打开商品页,读旧标题、下载旧封面到本地,写 `old_title/old_cover_path`,回写 Excel 旧字段。 - **collected**:只读打开商品页,读旧标题、下载旧封面到本地,写 `old_title/old_cover_path`,回写 Excel 旧字段。
- **generated**:AI 用提示词+旧标题生成新标题;用提示词+旧封面生成新封面(image-to-image),新图存本地。**不设逐条人工审核阶段**,生成完即可进入 ③(新图留档本地 + 回写 Excel 供事后追溯)。 - **generated**:AI 用提示词+旧标题生成新标题;封面按②本轮「生成封面图片(成本较高)」开关可选生成,开启时用提示词+旧封面生成新封面(image-to-image)并存本地,关闭时 `new_cover_path=NULL`。**不设逐条人工审核阶段**,标题生成成功即可进入 ③;有新封面时 ③ 可在安全开关允许后换封面,没有新封面时 ③ 只改标题。
- **applied**:③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量;确认后打开编辑页换标题+封面,逐条点「更新」提交线上,回写结果。 - **applied**:③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量;确认后打开编辑页换标题+封面,逐条点「更新」提交线上,回写结果。
任意阶段失败 → `stage` 不前进、记 `error`、`result/skipped`,不影响其他任务。 任意阶段失败 → `stage` 不前进、记 `error`、`result/skipped`,不影响其他任务。
@@ -102,6 +102,7 @@ imported → collected → generated → applied
"ai": { "ai": {
"default_text_model": "GPT-5.5 文本", "default_text_model": "GPT-5.5 文本",
"default_image_model": "Nano Banana 2", "default_image_model": "Nano Banana 2",
"generate_cover": false,
"title_concurrency": 4, "title_concurrency": 4,
"image_concurrency": 4, "image_concurrency": 4,
"retry": 2, "retry": 2,
@@ -125,6 +126,7 @@ imported → collected → generated → applied
`ai` 段只放**选择 + 全局生成参数**: `ai` 段只放**选择 + 全局生成参数**:
- `default_text_model` / `default_image_model`:引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型)。 - `default_text_model` / `default_image_model`:引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型)。
- `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。
- `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。 - `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。
- `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。 - `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。
- 模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。 - 模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。
@@ -301,7 +303,7 @@ CREATE TABLE run_log_events (
- `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。 - `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。
- `row_key` 防止同一批次内重复导入同一行。 - `row_key` 防止同一批次内重复导入同一行。
- `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。 - `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。
- `new_title/new_cover_path`:**AI 生成**结果(输出),图片落本地;不设逐条确认阶段,生成即可进入 ③。 - `new_title/new_cover_path`:**AI 生成**结果(输出);`new_title` 是进入 ③ 的必需结果,`new_cover_path` 是可选结果,只有②勾选生成封面且图片成功时才写入本地新封面路径;不设逐条确认阶段,标题生成成功即可进入 ③。
- `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。 - `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。
- `committed`:③ 用户确认批量更新弹窗后逐条点「更新」提交;该字段记录提交是否成功。 - `committed`:③ 用户确认批量更新弹窗后逐条点「更新」提交;该字段记录提交是否成功。
- 各字段**每阶段处理完立即写回 SQLite**(实时落库);阶段结束后批量回写 Excel。 - 各字段**每阶段处理完立即写回 SQLite**(实时落库);阶段结束后批量回写 Excel。
@@ -366,17 +368,18 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
### 6.2 AI 生成(② Tab) ### 6.2 AI 生成(② Tab)
单个「开始生成」按钮,**两段式、各自并发**(标题快、图片慢,分开并发更高效): 单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成:
1. **并发生成标题**:线程池大小 = `title_concurrency`,用 `default_text_model` 调 `gen_title(标题提示词, old_title)` → new_title。 1. **并发生成标题**:线程池大小 = `title_concurrency`,用 `default_text_model` 调 `gen_title(标题提示词, old_title)` → new_title。
2. **接着并发生成图片**:线程池大小 = `image_concurrency`,用 `default_image_model` 调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。 2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,用 `default_image_model` 调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。
- 连接超时取该模型 `connect_timeout_seconds`;**返回超时取 `resolution_timeouts[resolution]`**(512→180/1k→240/2k→360/4k→600)。 - 连接超时取该模型 `connect_timeout_seconds`;**返回超时取 `resolution_timeouts[resolution]`**(512→180/1k→240/2k→360/4k→600)。
3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。
- 失败重试:每次调用失败按 `retry` 次重试,仍失败则记 error(不阻塞其余)。 - 失败重试:每次调用失败按 `retry` 次重试,仍失败则记 error(不阻塞其余)。
- 每条/每张完成**立即 `set_generated` 写库**(实时落库,停止或崩溃不丢已生成的)。 - 标题-only 模式在标题成功后立即 `set_generated(task_id, new_title, NULL)` 写库;标题+封面模式在图片成功后 `set_generated(task_id, new_title, new_cover_path)` 写库。两种模式都实时落库,停止或崩溃不丢已生成结果。
- **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。 - **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。
- 进度:`标题 x/n · 封面 x/n · 失败 n`。 - 进度:标题和图片两条进度分开显示;未勾选生成封面时图片进度显示本轮未生成/0 张,并在运行日志写明“本轮仅生成标题”。
- 生成后 stage=generated;**不设逐条人工审核阶段**。双击任务弹窗查看新旧封面(纯查看);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。 - 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并只更新标题;若后续需要封面,用户可重置生成结果或重新生成该任务。双击任务弹窗查看新旧封面(纯查看,无新封面时显示为空);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。
- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`config.json` 的 `ai` 段)。 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`config.json` 的 `ai` 段)。
提示词管理: 提示词管理:
@@ -392,7 +395,7 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
- 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`,或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建真实更新 `ApplyWorker`。普通正式更新不再检查 `test_item_id`,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。 - 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`,或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建真实更新 `ApplyWorker`。普通正式更新不再检查 `test_item_id`,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。 - 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
- 真实更新前必须做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、账号 Chrome 未启动、CDP 端口不可访问、未登录或本轮账号端口冲突时,整体返回 `blocked` 并由 GUI 弹窗列出账号/原因、引导去④账号管理。预检不通过时不创建商品编辑页、不调用 `editor.apply_task()`、不写失败状态、不自动调用「启动登录」或静默打开 Chrome。 - 真实更新前必须做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、账号 Chrome 未启动、CDP 端口不可访问、未登录或本轮账号端口冲突时,整体返回 `blocked` 并由 GUI 弹窗列出账号/原因、引导去④账号管理。预检不通过时不创建商品编辑页、不调用 `editor.apply_task()`、不写失败状态、不自动调用「启动登录」或静默打开 Chrome。
- 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。 - 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。标题-only 任务没有 `new_cover_path` 时跳过封面更新,也不需要开启 ⑤「允许更新封面」。
- `click_update` 点击页面「更新」后必须处理 Shopee 站点侧二次确认框。2026-06-29 真实测试商品实测:页面会出现 `.eds-modal__content` / `.eds-modal__box`,标题为 `確定您要更新商品嗎?`,正文提示建议优化,底部两个按钮分别是 `立即優化` 与主按钮 `更新`。实现时只允许在标题匹配该确认框、且按钮位于可见 modal footer 内时点击 `button.eds-button--primary` / 文案 `更新`;不得点击 `立即優化`。如果弹窗出现但未成功点击主按钮,当前任务必须视为未提交失败,不得写 `committed=1`。 - `click_update` 点击页面「更新」后必须处理 Shopee 站点侧二次确认框。2026-06-29 真实测试商品实测:页面会出现 `.eds-modal__content` / `.eds-modal__box`,标题为 `確定您要更新商品嗎?`,正文提示建议优化,底部两个按钮分别是 `立即優化` 与主按钮 `更新`。实现时只允许在标题匹配该确认框、且按钮位于可见 modal footer 内时点击 `button.eds-button--primary` / 文案 `更新`;不得点击 `立即優化`。如果弹窗出现但未成功点击主按钮,当前任务必须视为未提交失败,不得写 `committed=1`。
- 更新封面时,`replace_cover()` 必须按“替换第一张”语义执行:无论当前商品图片是 8 张还是 9 张,只要本次有新封面,就先确认该任务已有本地旧封面备份(`old_cover_path` 非空且文件存在),再删除当前线上第一张图、等待图片管理器稳定、上传新图并拖到第一位。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。 - 更新封面时,`replace_cover()` 必须按“替换第一张”语义执行:无论当前商品图片是 8 张还是 9 张,只要本次有新封面,就先确认该任务已有本地旧封面备份(`old_cover_path` 非空且文件存在),再删除当前线上第一张图、等待图片管理器稳定、上传新图并拖到第一位。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。
- 默认串行、单条失败继续;⑤ 开启 `parallel_accounts` 后按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。 - 默认串行、单条失败继续;⑤ 开启 `parallel_accounts` 后按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。
+1
View File
@@ -103,6 +103,7 @@
| T-517 | ⑤设置分区 + 清理兼容字段 | T-506, T-507 | 依据 `docs/ux-review.md` P2。⑤ 视觉分区:将高频「Shopee 更新安全 / 执行模式」前置成独立分区,低频「基础设施(Chrome路径/端口/DB路径)」独立后置;彻底隐藏 `test_item_id`、`dry_run` 用户入口,不再占用可见表单位置,但保留 `config.json` 内部兼容字段与③「检查本轮更新」语义。只改⑤设置页 UI 分区、保存/加载展示和 GUI 测试,不改配置 schema,不改 Shopee/CDP 更新流程 | DONE | | T-517 | ⑤设置分区 + 清理兼容字段 | T-506, T-507 | 依据 `docs/ux-review.md` P2。⑤ 视觉分区:将高频「Shopee 更新安全 / 执行模式」前置成独立分区,低频「基础设施(Chrome路径/端口/DB路径)」独立后置;彻底隐藏 `test_item_id`、`dry_run` 用户入口,不再占用可见表单位置,但保留 `config.json` 内部兼容字段与③「检查本轮更新」语义。只改⑤设置页 UI 分区、保存/加载展示和 GUI 测试,不改配置 schema,不改 Shopee/CDP 更新流程 | DONE |
| T-518 | ②左栏提示词区组件密度优化 | T-302p, T-303, T-517 | 依据 `docs/ux-review.md` P2。② AI生成左栏保留高频「模板选择、编辑内容、保存当前模板/开始生成」可见入口;将封面模板低频操作「另存为、重命名、删除」收敛进一个「模板操作」菜单/小按钮,避免左栏按钮堆叠和误点。第一版不迁移到⑤设置,不改 prompts 数据结构,不改 AI 生成 HTTP、DB schema、Excel、Shopee/CDP 流程;删除仍保留现有二次确认,保存/重命名/删除行为和错误处理不变。同步 GUI 测试覆盖菜单入口、动作触发和旧低频按钮不再平铺 | DONE | | T-518 | ②左栏提示词区组件密度优化 | T-302p, T-303, T-517 | 依据 `docs/ux-review.md` P2。② AI生成左栏保留高频「模板选择、编辑内容、保存当前模板/开始生成」可见入口;将封面模板低频操作「另存为、重命名、删除」收敛进一个「模板操作」菜单/小按钮,避免左栏按钮堆叠和误点。第一版不迁移到⑤设置,不改 prompts 数据结构,不改 AI 生成 HTTP、DB schema、Excel、Shopee/CDP 流程;删除仍保留现有二次确认,保存/重命名/删除行为和错误处理不变。同步 GUI 测试覆盖菜单入口、动作触发和旧低频按钮不再平铺 | DONE |
| T-519 | ②AI生成长任务进度条 + 用户可读滚动日志 | T-303, T-505, T-518 | 问题:批量生成几十/上百个标题或图片超时重试时,仅底部文字 `进度:标题x/y · 封面x/y · 失败z` 和技术型结构化日志不足以让用户判断剩余量、是否卡住、慢在哪一步。方案:②底部操作区增加标题/图片两条独立 `QProgressBar`,分两行显示 `标题 x/y`、`图片 x/y`,标题阶段结束后保持 100%,图片进度按本轮总任务口径显示并在旁边保留失败数;现有「AI生成运行日志」改为用户可读自动滚动日志,显示本轮开始、标题/图片开始与成功、商品ID/店铺、图片超时/重试、失败原因、停止请求和完成汇总。日志不得显示 API Key、密码、Cookie、token、完整请求体、base64 图片或超长 prompt;保留 DB `run_logs/run_log_events` 脱敏记录。只改②生成 UI、`GenerateWorker`/AI 生成事件文案和 GUI/AI 单测,不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE | | T-519 | ②AI生成长任务进度条 + 用户可读滚动日志 | T-303, T-505, T-518 | 问题:批量生成几十/上百个标题或图片超时重试时,仅底部文字 `进度:标题x/y · 封面x/y · 失败z` 和技术型结构化日志不足以让用户判断剩余量、是否卡住、慢在哪一步。方案:②底部操作区增加标题/图片两条独立 `QProgressBar`,分两行显示 `标题 x/y`、`图片 x/y`,标题阶段结束后保持 100%,图片进度按本轮总任务口径显示并在旁边保留失败数;现有「AI生成运行日志」改为用户可读自动滚动日志,显示本轮开始、标题/图片开始与成功、商品ID/店铺、图片超时/重试、失败原因、停止请求和完成汇总。日志不得显示 API Key、密码、Cookie、token、完整请求体、base64 图片或超长 prompt;保留 DB `run_logs/run_log_events` 脱敏记录。只改②生成 UI、`GenerateWorker`/AI 生成事件文案和 GUI/AI 单测,不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
| T-520 | ②AI生成封面可选生成开关 | T-519, T-501b | 问题:图片生成成本明显高于标题生成,当前②「开始生成」默认标题和图片都生成,用户只想改标题时也会调用图片模型,成本不可控;⑤「允许更新封面」只控制③线上提交阶段,不能替代②生成阶段的成本选择。方案:②AI生成页增加「生成封面图片(成本较高)」复选框,状态持久化到 `config.json` 的 `ai.generate_cover`,默认 `false`;点击「开始生成」时读取该开关:关闭时只并发生成标题,标题成功后立即 `db.set_generated(task_id, new_title, NULL)`,任务进入 `generated` 并可在③只更新标题,不调用 `gen_cover()`、不渲染封面提示词、不创建新封面文件,图片进度和运行日志明确显示本轮未生成图片;开启时保持现有标题后图片两段流程。③的 `allow_cover_update` 仍只控制线上更新阶段,若任务无 `new_cover_path`,③跳过封面更新且不因未开启「允许更新封面」阻断。只改 `config.json` 的 AI 段、②生成 UI、`GenerateWorker`/`ai.generate_batch()` 编排和 `appconfig/ai/gui` 单测;不改 DB schema、AI HTTP 协议、Excel、Shopee/CDP 更新流程 | DONE |
## 里程碑 ## 里程碑
+6 -6
View File
@@ -91,7 +91,7 @@ mark_running(task_id, phase) -> None
mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1 mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1
mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进 mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进
set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1 set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1
set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1 set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1;new_cover_path 可为 None 表示只生成标题
update_generated_title(task_id, new_title, path=None) -> None # T-509 本地人工微调新标题;仅 generated/未提交/非运行中;清空 last_error 并回到 pending,不触碰 Shopee/CDP/Excel/封面 update_generated_title(task_id, new_title, path=None) -> None # T-509 本地人工微调新标题;仅 generated/未提交/非运行中;清空 last_error 并回到 pending,不触碰 Shopee/CDP/Excel/封面
set_applied(task_id, committed, error=None) -> None # 成功 stage=applied;失败 status=failed 且 stage 不前进 set_applied(task_id, committed, error=None) -> None # 成功 stage=applied;失败 status=failed 且 stage 不前进
# T-404a 已实现:选中记录重置 # T-404a 已实现:选中记录重置
@@ -275,8 +275,8 @@ gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=N
# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`,历史 DB 已存路径继续按原路径读取 # 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`,历史 DB 已存路径继续按原路径读取
generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict
# 编排:先以 title_concurrency 线程池并发跑 gen_title,再以 image_concurrency 并发跑 gen_cover # 编排:先以 title_concurrency 线程池并发跑 gen_title;ai.generate_cover 为 true 时再以 image_concurrency 并发跑 gen_cover
# 每条完成即 db.set_generated(实时落库);should_stop() 为真则取消未开始项 # 标题-only 模式标题成功即 db.set_generated(task_id, new_title, None);标题+封面模式图片成功后写 new_cover_path;should_stop() 为真则取消未开始项
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度 # on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度
# ai_cfg 可传 on_event/on_error 回调,逐条报告 title/cover 阶段 step/result/detail,供 GUI run_logs 与本地诊断日志使用 # ai_cfg 可传 on_event/on_error 回调,逐条报告 title/cover 阶段 step/result/detail,供 GUI run_logs 与本地诊断日志使用
# 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余 # 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余
@@ -328,7 +328,7 @@ render_prompt(template_text, task) -> str
main() -> int # 创建 QApplication + MainWindow main() -> int # 创建 QApplication + MainWindow
class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试 class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 检查本轮更新 + 安全开关拦截 + 确认后分批真实更新 + 运行日志 class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 检查本轮更新 + 安全开关拦截 + 确认后分批真实更新 + 运行日志
class SettingsTab(QWidget) # ⑤ 设置:AI 模型管理 + 响应式三列布局 + 角色/生成参数/路径端口 + Shopee 更新安全 class SettingsTab(QWidget) # ⑤ 设置:AI 模型管理 + 响应式三列布局 + 角色/生成参数/路径端口 + Shopee 更新安全
class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed
@@ -392,9 +392,9 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式:
- 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。 - 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。
- 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。 - 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。
- 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。 - 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
- 底部「开始生成」只处理当前筛选结果里 `stage=collected` 的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;通过 `GenerateWorker` 调 `ai.generate_batch()`,先并发标题、再并发封面。 - 底部「开始生成」只处理当前筛选结果里 `stage=collected` 的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;通过 `GenerateWorker` 调 `ai.generate_batch()`,先并发标题;只有②「生成封面图片(成本较高)」勾选时才继续并发封面。
- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。 - 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。
- 进度标签显示:`标题x/n · 封面x/n · 失败z`;每条封面生成成功后立即 `db.set_generated()`,失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 - 进度标签显示标题/图片/失败;未勾选生成封面时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, None)` 并进入 generated;勾选时每条封面生成成功后立即 `db.set_generated(task_id, new_title, new_cover_path)`。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。
- T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。 - T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。
③ 更新shopee当前要点(T-303b/T-401/T-402/T-403): ③ 更新shopee当前要点(T-303b/T-401/T-402/T-403):
+10 -9
View File
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -11,7 +11,7 @@
| Tab | 职责 | 风险 | | Tab | 职责 | 风险 |
| --- | --- | --- | | --- | --- | --- |
| ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 | | ① 导入采集 | 导入多个 Excel;任务列表;**采集**商品当前的旧标题/旧封面(只读),封面图下载本地;回写 Excel 旧字段 | 只读,低 |
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题/新封面;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 | | ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 |
| ③ 更新shopee | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | ③ 更新shopee | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式 | 中 | | ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式 | 中 |
| ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口、Shopee 更新安全开关等 | — | | ⑤ 设置 | AI 模型/API Key、本地图片目录、Chrome 路径、默认端口、Shopee 更新安全开关等 | — |
@@ -74,7 +74,7 @@
│ │[ ]│ │ (双击某行 → 弹窗看 旧封面 | 新封面) │ │ │ │[ ]│ │ (双击某行 → 弹窗看 旧封面 | 新封面) │ │
│ │ │ │ 运行日志:逐条记录标题/封面生成步骤与失败原因 │ │ │ │ │ │ 运行日志:逐条记录标题/封面生成步骤与失败原因 │ │
│ └──────────┘ └───────────────────────────────────────────────┘ │ │ └──────────┘ └───────────────────────────────────────────────┘ │
│ 进度:标题30/30 · 封面12/30 · 失败1 [▶ 开始生成][■停止][重置生成结果] │ │ [ ] 生成封面图片(成本较高) 进度:标题30/30 · 图片0/0 · 失败1 [▶ 开始生成][■停止][重置生成结果] │
└───────────────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────────────┘
``` ```
@@ -84,9 +84,9 @@
- 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。 - 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。
- 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。
- 右下:任务列表(店铺名、商品id、旧标题、新标题、状态)+ AI生成运行日志;已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面 | 新封面(纯查看)。 - 右下:任务列表(店铺名、商品id、旧标题、新标题、状态)+ AI生成运行日志;已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面 | 新封面(纯查看)。
- 底部**单个「开始生成」+「停止」**:开始生成 = **先按 `title_concurrency` 并发生成标题,接着按 `image_concurrency` 并发生成封面**;进度实时显示 标题/封面/失败 计数。 - 底部**单个「开始生成」+「停止」**,并增加「生成封面图片(成本较高)」复选框:默认不勾选,只生成标题;勾选后才在标题完成后按 `image_concurrency` 并发生成封面。该开关状态持久化到 `config.json` 的 `ai.generate_cover`,但入口放在②,便于用户在每轮生成前做成本判断。
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、模型/Key)在 **⑤ 设置**,不在本 Tab 重复。 - 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、模型/Key)在 **⑤ 设置**;②只暴露“本轮是否生成封面”的成本开关。
- 每条/每张完成即写库;`run_type=generate` 写 `run_logs/run_log_events`,图片生成失败同时写本地 `logs/cmshopee.log` 脱敏 traceback;「停止」取消未开始项,可再次「开始生成」对剩余继续。 - 标题-only 模式标题成功即写库并进入 `generated`,`new_cover_path` 留空;标题+封面模式图片成功后写入本地新封面路径。两种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;未勾选生成封面时日志明确显示“本轮仅生成标题”。「停止」取消未开始项,可再次「开始生成」对剩余继续。
- 「重置生成结果」仅作用当前选中单条,运行中禁用;确认后只改本地 DB,清空 `new_title/new_cover_path/last_error` 并退回 `collected/success` 供重新生成,默认不删除本地新封面文件。若该记录已提交过线上,必须在确认框提示本地重置不回滚 Shopee。 - 「重置生成结果」仅作用当前选中单条,运行中禁用;确认后只改本地 DB,清空 `new_title/new_cover_path/last_error` 并退回 `collected/success` 供重新生成,默认不删除本地新封面文件。若该记录已提交过线上,必须在确认框提示本地重置不回滚 Shopee。
- **无逐条人工审核环节**;新标题默认直接用 AI 输出,但允许对已生成且未提交线上的单行做本地微调;可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。 - **无逐条人工审核环节**;新标题默认直接用 AI 输出,但允许对已生成且未提交线上的单行做本地微调;可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。
@@ -162,7 +162,7 @@
│ │
① 导入采集:导入 Excel → 采集旧标题/旧封面 → 自动回写旧字段(失败可手动重试) ① 导入采集:导入 Excel → 采集旧标题/旧封面 → 自动回写旧字段(失败可手动重试)
│ │
② AI生成:提示词 → 生成新标题/新封面(无逐条审核) ② AI生成:提示词 → 生成新标题/可选生成新封面(无逐条审核)
│ │
⑤ 设置:配置 Shopee 更新安全、每批最大更新条数和执行模式 ⑤ 设置:配置 Shopee 更新安全、每批最大更新条数和执行模式
│ │
@@ -179,7 +179,7 @@
| --- | --- | --- | | --- | --- | --- |
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 | | `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 | | `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度已接入 `GenerateWorker` | | `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成封面图片(成本较高)」开关接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「检查本轮更新」+ Shopee 更新安全拦截 + 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 | | `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「检查本轮更新」+ Shopee 更新安全拦截 + 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式 | | `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式 |
| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 响应式三列设置表单 + 角色/生成参数/路径/端口配置 + Shopee 更新安全 | | `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 响应式三列设置表单 + 角色/生成参数/路径/端口配置 + Shopee 更新安全 |
+15
View File
@@ -1006,3 +1006,18 @@
- AI事件:`app/ai.py` 的 `_call_with_retry()` 支持 `on_retry` 回调,标题/图片请求失败但仍会重试时上报 `retry` 事件;不改 AI HTTP 协议、DB schema、Excel 或 Shopee/CDP 流程。 - AI事件:`app/ai.py` 的 `_call_with_retry()` 支持 `on_retry` 回调,标题/图片请求失败但仍会重试时上报 `retry` 事件;不改 AI HTTP 协议、DB schema、Excel 或 Shopee/CDP 流程。
- 测试:`tests/test_gui.py` 覆盖双进度条初始状态、用户可读日志、重试日志脱敏和旧日志加载;`tests/test_ai.py` 覆盖标题生成重试事件上报。 - 测试:`tests/test_gui.py` 覆盖双进度条初始状态、用户可读日志、重试日志脱敏和旧日志加载;`tests/test_ai.py` 覆盖标题生成重试事件上报。
- 验证:`python -m py_compile app\ai.py app\gui.py tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_ai.py"` 通过(7 tests);`python -m unittest discover -s tests -p "test_gui.py"` 通过(68 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(156 tests)。Qt 仍有本机字体目录提示,不影响测试结果。 - 验证:`python -m py_compile app\ai.py app\gui.py tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_ai.py"` 通过(7 tests);`python -m unittest discover -s tests -p "test_gui.py"` 通过(68 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(156 tests)。Qt 仍有本机字体目录提示,不影响测试结果。
## 【2026-07-02】T-520 文档启动 · ②AI生成封面可选生成开关
- 背景:②AI生成当前默认标题和图片都生成,图片模型成本明显高于标题;用户只想改标题时也会产生图片生成成本。⑤「允许更新封面」只控制③线上提交阶段,不能替代②生成阶段的成本选择。
- 决策:新增 T-520。②AI生成页增加「生成封面图片(成本较高)」复选框,默认关闭并持久化到 `config.json` 的 `ai.generate_cover`;关闭时只生成标题,标题成功即 `db.set_generated(task_id, new_title, NULL)`,③只更新标题;开启时保持标题后图片两段流程。
- 文档:同步 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/routes.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`。本轮只更新文档,未改代码。
- 下一步:按 T-520 文档更新 `app/appconfig.py`、`app/ai.py`、`app/gui.py` 和相关单测。
## 【2026-07-02】T-520 完成 · ②AI生成封面可选生成开关
- 配置:`app/appconfig.py` 新增 `ai.generate_cover=false` 默认值;②AI生成页复选框状态会持久化到 `config.json`,设置页保存其他 AI 参数时保留该隐藏成本开关。
- 代码:`app/ai.py` 的 `generate_batch()` 按 `generate_cover` 分支执行;关闭时只生成标题,标题成功即 `db.set_generated(task_id, new_title, None)` 并进入 `generated`,不调用 `gen_cover()`、不渲染封面提示词、不创建新封面文件;开启时保留标题后图片两段流程。
- GUI:`app/gui.py` 在②底部增加「生成封面图片(成本较高)」复选框;开始生成前保存当前开关;标题-only 模式进度显示 `图片0/0`,运行日志写明“本轮仅生成标题,不生成图片”,完成统计使用 `generated_done`,避免只看 `cover_done`。
- 测试:`tests/test_appconfig.py` 覆盖默认关闭;`tests/test_ai.py` 覆盖跳过封面时不调用 `gen_cover` 且任务进入 generated;`tests/test_gui.py` 覆盖开关持久化、Worker 标题-only 日志/运行日志统计,以及旧图片路径显式开启开关。
- 验证:`python -m py_compile app\appconfig.py app\ai.py app\gui.py tests\test_appconfig.py tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_appconfig.py"` 通过(7 tests);`python -m unittest discover -s tests -p "test_ai.py"` 通过(8 tests);`python -m unittest discover -s tests -p "test_gui.py"` 通过(70 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(159 tests);`git diff --check` 无空白错误,仅 LF/CRLF 提示。Qt 仍有本机字体目录提示,不影响测试结果。
+55
View File
@@ -247,6 +247,7 @@ class AITests(TempDirMixin, unittest.TestCase):
cfg["image_dir"] = os.path.join(temp_dir, "images") cfg["image_dir"] = os.path.join(temp_dir, "images")
cfg["ai"]["title_concurrency"] = 2 cfg["ai"]["title_concurrency"] = 2
cfg["ai"]["image_concurrency"] = 2 cfg["ai"]["image_concurrency"] = 2
cfg["ai"]["generate_cover"] = True
batch_id, tasks = self._collected_tasks(temp_dir, cfg) batch_id, tasks = self._collected_tasks(temp_dir, cfg)
cover_prompts = [] cover_prompts = []
progress = [] progress = []
@@ -306,11 +307,65 @@ class AITests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_generate_batch_can_skip_cover_generation(self):
with self.make_temp_dir() as temp_dir:
cfg = self._config()
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
cfg["image_dir"] = os.path.join(temp_dir, "images")
batch_id, tasks = self._collected_tasks(temp_dir, cfg)
events = []
progress = []
def fake_title(title_prompt, old_title, **kwargs):
self.assertEqual("标题提示", title_prompt)
return "新" + old_title
with mock.patch("app.ai.gen_title", side_effect=fake_title), \
mock.patch("app.ai.gen_cover") as gen_cover:
summary = ai.generate_batch(
tasks,
{"title": "标题提示", "cover": "封面 {新标题}"},
ai_cfg={
"config": cfg,
"db_path": cfg["db_path"],
"on_event": events.append,
},
on_progress=progress.append,
)
self.assertTrue(summary["ok"])
self.assertFalse(summary["generate_cover"])
self.assertEqual(2, summary["title_done"])
self.assertEqual(0, summary["cover_done"])
self.assertEqual(0, summary["cover_total"])
self.assertEqual(2, summary["generated_done"])
self.assertEqual(0, summary["failed"])
gen_cover.assert_not_called()
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
self.assertTrue(all(task.stage == "generated" for task in updated))
self.assertTrue(all(task.status == "success" for task in updated))
self.assertEqual({"新旧标题A", "新旧标题B"}, {task.new_title for task in updated})
self.assertTrue(all(task.new_cover_path is None for task in updated))
self.assertEqual(0, progress[-1]["cover_total"])
self.assertEqual(2, progress[-1]["generated_done"])
self.assertTrue(
any(
event.get("phase") == "title"
and event.get("step") == "db_write"
and event.get("result") == "success"
and event.get("detail") == "仅生成标题"
for event in events
)
)
self.assert_removed(temp_dir)
def test_generate_batch_marks_failed_task_without_blocking_others(self): def test_generate_batch_marks_failed_task_without_blocking_others(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg = self._config() cfg = self._config()
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db") cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
cfg["image_dir"] = os.path.join(temp_dir, "images") cfg["image_dir"] = os.path.join(temp_dir, "images")
cfg["ai"]["generate_cover"] = True
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["好标题", "坏标题"]) batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["好标题", "坏标题"])
def fake_title(title_prompt, old_title, **kwargs): def fake_title(title_prompt, old_title, **kwargs):
+1
View File
@@ -19,6 +19,7 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assertTrue(os.path.exists(config_path)) self.assertTrue(os.path.exists(config_path))
self.assertEqual("images", appconfig.image_dir(config)) self.assertEqual("images", appconfig.image_dir(config))
self.assertEqual(240, appconfig.response_timeout(config)) self.assertEqual(240, appconfig.response_timeout(config))
self.assertFalse(appconfig.ai_config(config)["generate_cover"])
updated = appconfig.update_config( updated = appconfig.update_config(
{"ai": {"resolution": "2k"}}, {"ai": {"resolution": "2k"}},
+94 -2
View File
@@ -17,7 +17,7 @@ if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装") raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtGui import QTextCursor from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QApplication, QLineEdit, QPlainTextEdit, QProgressBar, QTableView from PySide6.QtWidgets import QApplication, QCheckBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
from app.gui import ( from app.gui import (
AccountDialog, AccountDialog,
@@ -437,6 +437,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
cfg["ai"] = appconfig.default_config()["ai"] cfg["ai"] = appconfig.default_config()["ai"]
cfg["ai"]["default_text_model"] = "Text A" cfg["ai"]["default_text_model"] = "Text A"
cfg["ai"]["default_image_model"] = "Image A" cfg["ai"]["default_image_model"] = "Image A"
cfg["ai"]["generate_cover"] = True
models_path = cfg["ai_models_path"] models_path = cfg["ai_models_path"]
config_path = cfg["config_path"] config_path = cfg["config_path"]
appconfig.save_ai_models_config( appconfig.save_ai_models_config(
@@ -548,6 +549,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
saved = appconfig.load_config(config_path) saved = appconfig.load_config(config_path)
self.assertEqual("Text B", saved["ai"]["default_text_model"]) self.assertEqual("Text B", saved["ai"]["default_text_model"])
self.assertEqual("Image B", saved["ai"]["default_image_model"]) self.assertEqual("Image B", saved["ai"]["default_image_model"])
self.assertTrue(saved["ai"]["generate_cover"])
self.assertEqual(3, saved["ai"]["title_concurrency"]) self.assertEqual(3, saved["ai"]["title_concurrency"])
self.assertEqual(2, saved["ai"]["image_concurrency"]) self.assertEqual(2, saved["ai"]["image_concurrency"])
self.assertEqual(1, saved["ai"]["retry"]) self.assertEqual(1, saved["ai"]["retry"])
@@ -688,14 +690,17 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsInstance(tab.title_prompt_edit, QPlainTextEdit) self.assertIsInstance(tab.title_prompt_edit, QPlainTextEdit)
self.assertIsInstance(tab.cover_prompt_edit, QPlainTextEdit) self.assertIsInstance(tab.cover_prompt_edit, QPlainTextEdit)
self.assertIsInstance(tab.task_table, QTableView) self.assertIsInstance(tab.task_table, QTableView)
self.assertIsInstance(tab.generate_cover_checkbox, QCheckBox)
self.assertEqual("标题提示词", tab.title_prompt_edit.placeholderText()) self.assertEqual("标题提示词", tab.title_prompt_edit.placeholderText())
self.assertEqual("封面提示词", tab.cover_prompt_edit.placeholderText()) self.assertEqual("封面提示词", tab.cover_prompt_edit.placeholderText())
self.assertEqual("保存标题提示词", tab.save_title_button.text()) self.assertEqual("保存标题提示词", tab.save_title_button.text())
self.assertEqual("开始生成", tab.generate_button.text()) self.assertEqual("开始生成", tab.generate_button.text())
self.assertEqual("停止", tab.stop_generate_button.text()) self.assertEqual("停止", tab.stop_generate_button.text())
self.assertEqual("重置生成结果", tab.reset_generate_button.text()) self.assertEqual("重置生成结果", tab.reset_generate_button.text())
self.assertEqual("生成封面图片(成本较高)", tab.generate_cover_checkbox.text())
self.assertFalse(tab.generate_cover_checkbox.isChecked())
self.assertFalse(tab.stop_generate_button.isEnabled()) self.assertFalse(tab.stop_generate_button.isEnabled())
self.assertEqual("进度:标题0/0 · 封面0/0 · 失败0", tab.progress_label.text()) self.assertEqual("进度:标题0/0 · 图片0/0 · 失败0", tab.progress_label.text())
self.assertIsInstance(tab.title_progress_bar, QProgressBar) self.assertIsInstance(tab.title_progress_bar, QProgressBar)
self.assertIsInstance(tab.cover_progress_bar, QProgressBar) self.assertIsInstance(tab.cover_progress_bar, QProgressBar)
self.assertEqual("标题 0/0", tab.title_progress_label.text()) self.assertEqual("标题 0/0", tab.title_progress_label.text())
@@ -720,6 +725,23 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_generate_tab_persists_generate_cover_toggle(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
statuses = []
tab = GenerateTab(config=cfg, status_callback=statuses.append)
self.addCleanup(tab.close)
self.assertFalse(appconfig.ai_config(cfg)["generate_cover"])
tab.generate_cover_checkbox.setChecked(True)
saved = appconfig.load_config(cfg["config_path"])
self.assertTrue(saved["ai"]["generate_cover"])
self.assertTrue(cfg["ai"]["generate_cover"])
self.assertIn("会同时生成封面图片", statuses[-1])
self.assert_removed(temp_dir)
def test_generate_tab_manages_prompt_files_and_preview(self): def test_generate_tab_manages_prompt_files_and_preview(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir) cfg = self.make_config(temp_dir)
@@ -893,6 +915,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
def test_generate_worker_calls_generate_batch_and_emits_signals(self): def test_generate_worker_calls_generate_batch_and_emits_signals(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir) cfg = self.make_config(temp_dir)
cfg["ai"] = appconfig.ai_config(cfg)
cfg["ai"]["generate_cover"] = True
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks( db.insert_tasks(
@@ -985,6 +1009,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
def test_generate_worker_writes_run_log_and_diagnostic_log_on_image_failure(self): def test_generate_worker_writes_run_log_and_diagnostic_log_on_image_failure(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir) cfg = self.make_config(temp_dir)
cfg["ai"] = appconfig.ai_config(cfg)
cfg["ai"]["generate_cover"] = True
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks( db.insert_tasks(
@@ -1058,6 +1084,72 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_generate_worker_can_generate_titles_without_covers(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"])
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
logs = []
progress = []
def fake_title(title_prompt, old_title, **kwargs):
return "新标题"
worker = GenerateWorker(
tasks,
{"title": "标题提示", "cover": "封面提示"},
db_path=cfg["db_path"],
config=cfg,
)
worker.log.connect(logs.append)
worker.progress.connect(progress.append)
with mock.patch("app.ai.gen_title", side_effect=fake_title), \
mock.patch("app.ai.gen_cover") as gen_cover:
summary = worker.execute()
self.assertTrue(summary["ok"])
self.assertFalse(summary["generate_cover"])
self.assertEqual(1, summary["title_done"])
self.assertEqual(0, summary["cover_done"])
self.assertEqual(0, summary["cover_total"])
self.assertEqual(1, summary["generated_done"])
self.assertEqual(0, summary["failed"])
gen_cover.assert_not_called()
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
self.assertEqual("generated", updated.stage)
self.assertEqual("success", updated.status)
self.assertEqual("新标题", updated.new_title)
self.assertIsNone(updated.new_cover_path)
self.assertEqual(0, progress[-1]["cover_total"])
self.assertEqual(1, progress[-1]["generated_done"])
joined_logs = "\n".join(logs)
self.assertIn("本轮仅生成标题,不生成图片", joined_logs)
self.assertIn("已保存,仅生成标题", joined_logs)
self.assertIn("[完成] AI 生成完成:标题1/1,图片0/0,失败0", joined_logs)
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
self.assertEqual(1, run_log.done)
self.assertEqual(1, run_log.success_count)
self.assertTrue(run_log.options["generate_cover"] is False)
self.assert_removed(temp_dir)
def test_generate_tab_loads_latest_generate_run_log(self): def test_generate_tab_loads_latest_generate_run_log(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir) cfg = self.make_config(temp_dir)