diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index 170b769..11ec567 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -153,6 +153,7 @@ class ProductOriginalDelegate(QStyledItemDelegate): class ProductOriginalList(QListWidget): MAX_VISIBLE_ASSETS = 16 + EMPTY_STATE_TEXT = "暂无商品原图" filesDropped = Signal(list) clipboardImage = Signal(bytes) @@ -191,7 +192,20 @@ class ProductOriginalList(QListWidget): self._hover_remove_button.setFixedSize(28, 28) self._hover_remove_button.clicked.connect(self._remove_hovered) self._hover_remove_button.hide() - self.setToolTip("点击添加图片;也可拖入图片、粘贴图片或拖动缩略图排序") + self.setToolTip("可拖入或粘贴图片;已有缩略图可拖动排序") + + def paintEvent(self, event): + super().paintEvent(event) + if self.count() != 0: + return + painter = QPainter(self.viewport()) + painter.setPen(QColor("#6b7280")) + painter.drawText( + self.viewport().rect().adjusted(12, 8, -12, -8), + Qt.AlignCenter, + self.EMPTY_STATE_TEXT, + ) + painter.end() def dragEnterEvent(self, event): if event.mimeData().hasUrls(): @@ -319,9 +333,11 @@ class ProductOriginalList(QListWidget): return max(1, (available + self.spacing()) // step) def content_row_count(self): - count = min(self.MAX_VISIBLE_ASSETS, max(1, self.count())) + count = min(self.MAX_VISIBLE_ASSETS, self.count()) + if count == 0: + return 1 columns = self.content_column_count() - return max(1, (count + columns - 1) // columns) + return (count + columns - 1) // columns def update_content_height(self): rows = self.content_row_count() @@ -741,6 +757,19 @@ class ProductSuiteTab(QWidget): layout.addWidget(self.open_folder_button) self.add_images_button = QPushButton("添加图片") self.add_images_button.setObjectName("suiteAddImagesButton") + self.add_images_button.setAccessibleName("添加商品原图") + self.add_images_button.setToolTip("添加本地商品原图") + self.add_images_button.setStyleSheet( + "QPushButton#suiteAddImagesButton { " + "color: #0969da; border: 1px solid #0969da; " + "background-color: #eef4ff; font-weight: 600; " + "}" + "QPushButton#suiteAddImagesButton:hover { background-color: #dbeafe; }" + "QPushButton#suiteAddImagesButton:pressed { background-color: #c7ddff; }" + "QPushButton#suiteAddImagesButton:disabled { " + "color: #8c959f; border-color: #d8dee4; background-color: #f6f8fa; " + "}" + ) layout.addWidget(self.add_images_button) layout.addStretch(1) layout.addWidget(QLabel("账号")) @@ -1396,6 +1425,25 @@ class ProductSuiteTab(QWidget): self.pull_button.setEnabled( self._valid_context(state, show_message=False) and state.pull_worker is None ) + self._refresh_add_images_action(state) + + def _refresh_add_images_action(self, state): + asset_count = len(self._original_assets(state, include_missing=True)) if state else 0 + full = asset_count >= image_studio_images.MAX_ORIGINAL_ASSETS + generation_running = bool(state and state.generation_running()) + importing = bool(state and state.import_worker is not None) + self.add_images_button.setEnabled( + state is not None and not full and not generation_running and not importing + ) + if full: + tooltip = "已达到16张商品原图上限" + elif generation_running: + tooltip = "生成中不能添加商品原图" + elif importing: + tooltip = "正在添加商品原图" + else: + tooltip = "添加本地商品原图" + self.add_images_button.setToolTip(tooltip) def _start_thread(self, worker, name): thread = run_worker(worker, thread_name=name, start=False) @@ -1431,14 +1479,6 @@ class ProductSuiteTab(QWidget): item.setIcon(QIcon(_placeholder_pixmap("待下载", QSize(82, 64)))) item.setToolTip("%s尚未下载;勾选可批量删除,单击缩略图后台拉取" % label) self.original_list.addItem(item) - for index in range(len(assets) + 1, 7): - label = "主图" if index == 1 else "参考%d" % (index - 1) - item = QListWidgetItem(label) - item.setData(Qt.UserRole, None) - item.setIcon(QIcon(_placeholder_pixmap("添加", QSize(82, 64)))) - item.setToolTip("点击添加%s" % label) - item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) - self.original_list.addItem(item) finally: self.original_list.blockSignals(previous) self._original_list_context = context @@ -1446,6 +1486,7 @@ class ProductSuiteTab(QWidget): self.original_list.viewport().update() self.original_count_label.setText("%d/16" % len(assets)) self._refresh_original_selection_controls() + self._refresh_add_images_action(state) self._refresh_totals(state) def _on_original_checks_changed(self, asset_ids): @@ -1512,6 +1553,8 @@ class ProductSuiteTab(QWidget): config=self.config, ) state.import_worker = worker + if state is self._displayed_state: + self._refresh_add_images_action(state) worker.finished.connect(lambda result, state=state: self._on_import_finished(state, result)) worker.cancelled.connect(lambda result, state=state: self._on_import_finished(state, result)) worker.failed.connect(lambda row, error, state=state: self._on_import_failed(state, error)) @@ -1519,7 +1562,11 @@ class ProductSuiteTab(QWidget): self._status("正在后台添加商品原图", "info") def _on_import_failed(self, state, error): + state.import_worker = None + state.import_thread = None self._status("添加商品原图失败:%s" % _user_error(error), "danger") + if state is self._displayed_state: + self._refresh_add_images_action(state) def _on_import_finished(self, state, result): state.import_worker = None @@ -1622,9 +1669,6 @@ class ProductSuiteTab(QWidget): def _on_original_clicked(self, item): asset = self._asset_for_item(item) - if asset is None and item.data(Qt.UserRole) is None: - self.choose_images() - return if asset is not None and not _asset_usable(asset) and asset.remote_url: self._queue_original_downloads(self._displayed_state, [asset]) @@ -2107,7 +2151,7 @@ class ProductSuiteTab(QWidget): self.pull_button.setText("正在拉取..." if state.pull_worker is not None else "拉取蝦皮主图") self.account_combo.setEnabled(not generation_running and state.pull_worker is None) self.item_id_edit.setEnabled(not generation_running and state.pull_worker is None) - self.add_images_button.setEnabled(not generation_running and state.import_worker is None) + self._refresh_add_images_action(state) self.original_list.setEnabled(not generation_running) self._refresh_original_selection_controls() for widget in ( diff --git a/docs/tasks/T-635.md b/docs/tasks/T-635.md index efbe261..238dc7b 100644 --- a/docs/tasks/T-635.md +++ b/docs/tasks/T-635.md @@ -1,7 +1,7 @@ --- id: T-635 title: 商品套图原图取消固定六个占位并强化添加入口 -status: TODO +status: DONE phase: 7 deps: [T-631] created: 2026-07-14 @@ -104,3 +104,8 @@ git diff --check - 不修改 T-631 除“最少 6 个展示位”以外的既有行为。 ## 执行记录 +- 2026-07-16:移除商品原图不足 6 张时创建的假缩略图项;列表数量、计数、勾选、排序和删除现在只对应真实资产,点击假占位添加图片的旧分支同步删除。 +- 2026-07-16:零图片时通过列表绘制显示 `暂无商品原图`,保留一行稳定高度和拖放、粘贴能力;1 至 16 张时按真实数量和可用列数自然增高,内部滚动条继续关闭。 +- 2026-07-16:为「添加图片」增加信息蓝次要强调样式,并按真实容量、导入中和生成中状态统一控制可用性与中文 tooltip;补齐导入失败后的 worker 清理和按钮恢复。 +- 2026-07-16:更新 GUI 回归测试,覆盖 0/1/2/5/6/7/16 张布局、零图片拖放与剪贴板粘贴、按钮样式和状态、勾选与任务切换;`1180x760` 离屏检查确认空状态、按钮和原图区域无重叠。 +- 验证:当前工作区 `tests.test_product_suite_gui` 13 项、`tests.test_product_suite` 4 项通过,ruff、compileall、`git diff --check` 通过;隔离 worktree 中全仓 ruff、compileall、503 项 unittest 和 `git diff --check` 全部通过。 diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 8c912d8..35b1e25 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -14,7 +14,7 @@ from app import gui if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") -from PySide6.QtCore import Qt +from PySide6.QtCore import QMimeData, Qt, QUrl from PySide6.QtGui import QIcon, QImage, QPixmap from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton @@ -483,11 +483,16 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(Qt.ScrollBarAlwaysOff, tab.original_list.horizontalScrollBarPolicy()) self.assertEqual(Qt.ScrollBarAlwaysOff, tab.original_list.verticalScrollBarPolicy()) - self.assertEqual(6, tab.original_list.count()) - self.assertEqual(2, tab.original_list.content_row_count()) + self.assertEqual(0, tab.original_list.count()) + self.assertEqual([], tab.original_list.asset_ids()) + self.assertEqual("0/16", tab.original_count_label.text()) + self.assertEqual("已选 0 张", tab.original_selected_label.text()) + self.assertFalse(tab.select_all_originals_button.isEnabled()) + self.assertFalse(tab.invert_originals_button.isEnabled()) + self.assertEqual(1, tab.original_list.content_row_count()) empty_height = tab.original_list.height() - for target_count in (6, 7, 16): + for target_count in (1, 2, 5, 6, 7, 16): for index in range(len(assets), target_count): source_path = os.path.join(temp_dir, "added-%02d.png" % (index + 1)) self._write_image(source_path) @@ -502,11 +507,15 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): ) tab._refresh_originals(state) self.app.processEvents() + self.assertEqual(target_count, tab.original_list.count()) self.assertEqual(target_count, len(tab.original_list.asset_ids())) - expected_rows = (max(6, target_count) + 2) // 3 + columns = tab.original_list.content_column_count() + expected_rows = (target_count + columns - 1) // columns self.assertEqual(expected_rows, tab.original_list.content_row_count()) - if target_count == 6: + self.assertEqual("%d/16" % target_count, tab.original_count_label.text()) + if target_count == 1: self.assertEqual(empty_height, tab.original_list.height()) + self.assertTrue(tab.add_images_button.isEnabled()) wide_height = tab.original_list.height() tab.original_list.setFixedWidth(250) @@ -514,9 +523,95 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assertEqual(2, tab.original_list.content_column_count()) self.assertEqual(8, tab.original_list.content_row_count()) self.assertGreater(tab.original_list.height(), wide_height) + self.assertFalse(tab.add_images_button.isEnabled()) + self.assertEqual("已达到16张商品原图上限", tab.add_images_button.toolTip()) self.assert_removed(temp_dir) + def test_empty_original_list_accepts_file_drop_and_clipboard_image(self): + original_list = ProductOriginalList() + self.addCleanup(original_list.close) + original_list.setFixedSize(360, 110) + original_list.show() + self.app.processEvents() + + self.assertEqual(0, original_list.count()) + self.assertEqual("暂无商品原图", original_list.EMPTY_STATE_TEXT) + self.assertEqual(1, original_list.content_row_count()) + + dropped = [] + pasted = [] + original_list.filesDropped.connect(dropped.append) + original_list.clipboardImage.connect(pasted.append) + + class DropEvent: + def __init__(self, mime_data): + self._mime_data = mime_data + self.accepted = False + + def mimeData(self): + return self._mime_data + + def acceptProposedAction(self): + self.accepted = True + + mime_data = QMimeData() + expected_path = os.path.abspath("待导入图片.png") + mime_data.setUrls([QUrl.fromLocalFile(expected_path)]) + drop_event = DropEvent(mime_data) + original_list.dropEvent(drop_event) + self.assertTrue(drop_event.accepted) + self.assertEqual( + os.path.normcase(os.path.normpath(expected_path)), + os.path.normcase(os.path.normpath(dropped[0][0])), + ) + + clipboard_image = QImage(24, 18, QImage.Format_RGB32) + clipboard_image.fill(0xFF336699) + QApplication.clipboard().setImage(clipboard_image) + original_list.setFocus() + QTest.keyClick(original_list, Qt.Key_V, Qt.ControlModifier) + self.app.processEvents() + QApplication.clipboard().clear() + self.assertEqual(1, len(pasted)) + self.assertTrue(pasted[0].startswith(b"\x89PNG\r\n\x1a\n")) + + def test_add_images_button_tracks_capacity_import_and_generation(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, _ = self._create_project_with_assets(temp_dir, config, 1) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = "51100639510" + state.project_id = project.id + tab._load_state(state) + + style = tab.add_images_button.styleSheet() + for color in ("#0969da", "#eef4ff", "#dbeafe", "#c7ddff", "#f6f8fa"): + self.assertIn(color, style) + self.assertTrue(tab.add_images_button.isEnabled()) + self.assertEqual("添加本地商品原图", tab.add_images_button.toolTip()) + + state.import_worker = object() + tab._refresh_add_images_action(state) + self.assertFalse(tab.add_images_button.isEnabled()) + self.assertEqual("正在添加商品原图", tab.add_images_button.toolTip()) + state.import_thread = object() + tab._on_import_failed(state, "测试导入失败") + self.assertIsNone(state.import_worker) + self.assertIsNone(state.import_thread) + self.assertTrue(tab.add_images_button.isEnabled()) + + state.worker = object() + tab._refresh_add_images_action(state) + self.assertFalse(tab.add_images_button.isEnabled()) + self.assertEqual("生成中不能添加商品原图", tab.add_images_button.toolTip()) + state.worker = None + tab._refresh_add_images_action(state) + self.assertTrue(tab.add_images_button.isEnabled()) + def test_original_checks_preserve_on_refresh_and_clear_on_task_switch(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) @@ -529,13 +624,11 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): state.project_id = project.id tab._load_state(state) - self.assertEqual(6, tab.original_list.count()) + self.assertEqual(2, tab.original_list.count()) self.assertTrue(tab.select_all_originals_button.isEnabled()) self.assertTrue(tab.invert_originals_button.isEnabled()) - for row in range(2): + for row in range(tab.original_list.count()): self.assertIsNotNone(tab.original_list.item(row).data(ORIGINAL_CHECK_STATE_ROLE)) - for row in range(2, 6): - self.assertIsNone(tab.original_list.item(row).data(ORIGINAL_CHECK_STATE_ROLE)) tab.select_all_originals_button.click() self.assertEqual([asset.id for asset in assets], tab.original_list.checked_asset_ids())