feat: add optional cover generation toggle
This commit is contained in:
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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`,端口冲突直接阻断。
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+7
-7
@@ -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
@@ -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 仍有本机字体目录提示,不影响测试结果。
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user