T-568 支持封面画廊单任务重生
This commit is contained in:
+239
-3
@@ -73,18 +73,40 @@ class CoverGalleryDialog(QDialog):
|
|||||||
THUMBNAIL_SIZE = 180
|
THUMBNAIL_SIZE = 180
|
||||||
OLD_THUMBNAIL_SIZE = 300
|
OLD_THUMBNAIL_SIZE = 300
|
||||||
|
|
||||||
def __init__(self, task, image_root, db_path, account=None, parent=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
task,
|
||||||
|
image_root,
|
||||||
|
db_path,
|
||||||
|
account=None,
|
||||||
|
parent=None,
|
||||||
|
config=None,
|
||||||
|
prompt_values=None,
|
||||||
|
diagnostic_log_dir=None,
|
||||||
|
bulk_running_checker=None,
|
||||||
|
regenerate_running_callback=None,
|
||||||
|
gallery_updated_callback=None,
|
||||||
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.task = task
|
self.task = task
|
||||||
self.image_root = image_root
|
self.image_root = image_root
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.account = account
|
self.account = account
|
||||||
|
self.config = config or {}
|
||||||
|
self.prompt_values = dict(prompt_values or {})
|
||||||
|
self.diagnostic_log_dir = diagnostic_log_dir
|
||||||
|
self.bulk_running_checker = bulk_running_checker
|
||||||
|
self.regenerate_running_callback = regenerate_running_callback
|
||||||
|
self.gallery_updated_callback = gallery_updated_callback
|
||||||
self.current_path = _normalize_file_path(getattr(task, "new_cover_path", None))
|
self.current_path = _normalize_file_path(getattr(task, "new_cover_path", None))
|
||||||
self.selected_path = None
|
self.selected_path = None
|
||||||
self.changed = False
|
self.changed = False
|
||||||
self.candidate_buttons = {}
|
self.candidate_buttons = {}
|
||||||
self.button_group = QButtonGroup(self)
|
self.button_group = QButtonGroup(self)
|
||||||
self.button_group.setExclusive(True)
|
self.button_group.setExclusive(True)
|
||||||
|
self.regenerate_worker = None
|
||||||
|
self.regenerate_thread = None
|
||||||
|
self._regenerate_last_error = ""
|
||||||
self.candidates = image_paths.list_task_cover_candidates(image_root, task, account=account)
|
self.candidates = image_paths.list_task_cover_candidates(image_root, task, account=account)
|
||||||
|
|
||||||
self.setWindowTitle(f"封面画廊:{getattr(task, 'item_id', '')}")
|
self.setWindowTitle(f"封面画廊:{getattr(task, 'item_id', '')}")
|
||||||
@@ -95,6 +117,9 @@ class CoverGalleryDialog(QDialog):
|
|||||||
layout.addLayout(body_layout, 1)
|
layout.addLayout(body_layout, 1)
|
||||||
|
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
|
self.regenerate_button = QPushButton("重新生成")
|
||||||
|
self.regenerate_button.clicked.connect(self.regenerate_cover)
|
||||||
|
button_layout.addWidget(self.regenerate_button)
|
||||||
button_layout.addStretch(1)
|
button_layout.addStretch(1)
|
||||||
self.save_button = QPushButton("保存")
|
self.save_button = QPushButton("保存")
|
||||||
self.cancel_button = QPushButton("取消")
|
self.cancel_button = QPushButton("取消")
|
||||||
@@ -106,6 +131,7 @@ class CoverGalleryDialog(QDialog):
|
|||||||
|
|
||||||
self._sync_initial_selection()
|
self._sync_initial_selection()
|
||||||
self._update_status()
|
self._update_status()
|
||||||
|
self._update_regenerate_button_state()
|
||||||
self._fit_to_screen()
|
self._fit_to_screen()
|
||||||
|
|
||||||
def _old_cover_panel(self):
|
def _old_cover_panel(self):
|
||||||
@@ -247,6 +273,67 @@ class CoverGalleryDialog(QDialog):
|
|||||||
self.accept()
|
self.accept()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def regenerate_cover(self, checked=False):
|
||||||
|
if self.regenerate_thread is not None:
|
||||||
|
return False
|
||||||
|
if self._bulk_generate_running():
|
||||||
|
self.status_label.setText("②批量生成正在运行,完成后才能重新生成单个封面")
|
||||||
|
self._update_regenerate_button_state()
|
||||||
|
return False
|
||||||
|
if not str(getattr(self.task, "new_title", "") or "").strip():
|
||||||
|
QMessageBox.warning(self, "重新生成封面", "当前任务缺少新标题,不能单独重新生成封面")
|
||||||
|
return False
|
||||||
|
if not self._confirm_regenerate():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
db.reset_generated(
|
||||||
|
self.task.id,
|
||||||
|
reset_title=False,
|
||||||
|
reset_cover=True,
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
fresh_task = db.get_task(self.task.id, path=self.db_path)
|
||||||
|
except Exception as exc:
|
||||||
|
QMessageBox.warning(self, "重新生成封面", str(exc))
|
||||||
|
self.status_label.setText(f"重新生成封面失败:{exc}")
|
||||||
|
return False
|
||||||
|
if fresh_task is None:
|
||||||
|
QMessageBox.warning(self, "重新生成封面", "任务不存在或已删除")
|
||||||
|
self.status_label.setText("重新生成封面失败:任务不存在或已删除")
|
||||||
|
return False
|
||||||
|
self._reload_task_and_candidates(fresh_task)
|
||||||
|
self.status_label.setText(f"开始重新生成商品 {getattr(fresh_task, 'item_id', '')} 的封面图片")
|
||||||
|
worker = GenerateWorker(
|
||||||
|
[fresh_task],
|
||||||
|
self.prompt_values,
|
||||||
|
db_path=self.db_path,
|
||||||
|
config=_cover_regenerate_config(self.config),
|
||||||
|
diagnostic_log_dir=self.diagnostic_log_dir,
|
||||||
|
)
|
||||||
|
worker.progress.connect(self._on_regenerate_progress)
|
||||||
|
worker.row_updated.connect(self._on_regenerate_row_updated)
|
||||||
|
worker.log.connect(self._on_regenerate_log)
|
||||||
|
worker.failed.connect(self._on_regenerate_failed)
|
||||||
|
worker.finished.connect(self._on_regenerate_finished)
|
||||||
|
worker.cancelled.connect(self._on_regenerate_cancelled)
|
||||||
|
thread = run_worker(worker, thread_name="CoverRegenerateWorker", start=False)
|
||||||
|
thread.finished.connect(lambda: self._forget_regenerate_thread(thread))
|
||||||
|
self.regenerate_worker = worker
|
||||||
|
self.regenerate_thread = thread
|
||||||
|
self._set_regenerate_running(True)
|
||||||
|
thread.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _confirm_regenerate(self):
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setWindowTitle("重新生成封面")
|
||||||
|
box.setText("将重新生成当前商品封面,可能消耗 cmhub 点数。")
|
||||||
|
confirm_button = box.addButton("重新生成", QMessageBox.AcceptRole)
|
||||||
|
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||||||
|
box.setDefaultButton(cancel_button)
|
||||||
|
box.exec()
|
||||||
|
return box.clickedButton() is confirm_button
|
||||||
|
|
||||||
def _needs_committed_confirmation(self):
|
def _needs_committed_confirmation(self):
|
||||||
return int(getattr(self.task, "committed", 0) or 0) == 1 or getattr(self.task, "stage", None) == "applied"
|
return int(getattr(self.task, "committed", 0) or 0) == 1 or getattr(self.task, "stage", None) == "applied"
|
||||||
|
|
||||||
@@ -266,6 +353,105 @@ class CoverGalleryDialog(QDialog):
|
|||||||
dialog = OriginalImageDialog(image_path, self)
|
dialog = OriginalImageDialog(image_path, self)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
|
||||||
|
def _bulk_generate_running(self):
|
||||||
|
return bool(self.bulk_running_checker and self.bulk_running_checker())
|
||||||
|
|
||||||
|
def _set_regenerate_running(self, running):
|
||||||
|
self.save_button.setEnabled(not running and self.selected_path is not None)
|
||||||
|
self.regenerate_button.setEnabled(not running and not self._bulk_generate_running())
|
||||||
|
self.regenerate_button.setToolTip(
|
||||||
|
"②批量生成正在运行,完成后才能重新生成单个封面"
|
||||||
|
if self._bulk_generate_running()
|
||||||
|
else ("正在重新生成当前商品封面" if running else "")
|
||||||
|
)
|
||||||
|
if self.regenerate_running_callback is not None:
|
||||||
|
self.regenerate_running_callback(bool(running))
|
||||||
|
|
||||||
|
def _update_regenerate_button_state(self):
|
||||||
|
running = self.regenerate_thread is not None
|
||||||
|
self.regenerate_button.setEnabled(not running and not self._bulk_generate_running())
|
||||||
|
self.regenerate_button.setToolTip(
|
||||||
|
"②批量生成正在运行,完成后才能重新生成单个封面"
|
||||||
|
if self._bulk_generate_running()
|
||||||
|
else ("正在重新生成当前商品封面" if running else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reload_task_and_candidates(self, task=None):
|
||||||
|
if task is not None:
|
||||||
|
self.task = task
|
||||||
|
self.current_path = _normalize_file_path(getattr(self.task, "new_cover_path", None))
|
||||||
|
self.selected_path = None
|
||||||
|
self.candidates = image_paths.list_task_cover_candidates(self.image_root, self.task, account=self.account)
|
||||||
|
self._rebuild_candidate_items()
|
||||||
|
self._sync_initial_selection()
|
||||||
|
self._update_status()
|
||||||
|
|
||||||
|
def _rebuild_candidate_items(self):
|
||||||
|
_clear_layout(self.candidate_layout)
|
||||||
|
self.candidate_buttons = {}
|
||||||
|
self.button_group = QButtonGroup(self)
|
||||||
|
self.button_group.setExclusive(True)
|
||||||
|
if self.candidates:
|
||||||
|
for index, candidate_path in enumerate(self.candidates):
|
||||||
|
self.candidate_layout.addWidget(self._candidate_item(candidate_path, index))
|
||||||
|
self.candidate_layout.addStretch(1)
|
||||||
|
else:
|
||||||
|
empty_label = QLabel("暂无生成封面图片")
|
||||||
|
empty_label.setObjectName("coverGalleryEmptyLabel")
|
||||||
|
empty_label.setMinimumSize(320, self.THUMBNAIL_SIZE)
|
||||||
|
empty_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self.candidate_layout.addWidget(empty_label)
|
||||||
|
|
||||||
|
def _on_regenerate_progress(self, payload):
|
||||||
|
if payload.get("failed"):
|
||||||
|
self.status_label.setText("封面重新生成失败,历史候选仍可选择保存")
|
||||||
|
|
||||||
|
def _on_regenerate_row_updated(self, task_id, fields):
|
||||||
|
if self.gallery_updated_callback is not None:
|
||||||
|
self.gallery_updated_callback(self.task)
|
||||||
|
|
||||||
|
def _on_regenerate_log(self, message):
|
||||||
|
text = str(message or "").strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
if text.startswith("[失败]"):
|
||||||
|
self._regenerate_last_error = text
|
||||||
|
self.status_label.setText(text)
|
||||||
|
elif "成功" in text or "完成" in text:
|
||||||
|
self.status_label.setText(text)
|
||||||
|
|
||||||
|
def _on_regenerate_failed(self, task_id, error):
|
||||||
|
self._regenerate_last_error = str(error or "未知错误")
|
||||||
|
self.status_label.setText(f"封面重新生成失败:{self._regenerate_last_error}")
|
||||||
|
|
||||||
|
def _on_regenerate_finished(self, summary):
|
||||||
|
self._finish_regenerate(summary, cancelled=False)
|
||||||
|
|
||||||
|
def _on_regenerate_cancelled(self, summary):
|
||||||
|
self._finish_regenerate(summary, cancelled=True)
|
||||||
|
|
||||||
|
def _finish_regenerate(self, summary, cancelled=False):
|
||||||
|
fresh_task = db.get_task(self.task.id, path=self.db_path)
|
||||||
|
if fresh_task is not None:
|
||||||
|
self._reload_task_and_candidates(fresh_task)
|
||||||
|
self._set_regenerate_running(False)
|
||||||
|
if self.gallery_updated_callback is not None:
|
||||||
|
self.gallery_updated_callback(self.task)
|
||||||
|
if cancelled or summary.get("cancelled"):
|
||||||
|
self.status_label.setText("封面重新生成已停止,历史候选仍可选择保存")
|
||||||
|
return
|
||||||
|
if summary.get("ok") and summary.get("cover_done", 0) > 0:
|
||||||
|
self.status_label.setText("封面重新生成完成")
|
||||||
|
return
|
||||||
|
detail = self._regenerate_last_error or summary.get("error") or "请稍后重试"
|
||||||
|
self.status_label.setText(f"封面重新生成失败:{detail}")
|
||||||
|
|
||||||
|
def _forget_regenerate_thread(self, thread):
|
||||||
|
if self.regenerate_thread is thread:
|
||||||
|
self.regenerate_thread = None
|
||||||
|
self.regenerate_worker = None
|
||||||
|
self._set_regenerate_running(False)
|
||||||
|
|
||||||
def _fit_to_screen(self):
|
def _fit_to_screen(self):
|
||||||
available = _available_geometry()
|
available = _available_geometry()
|
||||||
if available is None:
|
if available is None:
|
||||||
@@ -281,6 +467,23 @@ def _load_image(image_path):
|
|||||||
return QImage(str(image_path or ""))
|
return QImage(str(image_path or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_layout(layout):
|
||||||
|
while layout.count():
|
||||||
|
item = layout.takeAt(0)
|
||||||
|
widget = item.widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.setParent(None)
|
||||||
|
widget.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_regenerate_config(config):
|
||||||
|
runtime_config = dict(config or {})
|
||||||
|
ai_settings = appconfig.ai_config(runtime_config)
|
||||||
|
ai_settings["generate_cover"] = True
|
||||||
|
runtime_config["ai"] = ai_settings
|
||||||
|
return runtime_config
|
||||||
|
|
||||||
|
|
||||||
def _build_image_label(image_path, size, empty_text):
|
def _build_image_label(image_path, size, empty_text):
|
||||||
label = QLabel()
|
label = QLabel()
|
||||||
label.setAlignment(Qt.AlignCenter)
|
label.setAlignment(Qt.AlignCenter)
|
||||||
@@ -391,6 +594,8 @@ class GenerateTab(QWidget):
|
|||||||
self.current_cover_template = None
|
self.current_cover_template = None
|
||||||
self.generate_worker = None
|
self.generate_worker = None
|
||||||
self.generate_thread = None
|
self.generate_thread = None
|
||||||
|
self.generate_running = False
|
||||||
|
self.cover_gallery_regenerate_running = False
|
||||||
self._title_elapsed_start = None
|
self._title_elapsed_start = None
|
||||||
self._cover_elapsed_start = None
|
self._cover_elapsed_start = None
|
||||||
self._title_elapsed_seconds = 0
|
self._title_elapsed_seconds = 0
|
||||||
@@ -948,6 +1153,9 @@ class GenerateTab(QWidget):
|
|||||||
self._set_status("封面提示词预览已生成")
|
self._set_status("封面提示词预览已生成")
|
||||||
|
|
||||||
def start_generate(self, checked=False):
|
def start_generate(self, checked=False):
|
||||||
|
if self.cover_gallery_regenerate_running:
|
||||||
|
self._set_status("封面画廊正在重新生成,完成后再开始批量生成")
|
||||||
|
return
|
||||||
if self.generate_thread is not None:
|
if self.generate_thread is not None:
|
||||||
self._set_status("AI 生成正在进行...")
|
self._set_status("AI 生成正在进行...")
|
||||||
return
|
return
|
||||||
@@ -1219,6 +1427,15 @@ class GenerateTab(QWidget):
|
|||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
account=account,
|
account=account,
|
||||||
parent=self,
|
parent=self,
|
||||||
|
config=self.config,
|
||||||
|
prompt_values={
|
||||||
|
"title": self.title_prompt_edit.toPlainText(),
|
||||||
|
"cover": self.cover_prompt_edit.toPlainText(),
|
||||||
|
},
|
||||||
|
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
||||||
|
bulk_running_checker=lambda: self.generate_thread is not None,
|
||||||
|
regenerate_running_callback=self._set_cover_gallery_regenerate_running,
|
||||||
|
gallery_updated_callback=self._on_cover_gallery_updated,
|
||||||
)
|
)
|
||||||
if dialog.exec() == QDialog.Accepted:
|
if dialog.exec() == QDialog.Accepted:
|
||||||
self.refresh_tasks()
|
self.refresh_tasks()
|
||||||
@@ -1228,9 +1445,10 @@ class GenerateTab(QWidget):
|
|||||||
self._set_status(f"当前新封面未变更:{task.item_id}")
|
self._set_status(f"当前新封面未变更:{task.item_id}")
|
||||||
|
|
||||||
def _set_generate_running(self, running):
|
def _set_generate_running(self, running):
|
||||||
self.generate_button.setEnabled(not running)
|
self.generate_running = bool(running)
|
||||||
|
self.generate_button.setEnabled(not running and not self.cover_gallery_regenerate_running)
|
||||||
self.stop_generate_button.setEnabled(running)
|
self.stop_generate_button.setEnabled(running)
|
||||||
self.reset_generate_button.setEnabled(not running)
|
self.reset_generate_button.setEnabled(not running and not self.cover_gallery_regenerate_running)
|
||||||
self.refresh_button.setEnabled(not running)
|
self.refresh_button.setEnabled(not running)
|
||||||
self.batch_filter.setEnabled(not running)
|
self.batch_filter.setEnabled(not running)
|
||||||
self.shop_filter.setEnabled(not running)
|
self.shop_filter.setEnabled(not running)
|
||||||
@@ -1244,6 +1462,24 @@ class GenerateTab(QWidget):
|
|||||||
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)
|
self.generate_cover_checkbox.setEnabled(not running)
|
||||||
|
self._sync_cover_gallery_regenerate_guard()
|
||||||
|
|
||||||
|
def _set_cover_gallery_regenerate_running(self, running):
|
||||||
|
self.cover_gallery_regenerate_running = bool(running)
|
||||||
|
self._sync_cover_gallery_regenerate_guard()
|
||||||
|
|
||||||
|
def _sync_cover_gallery_regenerate_guard(self):
|
||||||
|
if self.generate_thread is not None or self.generate_running:
|
||||||
|
return
|
||||||
|
blocked = bool(self.cover_gallery_regenerate_running)
|
||||||
|
self.generate_button.setEnabled(not blocked)
|
||||||
|
self.reset_generate_button.setEnabled(not blocked)
|
||||||
|
tooltip = "封面画廊正在重新生成,完成后再开始批量生成" if blocked else ""
|
||||||
|
self.generate_button.setToolTip(tooltip)
|
||||||
|
self.reset_generate_button.setToolTip(tooltip)
|
||||||
|
|
||||||
|
def _on_cover_gallery_updated(self, task):
|
||||||
|
self.refresh_tasks()
|
||||||
|
|
||||||
def _forget_generate_thread(self, thread):
|
def _forget_generate_thread(self, thread):
|
||||||
if self.generate_thread is thread:
|
if self.generate_thread is thread:
|
||||||
|
|||||||
+17
-2
@@ -3,7 +3,7 @@ id: T-568
|
|||||||
title: ②封面画廊单任务重新生成与并发守卫
|
title: ②封面画廊单任务重新生成与并发守卫
|
||||||
phase: 7
|
phase: 7
|
||||||
deps: [T-567]
|
deps: [T-567]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-09
|
created: 2026-07-09
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,4 +73,19 @@ T-567 只提供候选封面查看与保存。运营在对比时仍需要对当
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)
|
- 2026-07-09 完成 T-568。
|
||||||
|
- 修改 `app/gui/tabs/generate.py`:
|
||||||
|
- 在 T-567 封面画廊底部新增「重新生成」按钮;点击后先弹中文确认,说明会重新生成当前商品封面并可能消耗 cmhub 点数。
|
||||||
|
- 确认后调用 `db.reset_generated(task_id, reset_title=False, reset_cover=True)`,由 T-566 归档当前新封面并清空封面指针;随后重新 `db.get_task()` 获取 fresh task,再启动单任务 `GenerateWorker([fresh_task])`,避免旧 task 残留 `new_cover_path` 导致跳过生图。
|
||||||
|
- 单任务重生使用当前②页面传入的标题/封面提示词和配置,但只在 worker 配置副本中强制 `ai.generate_cover=True`,不保存到设置,确保只补缺失封面、不重生标题。
|
||||||
|
- worker 完成后刷新②列表与画廊候选;成功时新 `_new.jpg` 默认选中;失败时保留已归档旧图并允许选择历史候选保存;归档改名失败时弹中文错误并不启动 worker。
|
||||||
|
- 增加并发守卫:②批量生成运行中时画廊「重新生成」禁用并显示 tooltip;画廊单任务重生运行中时②主界面「开始生成」和「重置生成结果」禁用/阻断。
|
||||||
|
- 修改 `tests/test_gui.py`:
|
||||||
|
- 用 fake `GenerateWorker`/thread 覆盖重新生成确认取消、fresh task 启动、临时强制 `generate_cover=True`、成功刷新并选中新图、失败后历史候选可保存、归档失败不启动 worker、批量生成与单任务重生互斥、主界面启动阻断。
|
||||||
|
- 验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui`
|
||||||
|
- `py -3.10 -m unittest tests.test_gui tests.test_db`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`(297 tests;PySide6 字体目录警告不影响结果)
|
||||||
|
- `git diff --check`
|
||||||
|
|||||||
@@ -43,6 +43,45 @@ from app.gui.tabs.generate import CoverGalleryDialog, OriginalImageDialog
|
|||||||
from app.gui.main_window import _fit_and_center_window
|
from app.gui.main_window import _fit_and_center_window
|
||||||
|
|
||||||
|
|
||||||
|
class DummySignal:
|
||||||
|
def __init__(self):
|
||||||
|
self.callbacks = []
|
||||||
|
|
||||||
|
def connect(self, callback):
|
||||||
|
self.callbacks.append(callback)
|
||||||
|
|
||||||
|
def emit(self, *args):
|
||||||
|
for callback in list(self.callbacks):
|
||||||
|
callback(*args)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeThread:
|
||||||
|
def __init__(self):
|
||||||
|
self.finished = DummySignal()
|
||||||
|
self.started = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGenerateWorker:
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(self, tasks, prompt_values, db_path=None, config=None, diagnostic_log_dir=None):
|
||||||
|
self.tasks = list(tasks)
|
||||||
|
self.prompt_values = dict(prompt_values or {})
|
||||||
|
self.db_path = db_path
|
||||||
|
self.config = config
|
||||||
|
self.diagnostic_log_dir = diagnostic_log_dir
|
||||||
|
self.progress = DummySignal()
|
||||||
|
self.row_updated = DummySignal()
|
||||||
|
self.log = DummySignal()
|
||||||
|
self.failed = DummySignal()
|
||||||
|
self.finished = DummySignal()
|
||||||
|
self.cancelled = DummySignal()
|
||||||
|
FakeGenerateWorker.instances.append(self)
|
||||||
|
|
||||||
|
|
||||||
class GuiTests(TempDirMixin, unittest.TestCase):
|
class GuiTests(TempDirMixin, unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
@@ -89,6 +128,34 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
self.assertTrue(image.save(path))
|
self.assertTrue(image.save(path))
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def _cover_gallery_task(self, temp_dir, generate_cover=True):
|
||||||
|
cfg = self.make_config(temp_dir)
|
||||||
|
cfg.setdefault("ai", {})["generate_cover"] = bool(generate_cover)
|
||||||
|
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||||||
|
db.init_db(cfg["db_path"])
|
||||||
|
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||||
|
db.insert_tasks(
|
||||||
|
batch_id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "商品",
|
||||||
|
"source_row": 2,
|
||||||
|
"account_name": "Excel主店",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"item_id": "51100639510",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
path=cfg["db_path"],
|
||||||
|
)
|
||||||
|
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||||
|
old_cover = self.write_test_image(os.path.join(temp_dir, "old.jpg"))
|
||||||
|
canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new")
|
||||||
|
self.write_test_image(canonical)
|
||||||
|
db.set_collected(task.id, "旧标题", old_cover, path=cfg["db_path"])
|
||||||
|
db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"])
|
||||||
|
return cfg, account, db.get_task(task.id, path=cfg["db_path"]), canonical
|
||||||
|
|
||||||
def assert_foreground(self, model, row, column, color):
|
def assert_foreground(self, model, row, column, color):
|
||||||
value = model.data(model.index(row, column), gui.Qt.ForegroundRole)
|
value = model.data(model.index(row, column), gui.Qt.ForegroundRole)
|
||||||
self.assertIsNotNone(value)
|
self.assertIsNotNone(value)
|
||||||
@@ -141,6 +208,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
def clickedButton(self):
|
def clickedButton(self):
|
||||||
return self.buttons[selected_label]
|
return self.buttons[selected_label]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def warning(parent, title, text):
|
||||||
|
box = FakeMessageBox(parent)
|
||||||
|
box.setWindowTitle(title)
|
||||||
|
box.setText(text)
|
||||||
|
return 0
|
||||||
|
|
||||||
return FakeMessageBox, boxes
|
return FakeMessageBox, boxes
|
||||||
|
|
||||||
def test_collect_table_shows_product_unavailable_only_for_explicit_error(self):
|
def test_collect_table_shows_product_unavailable_only_for_explicit_error(self):
|
||||||
@@ -2269,6 +2343,203 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cover_gallery_regenerate_cancel_has_no_side_effect(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||||||
|
dialog = CoverGalleryDialog(
|
||||||
|
task,
|
||||||
|
cfg["image_dir"],
|
||||||
|
cfg["db_path"],
|
||||||
|
account=account,
|
||||||
|
config=cfg,
|
||||||
|
prompt_values={"title": "标题", "cover": "封面"},
|
||||||
|
)
|
||||||
|
self.addCleanup(dialog.close)
|
||||||
|
message_box, _ = self.make_fake_message_box("取消")
|
||||||
|
FakeGenerateWorker.instances.clear()
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \
|
||||||
|
mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker):
|
||||||
|
self.assertFalse(dialog.regenerate_cover())
|
||||||
|
|
||||||
|
self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path)
|
||||||
|
self.assertTrue(os.path.exists(canonical))
|
||||||
|
self.assertEqual([], FakeGenerateWorker.instances)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cover_gallery_regenerate_uses_fresh_task_and_forces_cover_generation(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, account, task, canonical = self._cover_gallery_task(temp_dir, generate_cover=False)
|
||||||
|
fake_thread = FakeThread()
|
||||||
|
running_states = []
|
||||||
|
dialog = CoverGalleryDialog(
|
||||||
|
task,
|
||||||
|
cfg["image_dir"],
|
||||||
|
cfg["db_path"],
|
||||||
|
account=account,
|
||||||
|
config=cfg,
|
||||||
|
prompt_values={"title": "标题提示", "cover": "封面提示 {新标题}"},
|
||||||
|
regenerate_running_callback=running_states.append,
|
||||||
|
)
|
||||||
|
self.addCleanup(dialog.close)
|
||||||
|
message_box, _ = self.make_fake_message_box("重新生成")
|
||||||
|
FakeGenerateWorker.instances.clear()
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \
|
||||||
|
mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker), \
|
||||||
|
mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread):
|
||||||
|
self.assertTrue(dialog.regenerate_cover())
|
||||||
|
|
||||||
|
self.assertTrue(fake_thread.started)
|
||||||
|
self.assertEqual([True], running_states)
|
||||||
|
worker = FakeGenerateWorker.instances[0]
|
||||||
|
self.assertIsNone(worker.tasks[0].new_cover_path)
|
||||||
|
self.assertEqual("新标题", worker.tasks[0].new_title)
|
||||||
|
self.assertTrue(worker.config["ai"]["generate_cover"])
|
||||||
|
self.assertFalse(appconfig.ai_config(cfg).get("generate_cover", False))
|
||||||
|
self.assertFalse(os.path.exists(canonical))
|
||||||
|
self.assertTrue(any(path.endswith(".jpg") for path in dialog.candidates))
|
||||||
|
self.assertIn("开始重新生成商品", dialog.status_label.text())
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cover_gallery_regenerate_success_refreshes_candidates_and_current_selection(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||||||
|
fake_thread = FakeThread()
|
||||||
|
refreshed = []
|
||||||
|
dialog = CoverGalleryDialog(
|
||||||
|
task,
|
||||||
|
cfg["image_dir"],
|
||||||
|
cfg["db_path"],
|
||||||
|
account=account,
|
||||||
|
config=cfg,
|
||||||
|
prompt_values={"title": "标题", "cover": "封面"},
|
||||||
|
regenerate_running_callback=lambda running: None,
|
||||||
|
gallery_updated_callback=lambda task: refreshed.append(task.id),
|
||||||
|
)
|
||||||
|
self.addCleanup(dialog.close)
|
||||||
|
message_box, _ = self.make_fake_message_box("重新生成")
|
||||||
|
FakeGenerateWorker.instances.clear()
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \
|
||||||
|
mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker), \
|
||||||
|
mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread):
|
||||||
|
self.assertTrue(dialog.regenerate_cover())
|
||||||
|
worker = FakeGenerateWorker.instances[0]
|
||||||
|
self.write_test_image(canonical)
|
||||||
|
db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"])
|
||||||
|
|
||||||
|
worker.finished.emit({"ok": True, "cover_done": 1, "failed": 0})
|
||||||
|
|
||||||
|
self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path)
|
||||||
|
self.assertEqual(canonical, dialog.selected_path)
|
||||||
|
self.assertTrue(dialog.candidate_buttons[canonical].isChecked())
|
||||||
|
self.assertEqual("封面重新生成完成", dialog.status_label.text())
|
||||||
|
self.assertIn(task.id, refreshed)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cover_gallery_regenerate_failure_keeps_archived_candidate_selectable(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||||||
|
fake_thread = FakeThread()
|
||||||
|
dialog = CoverGalleryDialog(
|
||||||
|
task,
|
||||||
|
cfg["image_dir"],
|
||||||
|
cfg["db_path"],
|
||||||
|
account=account,
|
||||||
|
config=cfg,
|
||||||
|
prompt_values={"title": "标题", "cover": "封面"},
|
||||||
|
)
|
||||||
|
self.addCleanup(dialog.close)
|
||||||
|
message_box, _ = self.make_fake_message_box("重新生成")
|
||||||
|
FakeGenerateWorker.instances.clear()
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \
|
||||||
|
mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker), \
|
||||||
|
mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread):
|
||||||
|
self.assertTrue(dialog.regenerate_cover())
|
||||||
|
worker = FakeGenerateWorker.instances[0]
|
||||||
|
worker.log.emit("[失败] 商品 51100639510 图片生成失败:测试失败")
|
||||||
|
worker.finished.emit({"ok": False, "cover_done": 0, "failed": 1})
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists(canonical))
|
||||||
|
self.assertIsNone(db.get_task(task.id, path=cfg["db_path"]).new_cover_path)
|
||||||
|
self.assertTrue(dialog.candidates)
|
||||||
|
archived = dialog.candidates[0]
|
||||||
|
dialog.candidate_buttons[archived].setChecked(True)
|
||||||
|
self.assertTrue(dialog.save_selection())
|
||||||
|
self.assertEqual(os.path.abspath(archived), db.get_task(task.id, path=cfg["db_path"]).new_cover_path)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cover_gallery_regenerate_archive_failure_does_not_start_worker(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||||||
|
dialog = CoverGalleryDialog(
|
||||||
|
task,
|
||||||
|
cfg["image_dir"],
|
||||||
|
cfg["db_path"],
|
||||||
|
account=account,
|
||||||
|
config=cfg,
|
||||||
|
prompt_values={"title": "标题", "cover": "封面"},
|
||||||
|
)
|
||||||
|
self.addCleanup(dialog.close)
|
||||||
|
message_box, boxes = self.make_fake_message_box("重新生成")
|
||||||
|
FakeGenerateWorker.instances.clear()
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \
|
||||||
|
mock.patch("app.db.os.rename", side_effect=PermissionError), \
|
||||||
|
mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker):
|
||||||
|
self.assertFalse(dialog.regenerate_cover())
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists(canonical))
|
||||||
|
self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path)
|
||||||
|
self.assertEqual([], FakeGenerateWorker.instances)
|
||||||
|
self.assertIn("请先关闭正在查看的封面图片再重置", boxes[-1].text)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_cover_gallery_regenerate_disabled_while_bulk_generation_runs(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, account, task, canonical = self._cover_gallery_task(temp_dir)
|
||||||
|
dialog = CoverGalleryDialog(
|
||||||
|
task,
|
||||||
|
cfg["image_dir"],
|
||||||
|
cfg["db_path"],
|
||||||
|
account=account,
|
||||||
|
config=cfg,
|
||||||
|
prompt_values={"title": "标题", "cover": "封面"},
|
||||||
|
bulk_running_checker=lambda: True,
|
||||||
|
)
|
||||||
|
self.addCleanup(dialog.close)
|
||||||
|
|
||||||
|
self.assertFalse(dialog.regenerate_button.isEnabled())
|
||||||
|
self.assertIn("批量生成正在运行", dialog.regenerate_button.toolTip())
|
||||||
|
self.assertFalse(dialog.regenerate_cover())
|
||||||
|
self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_generate_tab_blocks_batch_generate_while_gallery_regenerates(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)
|
||||||
|
|
||||||
|
tab._set_cover_gallery_regenerate_running(True)
|
||||||
|
tab.start_generate()
|
||||||
|
|
||||||
|
self.assertFalse(tab.generate_button.isEnabled())
|
||||||
|
self.assertIn("封面画廊正在重新生成", statuses[-1])
|
||||||
|
tab._set_cover_gallery_regenerate_running(False)
|
||||||
|
self.assertTrue(tab.generate_button.isEnabled())
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
def test_generate_tab_allows_editing_generated_title_locally(self):
|
def test_generate_tab_allows_editing_generated_title_locally(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
|||||||
Reference in New Issue
Block a user