From a391d2db99e3d4be12b3140eef70a2ce51d96516 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 9 Jul 2026 11:32:08 +0800 Subject: [PATCH] =?UTF-8?q?T-567=20=E5=A2=9E=E5=8A=A0=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E5=80=99=E9=80=89=E7=94=BB=E5=BB=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/gui/tabs/generate.py | 387 +++++++++++++++++++++++++++++++++++---- app/gui/widgets.py | 4 +- docs/tasks/T-567.md | 19 +- tests/test_gui.py | 205 ++++++++++++++++++++- 4 files changed, 573 insertions(+), 42 deletions(-) diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index ae3a7d4..001f755 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -10,6 +10,343 @@ from ..workers import GenerateWorker as _RealGenerateWorker def GenerateWorker(*args, **kwargs): return _call_package_attr("GenerateWorker", _RealGenerateWorker, *args, **kwargs) + +class _CoverThumbnailLabel(QLabel): + def __init__(self, image_path, open_callback, parent=None): + super().__init__(parent) + self.image_path = image_path + self.open_callback = open_callback + self.setCursor(Qt.PointingHandCursor) + + def mouseDoubleClickEvent(self, event): + if self.open_callback is not None: + self.open_callback(self.image_path) + event.accept() + return + super().mouseDoubleClickEvent(event) + + +class OriginalImageDialog(QDialog): + def __init__(self, image_path, parent=None): + super().__init__(parent) + self.image_path = image_path + self.setWindowTitle(os.path.basename(str(image_path or "")) or "原图") + layout = QVBoxLayout(self) + image = QImage(str(image_path)) + scroll = QScrollArea() + scroll.setWidgetResizable(False) + image_label = QLabel() + image_label.setAlignment(Qt.AlignCenter) + if image.isNull(): + image_label.setText("图片读取失败") + else: + self.setWindowTitle( + f"{os.path.basename(str(image_path))} · {image.width()}x{image.height()}" + ) + pixmap = QPixmap.fromImage(image) + image_label.setPixmap(pixmap) + image_label.resize(pixmap.size()) + scroll.setWidget(image_label) + layout.addWidget(scroll, 1) + button_layout = QHBoxLayout() + button_layout.addStretch(1) + close_button = QPushButton("关闭") + close_button.clicked.connect(self.reject) + button_layout.addWidget(close_button) + layout.addLayout(button_layout) + self._fit_to_screen(image) + + def _fit_to_screen(self, image): + available = _available_geometry() + if available is None: + self.resize(720, 520) + return + max_width = max(360, int(available.width() * 0.9)) + max_height = max(300, int(available.height() * 0.9)) + image_width = image.width() if not image.isNull() else 640 + image_height = image.height() if not image.isNull() else 480 + self.resize(min(max_width, image_width + 48), min(max_height, image_height + 92)) + _center_dialog(self, available) + + +class CoverGalleryDialog(QDialog): + THUMBNAIL_SIZE = 180 + OLD_THUMBNAIL_SIZE = 300 + + def __init__(self, task, image_root, db_path, account=None, parent=None): + super().__init__(parent) + self.task = task + self.image_root = image_root + self.db_path = db_path + self.account = account + self.current_path = _normalize_file_path(getattr(task, "new_cover_path", None)) + self.selected_path = None + self.changed = False + self.candidate_buttons = {} + self.button_group = QButtonGroup(self) + self.button_group.setExclusive(True) + self.candidates = image_paths.list_task_cover_candidates(image_root, task, account=account) + + self.setWindowTitle(f"封面画廊:{getattr(task, 'item_id', '')}") + layout = QVBoxLayout(self) + body_layout = QHBoxLayout() + body_layout.addWidget(self._old_cover_panel(), 0) + body_layout.addWidget(self._candidate_panel(), 1) + layout.addLayout(body_layout, 1) + + button_layout = QHBoxLayout() + button_layout.addStretch(1) + self.save_button = QPushButton("保存") + self.cancel_button = QPushButton("取消") + self.save_button.clicked.connect(self.save_selection) + self.cancel_button.clicked.connect(self.reject) + button_layout.addWidget(self.save_button) + button_layout.addWidget(self.cancel_button) + layout.addLayout(button_layout) + + self._sync_initial_selection() + self._update_status() + self._fit_to_screen() + + def _old_cover_panel(self): + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("旧封面")) + old_path = getattr(self.task, "old_cover_path", None) + layout.addWidget( + _build_image_label( + old_path, + self.OLD_THUMBNAIL_SIZE, + empty_text="暂无旧封面", + ) + ) + layout.addStretch(1) + return panel + + def _candidate_panel(self): + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("生成封面候选")) + self.status_label = QLabel() + self.status_label.setObjectName("coverGalleryStatusLabel") + self.status_label.setWordWrap(True) + layout.addWidget(self.status_label) + + scroll = QScrollArea() + scroll.setObjectName("coverGalleryScrollArea") + scroll.setWidgetResizable(False) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + content = QWidget() + self.candidate_layout = QHBoxLayout(content) + self.candidate_layout.setContentsMargins(0, 0, 0, 0) + self.candidate_layout.setSpacing(12) + 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) + scroll.setWidget(content) + layout.addWidget(scroll, 1) + return panel + + def _candidate_item(self, candidate_path, index): + item = QWidget() + item.setFixedWidth(self.THUMBNAIL_SIZE + 16) + layout = QVBoxLayout(item) + layout.setContentsMargins(0, 0, 0, 0) + thumbnail = _CoverThumbnailLabel(candidate_path, self.open_original_image) + thumbnail.setObjectName("coverCandidateThumbnail") + thumbnail.setAlignment(Qt.AlignCenter) + image = _load_image(candidate_path) + if image.isNull(): + thumbnail.setText("图片读取失败") + thumbnail.setMinimumSize(self.THUMBNAIL_SIZE, self.THUMBNAIL_SIZE) + else: + thumbnail.setPixmap( + QPixmap.fromImage( + image.scaled( + self.THUMBNAIL_SIZE, + self.THUMBNAIL_SIZE, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + ) + thumbnail.setMinimumSize(self.THUMBNAIL_SIZE, self.THUMBNAIL_SIZE) + radio = QRadioButton(_candidate_label(candidate_path, self.current_path)) + radio.setObjectName(f"coverCandidateRadio{index}") + radio.toggled.connect( + lambda checked, path=candidate_path: self._select_candidate(path) if checked else None + ) + self.button_group.addButton(radio, index) + self.candidate_buttons[candidate_path] = radio + meta = QLabel(_candidate_meta(candidate_path, self.current_path)) + meta.setWordWrap(True) + meta.setObjectName("coverCandidateMetaLabel") + layout.addWidget(thumbnail) + layout.addWidget(radio) + layout.addWidget(meta) + layout.addStretch(1) + return item + + def _sync_initial_selection(self): + for candidate_path, radio in self.candidate_buttons.items(): + if _same_file(candidate_path, self.current_path): + radio.setChecked(True) + self.selected_path = candidate_path + break + self.save_button.setEnabled(self.selected_path is not None) + + def _select_candidate(self, candidate_path): + self.selected_path = candidate_path + self.save_button.setEnabled(True) + self._update_status() + + def _update_status(self): + if not self.candidates: + self.status_label.setText("暂无生成封面图片") + self.save_button.setEnabled(False) + return + if self.current_path and not os.path.exists(self.current_path): + self.status_label.setText("当前生效封面文件不存在,请选择一张后保存") + return + if self.current_path and self.selected_path is None: + self.status_label.setText("当前生效封面不在候选列表,请选择一张后保存") + return + if self.selected_path is None: + self.status_label.setText("请选择一张生成封面后保存") + return + if _same_file(self.selected_path, self.current_path): + self.status_label.setText("当前生效封面已选中") + return + self.status_label.setText("已选择新的生成封面,保存后用于后续更新蝦皮") + + def save_selection(self, checked=False): + if not self.selected_path: + QMessageBox.warning(self, "封面画廊", "请先选择一张生成封面") + return False + if _same_file(self.selected_path, self.current_path): + self.changed = False + self.accept() + return True + if self._needs_committed_confirmation() and not self._confirm_committed_save(): + return False + try: + db.update_generated_cover(self.task.id, self.selected_path, path=self.db_path) + except Exception as exc: + QMessageBox.warning(self, "封面画廊", f"保存当前封面失败:{exc}") + return False + self.current_path = _normalize_file_path(self.selected_path) + self.changed = True + self.accept() + return True + + def _needs_committed_confirmation(self): + return int(getattr(self.task, "committed", 0) or 0) == 1 or getattr(self.task, "stage", None) == "applied" + + def _confirm_committed_save(self): + box = QMessageBox(self) + box.setWindowTitle("确认保存封面") + box.setText( + "该商品已经提交过线上。本地换封面不会回滚蝦皮,重复更新会再次提交线上。" + ) + save_button = box.addButton("确认保存", QMessageBox.AcceptRole) + cancel_button = box.addButton("取消", QMessageBox.RejectRole) + box.setDefaultButton(cancel_button) + box.exec() + return box.clickedButton() is save_button + + def open_original_image(self, image_path): + dialog = OriginalImageDialog(image_path, self) + dialog.exec() + + def _fit_to_screen(self): + available = _available_geometry() + if available is None: + self.resize(960, 580) + return + max_width = max(520, int(available.width() * 0.9)) + max_height = max(420, int(available.height() * 0.9)) + self.resize(min(960, max_width), min(580, max_height)) + _center_dialog(self, available) + + +def _load_image(image_path): + return QImage(str(image_path or "")) + + +def _build_image_label(image_path, size, empty_text): + label = QLabel() + label.setAlignment(Qt.AlignCenter) + label.setMinimumSize(size, size) + image = _load_image(image_path) if image_path else QImage() + if image.isNull(): + label.setText(empty_text) + return label + label.setPixmap( + QPixmap.fromImage( + image.scaled( + size, + size, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + ) + return label + + +def _candidate_label(candidate_path, current_path): + return "当前生效" if _same_file(candidate_path, current_path) else os.path.basename(candidate_path) + + +def _candidate_meta(candidate_path, current_path): + if _same_file(candidate_path, current_path): + return "当前生效封面" + stem, _ext = os.path.splitext(os.path.basename(candidate_path)) + parts = stem.rsplit("_", 2) + if len(parts) >= 2 and len(parts[-1]) == 14 and parts[-1].isdigit(): + return _format_archive_timestamp(parts[-1]) + if len(parts) >= 3 and len(parts[-2]) == 14 and parts[-2].isdigit() and parts[-1].isdigit(): + return f"{_format_archive_timestamp(parts[-2])} · 同秒第 {parts[-1]} 张" + if stem.endswith("_new"): + return "当前输出槽" + return "历史候选" + + +def _format_archive_timestamp(value): + return f"{value[0:4]}-{value[4:6]}-{value[6:8]} {value[8:10]}:{value[10:12]}:{value[12:14]}" + + +def _normalize_file_path(path): + value = str(path or "").strip() + return os.path.abspath(value) if value else None + + +def _same_file(left, right): + if not left or not right: + return False + return os.path.normcase(os.path.abspath(str(left))) == os.path.normcase(os.path.abspath(str(right))) + + +def _available_geometry(): + app = QApplication.instance() + screen = app.primaryScreen() if app is not None else None + return screen.availableGeometry() if screen is not None else None + + +def _center_dialog(dialog, available): + frame = dialog.frameGeometry() + frame.moveCenter(available.center()) + dialog.move(frame.topLeft()) + + class GenerateTab(QWidget): """Tab 2: prompt area plus generation task filters/list.""" @@ -875,44 +1212,20 @@ class GenerateTab(QWidget): if task is None: self._set_status("没有可预览的任务") return - dialog = QDialog(self) - dialog.setWindowTitle(f"封面对照:{task.item_id}") - layout = QVBoxLayout(dialog) - images_layout = QHBoxLayout() - images_layout.addWidget(self._image_panel("旧封面", task.old_cover_path)) - images_layout.addWidget(self._image_panel("新封面", task.new_cover_path)) - layout.addLayout(images_layout) - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(dialog.reject) - layout.addWidget(buttons) - dialog.resize(720, 420) - dialog.exec() - - def _image_panel(self, title, path): - panel = QWidget() - layout = QVBoxLayout(panel) - layout.addWidget(QLabel(title)) - image_label = QLabel() - image_label.setAlignment(Qt.AlignCenter) - image_label.setMinimumSize(260, 260) - image_label.setWordWrap(True) - if path and os.path.exists(str(path)): - pixmap = QPixmap(str(path)) - if not pixmap.isNull(): - image_label.setPixmap( - pixmap.scaled( - 260, - 260, - Qt.KeepAspectRatio, - Qt.SmoothTransformation, - ) - ) + account = self.model.account_by_alias.get(str(getattr(task, "alias", "") or "").strip()) + dialog = CoverGalleryDialog( + task, + image_root=appconfig.image_dir(self.config), + db_path=self.db_path, + account=account, + parent=self, + ) + if dialog.exec() == QDialog.Accepted: + self.refresh_tasks() + if dialog.changed: + self._set_status(f"已保存当前新封面:{task.item_id}") else: - image_label.setText(f"图片无法读取\n{path}") - else: - image_label.setText(f"无图片\n{path or ''}".strip()) - layout.addWidget(image_label, 1) - return panel + self._set_status(f"当前新封面未变更:{task.item_id}") def _set_generate_running(self, running): self.generate_button.setEnabled(not running) diff --git a/app/gui/widgets.py b/app/gui/widgets.py index 9a42b8f..1983382 100644 --- a/app/gui/widgets.py +++ b/app/gui/widgets.py @@ -10,10 +10,11 @@ from concurrent.futures import ThreadPoolExecutor, as_completed try: from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer - from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap + from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QApplication, + QButtonGroup, QCheckBox, QComboBox, QDialog, @@ -32,6 +33,7 @@ try: QPlainTextEdit, QProgressBar, QPushButton, + QRadioButton, QScrollArea, QSplitter, QTableView, diff --git a/docs/tasks/T-567.md b/docs/tasks/T-567.md index 648032d..4fb0490 100644 --- a/docs/tasks/T-567.md +++ b/docs/tasks/T-567.md @@ -3,7 +3,7 @@ id: T-567 title: ②封面画廊基础:候选图查看、单选保存、原图弹窗 phase: 7 deps: [T-566] -status: TODO +status: DONE created: 2026-07-09 --- @@ -81,4 +81,19 @@ T-566 已提供数据层能力:重置封面时归档旧图、`db.update_genera ## 执行记录 -(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。) +- 2026-07-09 完成 T-567。 +- 修改 `app/gui/tabs/generate.py`: + - 将②任务列表双击封面查看入口重构为 `CoverGalleryDialog`,左侧显示旧封面参照,右侧横向滚动画廊显示该任务全部 `_new*.jpg` 候选。 + - 画廊打开时调用 T-566 的 `image_paths.list_task_cover_candidates()` 现场扫描目录;默认选中 DB 当前 `new_cover_path`,当前路径缺失时不自动改库并提示选择后保存;无候选时显示空状态并禁用保存。 + - 保存时未变更直接关闭;变更时调用 `db.update_generated_cover()`,保存成功后刷新②任务列表。已提交线上任务保存前弹中文二次确认,说明本地换封面不会回滚蝦皮、重复更新会再次提交线上。 + - 缩略图和旧封面预览统一用 `QImage` 读入内存后转 `QPixmap`,不长期持有文件句柄;候选缩略图双击打开 `OriginalImageDialog` 原图弹窗,标题显示文件名与原始分辨率,超出屏幕时可滚动。 +- 修改 `app/gui/widgets.py`:补充 `QImage`、`QButtonGroup`、`QRadioButton` 聚合导入,供画廊弹窗使用。 +- 修改 `tests/test_gui.py`: + - 覆盖候选图展示和 `_old` 过滤、当前封面默认选中、打开画廊后候选文件可重命名、保存切换当前封面、已提交任务取消确认不写库、当前路径缺失/无候选提示和禁用保存、原图弹窗分辨率标题。 +- 验证通过: + - `py -3.10 -m unittest tests.test_gui` + - `py -3.10 -m unittest tests.test_gui tests.test_db tests.test_image_paths` + - `python -m ruff check app tests main.py` + - `py -3.10 -m compileall app main.py` + - `py -3.10 -m unittest discover -s tests`(290 tests;PySide6 字体目录警告不影响结果) + - `git diff --check` diff --git a/tests/test_gui.py b/tests/test_gui.py index 7985e2e..4068c92 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -12,13 +12,13 @@ sys.path.insert(0, os.path.dirname(__file__)) from _helpers import TempDirMixin from app import gui -from app import accounts, ai, appconfig, db, prompts, update_check +from app import accounts, ai, appconfig, db, image_paths, prompts, update_check if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") from PySide6.QtCore import QItemSelectionModel, QRect -from PySide6.QtGui import QTextCursor +from PySide6.QtGui import QImage, QTextCursor from PySide6.QtWidgets import QApplication, QCheckBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView from app.gui import ( @@ -39,6 +39,7 @@ from app.gui import ( TAB_TITLES, WriteBackWorker, ) +from app.gui.tabs.generate import CoverGalleryDialog, OriginalImageDialog from app.gui.main_window import _fit_and_center_window @@ -81,6 +82,13 @@ class GuiTests(TempDirMixin, unittest.TestCase): } return cfg + def write_test_image(self, path, width=32, height=32): + os.makedirs(os.path.dirname(path), exist_ok=True) + image = QImage(width, height, QImage.Format_RGB32) + image.fill(0xFF336699) + self.assertTrue(image.save(path)) + return path + def assert_foreground(self, model, row, column, color): value = model.data(model.index(row, column), gui.Qt.ForegroundRole) self.assertIsNotNone(value) @@ -2068,6 +2076,199 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cover_gallery_lists_candidates_and_selects_current_cover(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + 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") + prefix = os.path.splitext(canonical)[0] + archive_newer = self.write_test_image(f"{prefix}_20260709101000.jpg") + archive_older = self.write_test_image(f"{prefix}_20260709094800.jpg") + old_candidate = f"{prefix.replace('_new', '_old')}.jpg" + self.write_test_image(canonical) + self.write_test_image(old_candidate) + db.set_collected(task.id, "旧标题", old_cover, path=cfg["db_path"]) + db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"]) + task = db.get_task(task.id, path=cfg["db_path"]) + + dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) + self.addCleanup(dialog.close) + + self.assertEqual([canonical, archive_newer, archive_older], dialog.candidates) + self.assertTrue(dialog.candidate_buttons[canonical].isChecked()) + self.assertEqual(canonical, dialog.selected_path) + self.assertNotIn(old_candidate, dialog.candidates) + renamed_archive = archive_newer.replace(".jpg", "_renamed.jpg") + os.rename(archive_newer, renamed_archive) + self.assertTrue(os.path.exists(renamed_archive)) + + self.assert_removed(temp_dir) + + def test_cover_gallery_save_switches_current_cover(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + 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] + canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new") + selected = self.write_test_image(os.path.splitext(canonical)[0] + "_20260709101000.jpg") + self.write_test_image(canonical) + db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"]) + task = db.get_task(task.id, path=cfg["db_path"]) + dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) + self.addCleanup(dialog.close) + + dialog.candidate_buttons[selected].setChecked(True) + self.assertTrue(dialog.save_selection()) + + updated = db.get_task(task.id, path=cfg["db_path"]) + self.assertEqual(os.path.abspath(selected), updated.new_cover_path) + self.assertEqual("generated", updated.stage) + self.assertEqual("pending", updated.status) + self.assertTrue(dialog.changed) + + self.assert_removed(temp_dir) + + def test_cover_gallery_committed_cancel_does_not_write_db(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + 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] + canonical = image_paths.task_image_path(cfg["image_dir"], task, account, "new") + selected = self.write_test_image(os.path.splitext(canonical)[0] + "_20260709101000.jpg") + self.write_test_image(canonical) + db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"]) + db.set_applied(task.id, True, path=cfg["db_path"]) + task = db.get_task(task.id, path=cfg["db_path"]) + dialog = CoverGalleryDialog(task, cfg["image_dir"], cfg["db_path"], account=account) + self.addCleanup(dialog.close) + + dialog.candidate_buttons[selected].setChecked(True) + message_box, boxes = self.make_fake_message_box("取消") + with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): + self.assertFalse(dialog.save_selection()) + + updated = db.get_task(task.id, path=cfg["db_path"]) + self.assertEqual(canonical, updated.new_cover_path) + self.assertEqual("applied", updated.stage) + self.assertEqual(1, updated.committed) + self.assertIn("重复更新会再次提交线上", boxes[0].text) + self.assertFalse(dialog.changed) + + self.assert_removed(temp_dir) + + def test_cover_gallery_handles_missing_current_and_empty_candidates(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + 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", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 3, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639511", + }, + ], + path=cfg["db_path"], + ) + first, second = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + missing_current = os.path.join(temp_dir, "missing.jpg") + db.set_generated(first.id, "新标题A", missing_current, path=cfg["db_path"]) + first_candidate = image_paths.task_image_path(cfg["image_dir"], first, account, "new") + self.write_test_image(first_candidate) + first = db.get_task(first.id, path=cfg["db_path"]) + + missing_dialog = CoverGalleryDialog(first, cfg["image_dir"], cfg["db_path"], account=account) + self.addCleanup(missing_dialog.close) + self.assertIsNone(missing_dialog.selected_path) + self.assertFalse(missing_dialog.save_button.isEnabled()) + self.assertIn("当前生效封面文件不存在", missing_dialog.status_label.text()) + + db.set_generated(second.id, "新标题B", os.path.join(temp_dir, "none.jpg"), path=cfg["db_path"]) + second = db.get_task(second.id, path=cfg["db_path"]) + empty_dialog = CoverGalleryDialog(second, cfg["image_dir"], cfg["db_path"], account=account) + self.addCleanup(empty_dialog.close) + self.assertEqual([], empty_dialog.candidates) + self.assertFalse(empty_dialog.save_button.isEnabled()) + self.assertIn("暂无生成封面图片", empty_dialog.status_label.text()) + + self.assert_removed(temp_dir) + + def test_original_image_dialog_title_contains_resolution(self): + with self.make_temp_dir() as temp_dir: + image_path = self.write_test_image(os.path.join(temp_dir, "cover.jpg"), width=64, height=32) + + dialog = OriginalImageDialog(image_path) + self.addCleanup(dialog.close) + + self.assertIn("cover.jpg", dialog.windowTitle()) + self.assertIn("64x32", dialog.windowTitle()) + + self.assert_removed(temp_dir) + def test_generate_tab_allows_editing_generated_title_locally(self): with self.make_temp_dir() as temp_dir: