feat(product-suite): add original image batch management

This commit is contained in:
chengma
2026-07-14 15:35:59 +08:00
parent 21d2dd5253
commit e86b9189ab
8 changed files with 759 additions and 37 deletions
+292 -30
View File
@@ -7,7 +7,7 @@ import re
import time
from dataclasses import dataclass, field
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QSize, Qt, QTimer, Signal
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QRect, QSize, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QIcon, QImage, QImageReader, QKeySequence, QPainter, QPixmap
from PySide6.QtWidgets import (
QApplication,
@@ -32,6 +32,9 @@ from PySide6.QtWidgets import (
QScrollArea,
QSizePolicy,
QSplitter,
QStyle,
QStyledItemDelegate,
QStyleOptionButton,
QTabBar,
QToolButton,
QVBoxLayout,
@@ -51,6 +54,7 @@ from ..workers import (
ORIGINAL_DOWNLOAD_CONCURRENCY = 2
ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1
_PRODUCT_SUITE_THREAD_REFS = {}
_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE)
@@ -143,11 +147,48 @@ class ProductSuitePreviewDialog(QDialog):
)
class ProductOriginalDelegate(QStyledItemDelegate):
CHECK_HIT_SIZE = 26
@classmethod
def checkbox_hit_rect(cls, item_rect):
return QRect(
item_rect.left() + 2,
item_rect.top() + 2,
cls.CHECK_HIT_SIZE,
cls.CHECK_HIT_SIZE,
)
def paint(self, painter, option, index):
super().paint(painter, option, index)
if index.data(Qt.UserRole) is None:
return
checked = index.data(ORIGINAL_CHECK_STATE_ROLE) == Qt.Checked
style = QApplication.style()
width = style.pixelMetric(QStyle.PM_IndicatorWidth)
height = style.pixelMetric(QStyle.PM_IndicatorHeight)
hit_rect = self.checkbox_hit_rect(option.rect)
checkbox = QStyleOptionButton()
checkbox.rect = QRect(
hit_rect.center().x() - width // 2,
hit_rect.center().y() - height // 2,
width,
height,
)
checkbox.state = QStyle.State_Enabled if option.state & QStyle.State_Enabled else QStyle.State_None
checkbox.state |= QStyle.State_On if checked else QStyle.State_Off
style.drawControl(QStyle.CE_CheckBox, checkbox, painter)
class ProductOriginalList(QListWidget):
MAX_VISIBLE_ASSETS = 16
filesDropped = Signal(list)
clipboardImage = Signal(bytes)
orderChanged = Signal(list)
deleteRequested = Signal(int)
deleteAssetsRequested = Signal(list)
checkedAssetsChanged = Signal(list)
def __init__(self, parent=None):
super().__init__(parent)
@@ -159,12 +200,16 @@ class ProductOriginalList(QListWidget):
self.setIconSize(QSize(82, 64))
self.setGridSize(QSize(112, 98))
self.setSpacing(4)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setAcceptDrops(True)
self.setDragEnabled(True)
self.setDropIndicatorShown(True)
self.setDragDropMode(QListWidget.InternalMove)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_menu)
self.itemChanged.connect(lambda item: self.checkedAssetsChanged.emit(self.checked_asset_ids()))
self.setItemDelegate(ProductOriginalDelegate(self))
self.setMouseTracking(True)
self.itemEntered.connect(self._show_hover_remove)
self._hovered_asset_id = None
@@ -198,6 +243,36 @@ class ProductOriginalList(QListWidget):
super().dropEvent(event)
self.orderChanged.emit(self.asset_ids())
def resizeEvent(self, event):
super().resizeEvent(event)
self.update_content_height()
def mousePressEvent(self, event):
position = event.position().toPoint()
item = self.itemAt(position)
if (
event.button() == Qt.LeftButton
and item is not None
and item.data(Qt.UserRole) is not None
and ProductOriginalDelegate.checkbox_hit_rect(self.visualItemRect(item)).contains(position)
):
self._set_item_checked(item, not self._item_checked(item))
event.accept()
return
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
position = event.position().toPoint()
item = self.itemAt(position)
if (
item is not None
and item.data(Qt.UserRole) is not None
and ProductOriginalDelegate.checkbox_hit_rect(self.visualItemRect(item)).contains(position)
):
event.accept()
return
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event):
if event.matches(QKeySequence.Paste):
image = QApplication.clipboard().image()
@@ -210,10 +285,19 @@ class ProductOriginalList(QListWidget):
self.clipboardImage.emit(bytes(payload))
return
if event.key() in {Qt.Key_Delete, Qt.Key_Backspace}:
checked_ids = self.checked_asset_ids()
if checked_ids:
self.deleteAssetsRequested.emit(checked_ids)
return
item = self.currentItem()
if item is not None and item.data(Qt.UserRole) is not None:
self.deleteRequested.emit(int(item.data(Qt.UserRole)))
return
if event.key() == Qt.Key_Space:
item = self.currentItem()
if item is not None and item.data(Qt.UserRole) is not None:
self._set_item_checked(item, not self._item_checked(item))
return
super().keyPressEvent(event)
def asset_ids(self):
@@ -223,14 +307,102 @@ class ProductOriginalList(QListWidget):
if self.item(row).data(Qt.UserRole) is not None
]
def checked_asset_ids(self):
return [
int(self.item(row).data(Qt.UserRole))
for row in range(self.count())
if self.item(row).data(Qt.UserRole) is not None
and self._item_checked(self.item(row))
]
def set_checked_asset_ids(self, asset_ids):
checked_ids = {int(asset_id) for asset_id in asset_ids or []}
previous = self.blockSignals(True)
try:
for row in range(self.count()):
item = self.item(row)
asset_id = item.data(Qt.UserRole)
if asset_id is not None:
item.setData(
ORIGINAL_CHECK_STATE_ROLE,
Qt.Checked if int(asset_id) in checked_ids else Qt.Unchecked,
)
finally:
self.blockSignals(previous)
self.viewport().update()
self.checkedAssetsChanged.emit(self.checked_asset_ids())
def select_all_assets(self):
self.set_checked_asset_ids(self.asset_ids())
def invert_asset_checks(self):
checked = set(self.checked_asset_ids())
self.set_checked_asset_ids([asset_id for asset_id in self.asset_ids() if asset_id not in checked])
def clear_checks(self):
self.set_checked_asset_ids([])
def content_column_count(self):
step = max(1, self.gridSize().width() + self.spacing())
available = max(1, self.viewport().width() - self.spacing())
return max(1, (available + self.spacing()) // step)
def content_row_count(self):
count = min(self.MAX_VISIBLE_ASSETS, max(1, self.count()))
columns = self.content_column_count()
return max(1, (count + columns - 1) // columns)
def update_content_height(self):
rows = self.content_row_count()
target = (
rows * self.gridSize().height()
+ (rows + 1) * self.spacing()
+ 2 * self.frameWidth()
)
if target != self.height():
self.setFixedHeight(target)
def _show_menu(self, position):
item = self.itemAt(position)
if item is None or item.data(Qt.UserRole) is None:
return
menu = QMenu(self)
remove_action = menu.addAction("删除图片")
if menu.exec(self.viewport().mapToGlobal(position)) is remove_action:
self.deleteRequested.emit(int(item.data(Qt.UserRole)))
actions = []
for label, asset_ids in self.context_delete_options(int(item.data(Qt.UserRole))):
actions.append((menu.addAction(label), asset_ids))
selected = menu.exec(self.viewport().mapToGlobal(position))
for action, asset_ids in actions:
if selected is action:
if len(asset_ids) == 1 and asset_ids[0] not in self.checked_asset_ids():
self.deleteRequested.emit(asset_ids[0])
else:
self.deleteAssetsRequested.emit(asset_ids)
return
def context_delete_options(self, clicked_asset_id):
clicked_asset_id = int(clicked_asset_id)
checked_ids = self.checked_asset_ids()
if clicked_asset_id in checked_ids:
if len(checked_ids) > 1:
return [("删除选中的%d张图片…" % len(checked_ids), checked_ids)]
return [("删除选中图片…", checked_ids)]
options = [("删除这张图片…", [clicked_asset_id])]
if checked_ids:
label = (
"删除选中的%d张图片…" % len(checked_ids)
if len(checked_ids) > 1
else "删除选中图片…"
)
options.append((label, checked_ids))
return options
@staticmethod
def _item_checked(item):
return item.data(ORIGINAL_CHECK_STATE_ROLE) == Qt.Checked
@staticmethod
def _set_item_checked(item, checked):
item.setData(ORIGINAL_CHECK_STATE_ROLE, Qt.Checked if checked else Qt.Unchecked)
def _show_hover_remove(self, item):
value = item.data(Qt.UserRole)
@@ -407,6 +579,7 @@ class ProductSuiteTab(QWidget):
self._next_key = 1
self._next_serial = 1
self._displayed_state = None
self._original_list_context = None
self._loading = False
self._result_refresh_pending = False
@@ -569,9 +742,26 @@ class ProductSuiteTab(QWidget):
self.original_count_label.setStyleSheet("color: #6b7280;")
title_row.addWidget(self.original_count_label)
title_row.addStretch(1)
self.original_selected_label = QLabel("已选 0 张")
self.original_selected_label.setObjectName("suiteOriginalSelectedLabel")
self.original_selected_label.setStyleSheet("color: #6b7280;")
title_row.addWidget(self.original_selected_label)
self.select_all_originals_button = QToolButton()
self.select_all_originals_button.setObjectName("suiteSelectAllOriginalsButton")
self.select_all_originals_button.setText("全选")
self.select_all_originals_button.setToolTip("选择当前商品的全部原图")
self.select_all_originals_button.setAccessibleName("全选商品原图")
self.select_all_originals_button.setMinimumSize(48, 28)
title_row.addWidget(self.select_all_originals_button)
self.invert_originals_button = QToolButton()
self.invert_originals_button.setObjectName("suiteInvertOriginalsButton")
self.invert_originals_button.setText("反选")
self.invert_originals_button.setToolTip("反转当前商品原图的勾选状态")
self.invert_originals_button.setAccessibleName("反选商品原图")
self.invert_originals_button.setMinimumSize(48, 28)
title_row.addWidget(self.invert_originals_button)
layout.addLayout(title_row)
self.original_list = ProductOriginalList()
self.original_list.setFixedHeight(210)
layout.addWidget(self.original_list)
return frame
@@ -772,8 +962,16 @@ class ProductSuiteTab(QWidget):
self.original_list.clipboardImage.connect(self.import_clipboard_image)
self.original_list.orderChanged.connect(self.reorder_originals)
self.original_list.deleteRequested.connect(self.delete_original)
self.original_list.deleteAssetsRequested.connect(self.delete_originals)
self.original_list.checkedAssetsChanged.connect(self._on_original_checks_changed)
self.original_list.itemClicked.connect(self._on_original_clicked)
self.original_list.itemDoubleClicked.connect(self._preview_original)
self.select_all_originals_button.clicked.connect(
lambda checked=False: self.original_list.select_all_assets()
)
self.invert_originals_button.clicked.connect(
lambda checked=False: self.original_list.invert_asset_checks()
)
for combo in (
self.platform_combo,
self.country_combo,
@@ -1132,31 +1330,61 @@ class ProductSuiteTab(QWidget):
thread.start()
return thread
def _refresh_originals(self, state):
def _refresh_originals(self, state, *, preserve_checks=True):
context = (state.key, state.project_id) if state is not None else None
checked_ids = (
set(self.original_list.checked_asset_ids())
if preserve_checks and context == self._original_list_context
else set()
)
previous = self.original_list.blockSignals(True)
self.original_list.clear()
assets = self._original_assets(state, include_missing=False)
for index, asset in enumerate(assets, 1):
label = "主图" if index == 1 else "参考%d" % (index - 1)
item = QListWidgetItem(label)
item.setData(Qt.UserRole, int(asset.id))
if _asset_usable(asset):
item.setIcon(QIcon(_image_pixmap(asset.local_path, QSize(82, 64))))
item.setToolTip("%s,双击预览;拖动可调整顺序" % label)
else:
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)
try:
for index, asset in enumerate(assets, 1):
label = "主图" if index == 1 else "参考%d" % (index - 1)
item = QListWidgetItem(label)
item.setData(Qt.UserRole, int(asset.id))
item.setData(
ORIGINAL_CHECK_STATE_ROLE,
Qt.Checked if int(asset.id) in checked_ids else Qt.Unchecked,
)
if _asset_usable(asset):
item.setIcon(QIcon(_image_pixmap(asset.local_path, QSize(82, 64))))
item.setToolTip("%s,勾选可批量删除;双击预览;拖动可调整顺序" % label)
else:
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
self.original_list.update_content_height()
self.original_list.viewport().update()
self.original_count_label.setText("%d/16" % len(assets))
self._refresh_original_selection_controls()
self._refresh_totals(state)
def _on_original_checks_changed(self, asset_ids):
self._refresh_original_selection_controls()
def _refresh_original_selection_controls(self):
checked_count = len(self.original_list.checked_asset_ids())
asset_count = len(self.original_list.asset_ids())
self.original_selected_label.setText("已选 %d 张" % checked_count)
state = self._displayed_state
enabled = bool(asset_count) and state is not None and not state.generation_running()
self.select_all_originals_button.setEnabled(enabled)
self.invert_originals_button.setEnabled(enabled)
def _original_assets(self, state, *, include_missing=False):
if state is None or state.project_id is None:
return []
@@ -1241,19 +1469,52 @@ class ProductSuiteTab(QWidget):
self._refresh_originals(state)
def delete_original(self, asset_id):
self.delete_originals([asset_id])
def delete_originals(self, asset_ids):
state = self._displayed_state
if state is None or state.generation_running():
if state is None or state.project_id is None:
self._status("当前商品没有可删除的原图", "warning")
return
if state.generation_running():
self._status("生成中不能删除当前任务的商品原图", "warning")
return
if not self._confirm("删除商品原图", "确认从当前商品的原图列表移除这张图片吗?"):
normalized_ids = []
seen = set()
for value in asset_ids or []:
asset_id = int(value)
if asset_id not in seen:
seen.add(asset_id)
normalized_ids.append(asset_id)
if not normalized_ids:
self._status("请先勾选要删除的商品原图", "warning")
return
downloading = set(state.download_queue) | set(state.downloads)
if downloading.intersection(normalized_ids):
self._message("暂不能删除商品原图", "选中的图片仍在后台下载,请等待下载结束后再删除。")
return
visible_ids = [int(asset.id) for asset in self._original_assets(state, include_missing=False)]
message = (
"确认从当前商品原图列表移除选中的%d张图片吗?\n\n"
"此操作不会删除蝦皮线上图片,也不会删除本地源文件。"
"以后重新拉取蝦皮主图时,线上仍存在的图片可能重新出现。"
% len(normalized_ids)
)
if visible_ids and visible_ids[0] in normalized_ids:
message += "\n\n选中内容包含当前主图,删除后下一张图片将成为主图。"
if not self._confirm("删除商品原图", message, destructive=True):
return
try:
image_studio.remove_asset_if_unused(asset_id, path=self.db_path)
image_studio.remove_original_assets_if_unused(
state.project_id,
normalized_ids,
path=self.db_path,
)
except Exception as exc:
self._message("不能删除商品原图", _user_error(exc))
return
self._refresh_originals(state)
self._status("商品原图已移除", "success")
self._refresh_originals(state, preserve_checks=False)
self._status("已移除%d张商品原图" % len(normalized_ids), "success")
def reorder_originals(self, visible_ids):
state = self._displayed_state
@@ -1828,6 +2089,7 @@ class ProductSuiteTab(QWidget):
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.original_list.setEnabled(not generation_running)
self._refresh_original_selection_controls()
for widget in (
self.platform_combo,
self.country_combo,
+78
View File
@@ -575,6 +575,84 @@ def remove_asset_if_unused(asset_id, path=None, conn=None):
return asset
def remove_original_assets_if_unused(project_id, asset_ids, path=None, conn=None):
"""Atomically remove unreferenced original assets from one project.
Local files are intentionally retained. If any requested asset is invalid or
referenced, no asset row is removed.
"""
project_id = int(project_id)
ordered_ids = []
seen = set()
for value in asset_ids or []:
asset_id = int(value)
if asset_id not in seen:
seen.add(asset_id)
ordered_ids.append(asset_id)
if not ordered_ids:
raise db.DbError("请选择要删除的商品原图")
placeholders = ",".join("?" for _ in ordered_ids)
with _connection(conn, path) as database:
with database:
project = get_project(project_id, conn=database)
if project is None:
raise db.DbError("商品套图项目不存在或已删除")
rows = database.execute(
f"""
SELECT * FROM image_studio_assets
WHERE project_id = ? AND kind = ? AND id IN ({placeholders})
""",
[project_id, ASSET_KIND_ORIGINAL, *ordered_ids],
).fetchall()
by_id = {int(row["id"]): row for row in rows}
if set(by_id) != set(ordered_ids):
raise db.DbError("选中的商品原图不存在或不属于当前项目")
referenced_ids = [
asset_id
for asset_id in ordered_ids
if asset_reference_counts(asset_id, conn=database)["total"]
]
if referenced_ids:
raise db.DbError("选中的图片正在被生成任务或终选引用,不能移除")
database.execute(
f"""
DELETE FROM image_studio_assets
WHERE project_id = ? AND kind = ? AND id IN ({placeholders})
""",
[project_id, ASSET_KIND_ORIGINAL, *ordered_ids],
)
remaining_rows = database.execute(
"""
SELECT id FROM image_studio_assets
WHERE project_id = ? AND kind = ?
ORDER BY source_order, id
""",
(project_id, ASSET_KIND_ORIGINAL),
).fetchall()
now = _now()
for source_order, row in enumerate(remaining_rows, 1):
database.execute(
"""
UPDATE image_studio_assets
SET source_order = ?, updated_at = ?
WHERE id = ? AND project_id = ? AND kind = ?
""",
(
source_order,
now,
int(row["id"]),
project_id,
ASSET_KIND_ORIGINAL,
),
)
return [_row_to_dataclass(by_id[asset_id], ImageStudioAsset) for asset_id in ordered_ids]
def sync_original_asset_urls(project_id, image_urls, path=None, conn=None, max_assets=16):
"""Store the read-only Shopee main image URL snapshot as remote-only assets."""