feat(product-suite): remove fixed original image placeholders

This commit is contained in:
chengma
2026-07-16 09:34:10 +08:00
parent c80c6e67e9
commit cd61a14856
3 changed files with 168 additions and 26 deletions
+59 -15
View File
@@ -153,6 +153,7 @@ class ProductOriginalDelegate(QStyledItemDelegate):
class ProductOriginalList(QListWidget): class ProductOriginalList(QListWidget):
MAX_VISIBLE_ASSETS = 16 MAX_VISIBLE_ASSETS = 16
EMPTY_STATE_TEXT = "暂无商品原图"
filesDropped = Signal(list) filesDropped = Signal(list)
clipboardImage = Signal(bytes) clipboardImage = Signal(bytes)
@@ -191,7 +192,20 @@ class ProductOriginalList(QListWidget):
self._hover_remove_button.setFixedSize(28, 28) self._hover_remove_button.setFixedSize(28, 28)
self._hover_remove_button.clicked.connect(self._remove_hovered) self._hover_remove_button.clicked.connect(self._remove_hovered)
self._hover_remove_button.hide() 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): def dragEnterEvent(self, event):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
@@ -319,9 +333,11 @@ class ProductOriginalList(QListWidget):
return max(1, (available + self.spacing()) // step) return max(1, (available + self.spacing()) // step)
def content_row_count(self): 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() columns = self.content_column_count()
return max(1, (count + columns - 1) // columns) return (count + columns - 1) // columns
def update_content_height(self): def update_content_height(self):
rows = self.content_row_count() rows = self.content_row_count()
@@ -741,6 +757,19 @@ class ProductSuiteTab(QWidget):
layout.addWidget(self.open_folder_button) layout.addWidget(self.open_folder_button)
self.add_images_button = QPushButton("添加图片") self.add_images_button = QPushButton("添加图片")
self.add_images_button.setObjectName("suiteAddImagesButton") 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.addWidget(self.add_images_button)
layout.addStretch(1) layout.addStretch(1)
layout.addWidget(QLabel("账号")) layout.addWidget(QLabel("账号"))
@@ -1396,6 +1425,25 @@ class ProductSuiteTab(QWidget):
self.pull_button.setEnabled( self.pull_button.setEnabled(
self._valid_context(state, show_message=False) and state.pull_worker is None 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): def _start_thread(self, worker, name):
thread = run_worker(worker, thread_name=name, start=False) 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.setIcon(QIcon(_placeholder_pixmap("待下载", QSize(82, 64))))
item.setToolTip("%s尚未下载;勾选可批量删除,单击缩略图后台拉取" % label) item.setToolTip("%s尚未下载;勾选可批量删除,单击缩略图后台拉取" % label)
self.original_list.addItem(item) 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: finally:
self.original_list.blockSignals(previous) self.original_list.blockSignals(previous)
self._original_list_context = context self._original_list_context = context
@@ -1446,6 +1486,7 @@ class ProductSuiteTab(QWidget):
self.original_list.viewport().update() self.original_list.viewport().update()
self.original_count_label.setText("%d/16" % len(assets)) self.original_count_label.setText("%d/16" % len(assets))
self._refresh_original_selection_controls() self._refresh_original_selection_controls()
self._refresh_add_images_action(state)
self._refresh_totals(state) self._refresh_totals(state)
def _on_original_checks_changed(self, asset_ids): def _on_original_checks_changed(self, asset_ids):
@@ -1512,6 +1553,8 @@ class ProductSuiteTab(QWidget):
config=self.config, config=self.config,
) )
state.import_worker = worker 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.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.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)) worker.failed.connect(lambda row, error, state=state: self._on_import_failed(state, error))
@@ -1519,7 +1562,11 @@ class ProductSuiteTab(QWidget):
self._status("正在后台添加商品原图", "info") self._status("正在后台添加商品原图", "info")
def _on_import_failed(self, state, error): def _on_import_failed(self, state, error):
state.import_worker = None
state.import_thread = None
self._status("添加商品原图失败:%s" % _user_error(error), "danger") 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): def _on_import_finished(self, state, result):
state.import_worker = None state.import_worker = None
@@ -1622,9 +1669,6 @@ class ProductSuiteTab(QWidget):
def _on_original_clicked(self, item): def _on_original_clicked(self, item):
asset = self._asset_for_item(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: if asset is not None and not _asset_usable(asset) and asset.remote_url:
self._queue_original_downloads(self._displayed_state, [asset]) 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.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.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.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.original_list.setEnabled(not generation_running)
self._refresh_original_selection_controls() self._refresh_original_selection_controls()
for widget in ( for widget in (
+6 -1
View File
@@ -1,7 +1,7 @@
--- ---
id: T-635 id: T-635
title: 商品套图原图取消固定六个占位并强化添加入口 title: 商品套图原图取消固定六个占位并强化添加入口
status: TODO status: DONE
phase: 7 phase: 7
deps: [T-631] deps: [T-631]
created: 2026-07-14 created: 2026-07-14
@@ -104,3 +104,8 @@ git diff --check
- 不修改 T-631 除“最少 6 个展示位”以外的既有行为。 - 不修改 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` 全部通过。
+103 -10
View File
@@ -14,7 +14,7 @@ from app import gui
if gui.QT_IMPORT_ERROR is not None: if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装") 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.QtGui import QIcon, QImage, QPixmap
from PySide6.QtTest import QTest from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton 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.horizontalScrollBarPolicy())
self.assertEqual(Qt.ScrollBarAlwaysOff, tab.original_list.verticalScrollBarPolicy()) self.assertEqual(Qt.ScrollBarAlwaysOff, tab.original_list.verticalScrollBarPolicy())
self.assertEqual(6, tab.original_list.count()) self.assertEqual(0, tab.original_list.count())
self.assertEqual(2, tab.original_list.content_row_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() 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): for index in range(len(assets), target_count):
source_path = os.path.join(temp_dir, "added-%02d.png" % (index + 1)) source_path = os.path.join(temp_dir, "added-%02d.png" % (index + 1))
self._write_image(source_path) self._write_image(source_path)
@@ -502,11 +507,15 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
) )
tab._refresh_originals(state) tab._refresh_originals(state)
self.app.processEvents() self.app.processEvents()
self.assertEqual(target_count, tab.original_list.count())
self.assertEqual(target_count, len(tab.original_list.asset_ids())) 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()) 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.assertEqual(empty_height, tab.original_list.height())
self.assertTrue(tab.add_images_button.isEnabled())
wide_height = tab.original_list.height() wide_height = tab.original_list.height()
tab.original_list.setFixedWidth(250) 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(2, tab.original_list.content_column_count())
self.assertEqual(8, tab.original_list.content_row_count()) self.assertEqual(8, tab.original_list.content_row_count())
self.assertGreater(tab.original_list.height(), wide_height) 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) 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): def test_original_checks_preserve_on_refresh_and_clear_on_task_switch(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir) config = self._config(temp_dir)
@@ -529,13 +624,11 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
state.project_id = project.id state.project_id = project.id
tab._load_state(state) 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.select_all_originals_button.isEnabled())
self.assertTrue(tab.invert_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)) 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() tab.select_all_originals_button.click()
self.assertEqual([asset.id for asset in assets], tab.original_list.checked_asset_ids()) self.assertEqual([asset.id for asset in assets], tab.original_list.checked_asset_ids())