Files
cmshoppe/app/gui/tabs/product_suite.py
T

2327 lines
96 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tab 6: project-bound product suite generation workspace."""
from __future__ import annotations
import os
import re
import time
from dataclasses import dataclass, field
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,
QCheckBox,
QComboBox,
QFileDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QListView,
QListWidget,
QListWidgetItem,
QMenu,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QScrollArea,
QSizePolicy,
QSplitter,
QStyle,
QStyledItemDelegate,
QStyleOptionButton,
QTabBar,
QToolButton,
QVBoxLayout,
QWidget,
)
from ... import accounts, appconfig, diagnostics, image_studio, image_studio_images, product_suite
from .. import file_manager
from ..image_preview import ImagePreviewDialog
from ..widgets import COLOR_DANGER, _emit_status, run_worker
from ..workers import (
ImageStudioDownloadOriginalWorker,
ImageStudioPullImagesWorker,
ProductSuiteAiWriteWorker,
ProductSuiteGenerateWorker,
ProductSuiteImportImagesWorker,
)
ORIGINAL_DOWNLOAD_CONCURRENCY = 2
ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1
_PRODUCT_SUITE_THREAD_REFS = {}
_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE)
def _asset_usable(asset):
path = str(getattr(asset, "local_path", "") or "")
return (
getattr(asset, "status", "") != image_studio.ASSET_STATUS_MISSING
and bool(path)
and os.path.isfile(path)
)
def _placeholder_pixmap(text, size, color="#f3f4f6"):
pixmap = QPixmap(size)
pixmap.fill(QColor(color))
painter = QPainter(pixmap)
painter.setPen(QColor("#6b7280"))
painter.drawText(pixmap.rect(), Qt.AlignCenter | Qt.TextWordWrap, str(text or ""))
painter.end()
return pixmap
def _image_pixmap(path, size, fallback="暂无图片"):
image = QImage()
if path and os.path.isfile(path):
reader = QImageReader(str(path))
reader.setAutoTransform(True)
source_size = reader.size()
if source_size.isValid():
source_size.scale(size, Qt.KeepAspectRatio)
reader.setScaledSize(source_size)
image = reader.read()
if image.isNull():
return _placeholder_pixmap(fallback, size)
scaled = QPixmap.fromImage(image).scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
canvas = QPixmap(size)
canvas.fill(QColor("#f7f8fa"))
painter = QPainter(canvas)
painter.drawPixmap(
max(0, (size.width() - scaled.width()) // 2),
max(0, (size.height() - scaled.height()) // 2),
scaled,
)
painter.end()
return canvas
def _user_error(error):
text = diagnostics.redact_log_text(str(error or "未知错误"))
text = _URL_RE.sub("[链接已隐藏]", text).replace("\r", " ").replace("\n", " ").strip()
return text if len(text) <= 90 else text[:87] + "..."
class ProductSuitePreviewDialog(ImagePreviewDialog):
"""Current product-suite wrapper for the shared full-image preview."""
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)
self.setObjectName("suiteOriginalList")
self.setViewMode(QListView.IconMode)
self.setResizeMode(QListView.Adjust)
self.setMovement(QListView.Snap)
self.setWrapping(True)
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
self._hover_remove_button = QToolButton(self.viewport())
self._hover_remove_button.setText("×")
self._hover_remove_button.setToolTip("删除这张商品原图")
self._hover_remove_button.setAccessibleName("删除商品原图")
self._hover_remove_button.setFixedSize(28, 28)
self._hover_remove_button.clicked.connect(self._remove_hovered)
self._hover_remove_button.hide()
self.setToolTip("点击添加图片;也可拖入图片、粘贴图片或拖动缩略图排序")
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
return
super().dragEnterEvent(event)
def dragMoveEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
return
super().dragMoveEvent(event)
def dropEvent(self, event):
if event.mimeData().hasUrls():
paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()]
self.filesDropped.emit(paths)
event.acceptProposedAction()
return
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()
if not image.isNull():
payload = QByteArray()
buffer = QBuffer(payload)
buffer.open(QIODevice.WriteOnly)
image.save(buffer, "PNG")
buffer.close()
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):
return [
int(self.item(row).data(Qt.UserRole))
for row in range(self.count())
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)
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)
if value is None:
self._hovered_asset_id = None
self._hover_remove_button.hide()
return
self._hovered_asset_id = int(value)
rect = self.visualItemRect(item)
self._hover_remove_button.move(max(0, rect.right() - 27), rect.top() + 1)
self._hover_remove_button.show()
self._hover_remove_button.raise_()
def _remove_hovered(self):
if self._hovered_asset_id is not None:
self.deleteRequested.emit(int(self._hovered_asset_id))
def leaveEvent(self, event):
self._hovered_asset_id = None
self._hover_remove_button.hide()
super().leaveEvent(event)
class CategoryChipButton(QPushButton):
doubleClicked = Signal()
def mouseDoubleClickEvent(self, event):
self.doubleClicked.emit()
event.accept()
class SuiteResultCard(QFrame):
previewRequested = Signal(object)
retryRequested = Signal(object)
menuRequested = Signal(object, object)
deleteRequested = Signal(object)
def __init__(self, job, asset=None, parent=None):
super().__init__(parent)
self.job = job
self.asset = asset
self.setObjectName("suiteResultCard")
self.setFrameShape(QFrame.StyledPanel)
self.setFixedSize(186, 224)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(
lambda point: self.menuRequested.emit(self.job, self.mapToGlobal(point))
)
layout = QVBoxLayout(self)
layout.setContentsMargins(7, 7, 7, 7)
layout.setSpacing(5)
title_row = QHBoxLayout()
title = QLabel(str(getattr(job, "job_type", "套图")))
title.setStyleSheet("font-weight: 600; color: #24292f;")
title_row.addWidget(title, 1)
if asset is not None and _asset_usable(asset):
delete_button = QToolButton()
delete_button.setText("×")
delete_button.setToolTip("删除这张生成图片")
delete_button.setAccessibleName("删除生成图片")
delete_button.setMinimumSize(28, 28)
delete_button.clicked.connect(lambda: self.deleteRequested.emit(self.job))
title_row.addWidget(delete_button)
layout.addLayout(title_row)
image_label = QLabel()
image_label.setAlignment(Qt.AlignCenter)
image_label.setFixedSize(170, 150)
status = str(getattr(job, "status", "pending") or "pending")
if asset is not None and _asset_usable(asset):
image_label.setPixmap(_image_pixmap(asset.local_path, QSize(170, 150)))
else:
label = {
"pending": "等待提交",
"submitted": "排队中",
"running": "生成中",
"failed": "生成失败",
"expired": "任务过期",
"cancelled": "已停止",
"succeeded": "图片已删除",
}.get(status, "处理中")
color = "#ffebe9" if status in {"failed", "expired"} else "#f3f4f6"
image_label.setPixmap(_placeholder_pixmap(label, QSize(170, 150), color))
image_label.mousePressEvent = lambda event: (
self.previewRequested.emit(self.job)
if event.button() == Qt.LeftButton
else None
)
layout.addWidget(image_label)
footer = QHBoxLayout()
status_text = {
"pending": "等待提交",
"submitted": "已提交",
"running": "生成中",
"succeeded": "生成成功" if asset is not None and _asset_usable(asset) else "已删除",
"failed": "失败:%s" % _user_error(getattr(job, "error", "")),
"expired": "已过期",
"cancelled": "已停止",
}.get(status, status)
status_label = QLabel(status_text)
status_label.setWordWrap(False)
status_label.setToolTip(status_text)
footer.addWidget(status_label, 1)
if status in {"failed", "expired", "cancelled"}:
retry_button = QPushButton("重试")
retry_button.setMinimumWidth(52)
retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job))
footer.addWidget(retry_button)
layout.addLayout(footer)
@dataclass
class SuiteTaskState:
key: int
serial: int
account_alias: str = ""
item_id: str = ""
project_id: int = None
prompt: str = ""
settings: dict = field(default_factory=product_suite.default_suite_settings)
current_job_ids: list = field(default_factory=list)
show_history: bool = False
worker: object = None
thread: object = None
pull_worker: object = None
pull_thread: object = None
import_worker: object = None
import_thread: object = None
ai_worker: object = None
ai_thread: object = None
download_queue: list = field(default_factory=list)
downloads: dict = field(default_factory=dict)
done: int = 0
failed: int = 0
total: int = 0
started_at: float = None
ai_started_at: float = None
ai_prompt_snapshot: str = ""
pending_ai_text: str = ""
undo_records: list = field(default_factory=list)
active_category: str = "白底图"
def generation_running(self):
return self.worker is not None
class ProductSuiteTab(QWidget):
"""Native PySide6 product-suite UI backed by image_studio services."""
PLATFORM_OPTIONS = product_suite.PLATFORMS
COUNTRY_OPTIONS = product_suite.COUNTRIES
LANGUAGE_OPTIONS = product_suite.LANGUAGES
def __init__(
self,
parent=None,
db_path=None,
config=None,
config_path=None,
status_callback=None,
):
super().__init__(parent)
self.setObjectName("productSuiteTab")
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
self.db_path = db_path or appconfig.db_path(self.config)
self.config_path = config_path or self.config.get("config_path") or appconfig.CONFIG_PATH
self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config)
self.status_callback = status_callback
self.accounts = []
self._states = {}
self._retired_states = []
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
self._build_ui()
self._connect_signals()
self.refresh_accounts()
self.add_task(inherit=False)
self.elapsed_timer = QTimer(self)
self.elapsed_timer.setInterval(1000)
self.elapsed_timer.timeout.connect(self._refresh_elapsed)
self.elapsed_timer.start()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(10, 8, 10, 8)
root.setSpacing(8)
root.addWidget(self._build_task_bar())
root.addWidget(self._build_context_bar())
self.main_splitter = QSplitter(Qt.Horizontal)
self.main_splitter.setObjectName("suiteMainSplitter")
self.main_splitter.addWidget(self._build_config_panel())
self.main_splitter.addWidget(self._build_results_panel())
self.main_splitter.setStretchFactor(0, 2)
self.main_splitter.setStretchFactor(1, 3)
self.main_splitter.setSizes([430, 700])
root.addWidget(self.main_splitter, 1)
def _build_task_bar(self):
frame = QFrame()
layout = QHBoxLayout(frame)
layout.setContentsMargins(0, 0, 0, 0)
self.task_tabs = QTabBar()
self.task_tabs.setObjectName("suiteTaskTabs")
self.task_tabs.setTabsClosable(True)
self.task_tabs.setMovable(True)
self.task_tabs.setExpanding(False)
self.task_tabs.setFixedHeight(36)
self.task_tabs.setStyleSheet(
"""
QTabBar#suiteTaskTabs::tab {
min-width: 96px;
min-height: 26px;
max-height: 26px;
padding: 3px 10px;
margin-right: 4px;
border: 1px solid #c9d1d9;
border-bottom-color: #b8c0ca;
background: #f4f6f8;
color: #24292f;
}
QTabBar#suiteTaskTabs::tab:selected {
background: #ffffff;
border-color: #687785;
border-bottom-color: #ffffff;
font-weight: 600;
}
QTabBar#suiteTaskTabs::tab:hover:!selected {
background: #eaf2ff;
}
"""
)
layout.addWidget(self.task_tabs, 1)
self.add_task_button = QToolButton()
self.add_task_button.setObjectName("suiteAddTaskButton")
self.add_task_button.setText("+")
self.add_task_button.setToolTip("新建套图任务")
self.add_task_button.setAccessibleName("新建套图任务")
self.add_task_button.setMinimumSize(32, 32)
layout.addWidget(self.add_task_button)
return frame
def _build_context_bar(self):
frame = QFrame()
frame.setObjectName("suiteContextBar")
layout = QHBoxLayout(frame)
self.context_bar_layout = layout
layout.setContentsMargins(10, 7, 10, 7)
layout.setSpacing(8)
self.history_button = QPushButton("历史生成")
self.history_button.setObjectName("suiteHistoryButton")
self.history_button.setCheckable(True)
layout.addWidget(self.history_button)
self.open_folder_button = QPushButton("打开结果文件夹")
self.open_folder_button.setObjectName("suiteOpenFolderButton")
layout.addWidget(self.open_folder_button)
self.add_images_button = QPushButton("添加图片")
self.add_images_button.setObjectName("suiteAddImagesButton")
layout.addWidget(self.add_images_button)
layout.addStretch(1)
layout.addWidget(QLabel("账号"))
self.account_combo = QComboBox()
self.account_combo.setObjectName("suiteAccountCombo")
self.account_combo.setMinimumWidth(120)
self.account_combo.setMaximumWidth(160)
self.account_combo.setMinimumContentsLength(10)
layout.addWidget(self.account_combo)
layout.addWidget(QLabel("商品ID"))
self.item_id_edit = QLineEdit()
self.item_id_edit.setObjectName("suiteItemIdEdit")
self.item_id_edit.setPlaceholderText("输入商品ID")
item_width = max(
120,
min(140, self.item_id_edit.fontMetrics().horizontalAdvance("0" * 13) + 30),
)
self.item_id_edit.setMinimumWidth(item_width)
self.item_id_edit.setMaximumWidth(item_width)
layout.addWidget(self.item_id_edit)
self.pull_button = QPushButton("拉取蝦皮主图")
self.pull_button.setObjectName("suitePullButton")
layout.addWidget(self.pull_button)
return frame
def _build_config_panel(self):
panel = QWidget()
panel.setMinimumWidth(400)
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 4, 0)
layout.setSpacing(7)
scroll = QScrollArea()
scroll.setObjectName("suiteConfigScroll")
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
content = QWidget()
self.config_layout = QVBoxLayout(content)
self.config_layout.setContentsMargins(4, 2, 8, 2)
self.config_layout.setSpacing(9)
self.config_layout.addWidget(self._build_original_section())
self.config_layout.addWidget(self._build_settings_section())
self.config_layout.addWidget(self._build_prompt_section())
self.config_layout.addWidget(self._build_category_section())
self.config_layout.addStretch(1)
scroll.setWidget(content)
layout.addWidget(scroll, 1)
self.generate_button = QPushButton("生成套图(0)")
self.generate_button.setObjectName("suiteGenerateButton")
self.generate_button.setMinimumHeight(42)
self.generate_button.setStyleSheet(
"QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }"
"QPushButton:hover { background: #245fce; }"
)
layout.addWidget(self.generate_button)
self.generate_helper_label = QLabel("建议填写产品名称、核心卖点、目标人群、使用场景与禁用元素")
self.generate_helper_label.setWordWrap(True)
self.generate_helper_label.setStyleSheet("color: #6b7280;")
layout.addWidget(self.generate_helper_label)
return panel
def _build_original_section(self):
frame = QFrame()
layout = QVBoxLayout(frame)
layout.setContentsMargins(0, 0, 0, 0)
title_row = QHBoxLayout()
title = QLabel("商品原图")
title.setStyleSheet("font-weight: 600;")
title_row.addWidget(title)
self.original_count_label = QLabel("0/16")
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()
layout.addWidget(self.original_list)
return frame
def _build_settings_section(self):
frame = QFrame()
layout = QVBoxLayout(frame)
layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("生成设置")
title.setStyleSheet("font-weight: 600;")
layout.addWidget(title)
grid = QGridLayout()
self.settings_grid = grid
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(6)
grid.setVerticalSpacing(3)
settings = (
(
"platform_label",
"platform_combo",
"平台",
"suitePlatformLabel",
"suitePlatformCombo",
self.PLATFORM_OPTIONS,
),
(
"country_label",
"country_combo",
"站点",
"suiteCountryLabel",
"suiteCountryCombo",
self.COUNTRY_OPTIONS,
),
(
"language_label",
"language_combo",
"语言",
"suiteLanguageLabel",
"suiteLanguageCombo",
self.LANGUAGE_OPTIONS,
),
(
"ratio_label",
"ratio_combo",
"比例",
"suiteRatioLabel",
"suiteRatioCombo",
product_suite.RATIOS,
),
)
for column, setting in enumerate(settings):
label_attr, combo_attr, text, label_name, combo_name, values = setting
label = QLabel(text)
label.setObjectName(label_name)
setattr(self, label_attr, label)
combo = self._value_combo(combo_name, text, values)
setattr(self, combo_attr, combo)
grid.addWidget(label, 0, column)
grid.addWidget(combo, 1, column)
grid.setColumnStretch(column, 1)
layout.addLayout(grid)
self.per_image_checkbox = QCheckBox("每张上传图分别作为主图生成")
self.per_image_checkbox.setObjectName("suitePerImageCheckbox")
layout.addWidget(self.per_image_checkbox)
return frame
def _value_combo(self, object_name, label, values):
combo = QComboBox()
combo.setObjectName(object_name)
combo.setToolTip("%s设置" % label)
combo.setAccessibleName("%s设置" % label)
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
for value in values:
combo.addItem(value, value)
combo.setItemData(combo.count() - 1, value, Qt.ToolTipRole)
return combo
def _build_prompt_section(self):
frame = QFrame()
layout = QVBoxLayout(frame)
layout.setContentsMargins(0, 0, 0, 0)
title_row = QHBoxLayout()
title = QLabel("商品卖点与要求")
title.setStyleSheet("font-weight: 600;")
title_row.addWidget(title)
title_row.addStretch(1)
self.ai_cancel_button = QPushButton("取消")
self.ai_cancel_button.setObjectName("suiteAiCancelButton")
self.ai_cancel_button.hide()
title_row.addWidget(self.ai_cancel_button)
self.ai_write_button = QPushButton("AI 帮写")
self.ai_write_button.setObjectName("suiteAiWriteButton")
title_row.addWidget(self.ai_write_button)
layout.addLayout(title_row)
self.prompt_edit = QPlainTextEdit()
self.prompt_edit.setObjectName("suitePromptEdit")
self.prompt_edit.setPlaceholderText("输入产品名称、卖点、目标人群、使用场景和禁用元素")
self.prompt_edit.setMinimumHeight(96)
self.prompt_edit.setMaximumHeight(112)
layout.addWidget(self.prompt_edit)
helper = QLabel("内容越具体,生成的商品套图越稳定。AI 帮写不会阻塞其他套图任务。")
helper.setWordWrap(True)
helper.setStyleSheet("color: #6b7280;")
layout.addWidget(helper)
return frame
def _build_category_section(self):
frame = QFrame()
layout = QVBoxLayout(frame)
layout.setContentsMargins(0, 0, 0, 0)
title_row = QHBoxLayout()
title = QLabel("套图结构配置")
title.setStyleSheet("font-weight: 600;")
title_row.addWidget(title)
title_row.addStretch(1)
self.category_total_label = QLabel("合计 0 张")
self.category_total_label.setStyleSheet("color: #2f6fed; font-weight: 600;")
title_row.addWidget(self.category_total_label)
layout.addLayout(title_row)
self.category_grid = QGridLayout()
self.category_grid.setContentsMargins(0, 0, 0, 0)
self.category_grid.setHorizontalSpacing(4)
self.category_grid.setVerticalSpacing(5)
layout.addLayout(self.category_grid)
self.add_category_button = QPushButton("添加自定义分类")
self.add_category_button.setObjectName("suiteAddCategoryButton")
layout.addWidget(self.add_category_button)
self.custom_category_edit = QLineEdit()
self.custom_category_edit.setObjectName("suiteCustomCategoryEdit")
self.custom_category_edit.setPlaceholderText("输入分类名称后按回车")
self.custom_category_edit.hide()
layout.addWidget(self.custom_category_edit)
return frame
def _build_results_panel(self):
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setContentsMargins(4, 0, 0, 0)
layout.setSpacing(7)
toolbar = QHBoxLayout()
self.results_toolbar_layout = toolbar
title = QLabel("生成结果")
title.setStyleSheet("font-weight: 600; font-size: 15px;")
toolbar.addWidget(title)
self.result_summary_label = QLabel("共 0 张 · 成功 0 张")
self.result_summary_label.setObjectName("suiteResultSummaryLabel")
self.result_summary_label.setStyleSheet(
"background: #eef4ff; color: #2459c4; padding: 3px 8px; border-radius: 4px;"
)
toolbar.addWidget(self.result_summary_label)
toolbar.addStretch(1)
self.undo_button = QPushButton("撤销删除")
self.undo_button.setObjectName("suiteUndoButton")
self.undo_button.hide()
toolbar.addWidget(self.undo_button)
self.more_button = QToolButton()
self.more_button.setText("⋯")
self.more_button.setToolTip("更多操作")
self.more_button.setAccessibleName("更多操作")
self.more_button.setMinimumSize(32, 32)
toolbar.addWidget(self.more_button)
layout.addLayout(toolbar)
self.result_scroll = QScrollArea()
self.result_scroll.setObjectName("suiteResultScroll")
self.result_scroll.setWidgetResizable(True)
self.result_scroll.setFrameShape(QFrame.StyledPanel)
self.result_content = QWidget()
self.result_grid = QGridLayout(self.result_content)
self.result_grid.setContentsMargins(12, 12, 12, 12)
self.result_grid.setSpacing(10)
self.result_grid.setAlignment(Qt.AlignTop | Qt.AlignLeft)
self.result_scroll.setWidget(self.result_content)
layout.addWidget(self.result_scroll, 1)
bottom = QHBoxLayout()
self.progress_bar = QProgressBar()
self.progress_bar.setObjectName("suiteProgressBar")
self.progress_bar.setRange(0, 1)
self.progress_bar.setValue(0)
self.progress_bar.setTextVisible(False)
bottom.addWidget(self.progress_bar, 1)
self.elapsed_label = QLabel("套图 0/0(0秒) · 失败 0")
self.elapsed_label.setObjectName("suiteElapsedLabel")
self.elapsed_label.setMinimumWidth(190)
bottom.addWidget(self.elapsed_label)
layout.addLayout(bottom)
return panel
def _connect_signals(self):
self.task_tabs.currentChanged.connect(self._on_task_changed)
self.task_tabs.tabCloseRequested.connect(self.close_task)
self.add_task_button.clicked.connect(lambda: self.add_task(inherit=True))
self.account_combo.currentIndexChanged.connect(self._on_account_changed)
self.item_id_edit.editingFinished.connect(self._on_item_finished)
self.pull_button.clicked.connect(self.pull_main_images)
self.add_images_button.clicked.connect(self.choose_images)
self.original_list.filesDropped.connect(self.import_files)
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,
self.language_combo,
self.ratio_combo,
):
combo.currentIndexChanged.connect(self._on_settings_changed)
self.per_image_checkbox.toggled.connect(self._on_settings_changed)
self.prompt_edit.textChanged.connect(self._on_prompt_changed)
self.ai_write_button.clicked.connect(self.start_ai_write)
self.ai_cancel_button.clicked.connect(self.cancel_ai_write)
self.add_category_button.clicked.connect(self.add_custom_category)
self.custom_category_edit.returnPressed.connect(self._commit_custom_category)
self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit)
self.generate_button.clicked.connect(self.toggle_generation)
self.history_button.toggled.connect(self._toggle_history)
self.open_folder_button.clicked.connect(self.open_project_folder)
self.undo_button.clicked.connect(self.undo_delete)
self.more_button.clicked.connect(self._show_more_menu)
def _status(self, message, level=None):
_emit_status(self.status_callback, message, level=level)
def _message(self, title, message, icon=QMessageBox.Warning):
box = QMessageBox(self)
box.setIcon(icon)
box.setWindowTitle(str(title))
box.setText(str(message))
box.exec()
def _confirm(self, title, message, *, destructive=False):
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question)
box.setWindowTitle(str(title))
box.setText(str(message))
confirm_button = box.addButton("确认", QMessageBox.AcceptRole)
box.addButton("取消", QMessageBox.RejectRole)
if not destructive:
box.setDefaultButton(confirm_button)
box.exec()
return box.clickedButton() is confirm_button
def refresh_accounts(self):
selected = self.account_combo.currentData()
try:
self.accounts = accounts.list_accounts(path=self.db_path, config=self.config)
except Exception as exc:
self.accounts = []
self._status("商品套图账号读取失败:%s" % _user_error(exc), "danger")
self._loading = True
try:
self.account_combo.clear()
for account in self.accounts:
text = "%s(%s)" % (account.account_name, account.alias)
self.account_combo.addItem(text, account.alias)
self.account_combo.setItemData(
self.account_combo.count() - 1,
text,
Qt.ToolTipRole,
)
if not self.accounts:
self.account_combo.addItem("暂无账号,请先到④账号管理添加", "")
index = self.account_combo.findData(selected)
if index >= 0:
self.account_combo.setCurrentIndex(index)
finally:
self._loading = False
self._update_account_tooltip()
def add_task(self, checked=False, inherit=True):
source = self._displayed_state if inherit else None
initial_settings = (
source.settings
if source is not None
else appconfig.product_suite_last_settings(self.config)
)
state = SuiteTaskState(
key=self._next_key,
serial=self._next_serial,
account_alias=(source.account_alias if source is not None else ""),
prompt=(source.prompt if source is not None else ""),
settings=product_suite.normalize_suite_settings(initial_settings),
)
if not state.account_alias and self.accounts:
state.account_alias = self.accounts[0].alias
self._next_key += 1
self._next_serial += 1
self._states[state.key] = state
index = self.task_tabs.addTab("套图任务 %d" % state.serial)
self.task_tabs.setTabData(index, state.key)
if self.task_tabs.currentIndex() == index:
self._on_task_changed(index)
else:
self.task_tabs.setCurrentIndex(index)
return state
def close_task(self, index):
key = self.task_tabs.tabData(index)
state = self._states.get(key)
if state is None:
return
if state.generation_running():
if not self._confirm(
"关闭套图任务",
"任务生成中,关闭会取消该任务。确认关闭吗?",
destructive=True,
):
return
state.worker.cancel()
if state.ai_worker is not None:
state.ai_worker.cancel()
if state.pull_worker is not None:
state.pull_worker.cancel()
self._retired_states.append(state)
self._states.pop(state.key, None)
self.task_tabs.removeTab(index)
if self.task_tabs.count() == 0:
self.add_task(inherit=False)
def _state_for_index(self, index):
if index < 0:
return None
return self._states.get(self.task_tabs.tabData(index))
def _on_task_changed(self, index):
if self._loading:
return
if self._displayed_state is not None:
self._save_controls_to_state(self._displayed_state)
state = self._state_for_index(index)
self._displayed_state = state
if state is not None:
self._load_state(state)
self._offer_pending_ai_result(state)
def _save_controls_to_state(self, state):
if self._loading or state is None:
return
state.account_alias = str(self.account_combo.currentData() or "")
state.item_id = self.item_id_edit.text().strip()
state.prompt = self.prompt_edit.toPlainText()
state.settings = self._settings_from_controls()
def _load_state(self, state):
self._loading = True
try:
self.custom_category_edit.hide()
self.add_category_button.show()
index = self.account_combo.findData(state.account_alias)
if index >= 0:
self.account_combo.setCurrentIndex(index)
self.item_id_edit.setText(state.item_id)
self.prompt_edit.setPlainText(state.prompt)
self._set_combo_value(self.platform_combo, state.settings.get("platform"))
self._set_combo_value(self.country_combo, state.settings.get("country"))
self._set_combo_value(self.language_combo, state.settings.get("language"))
self._set_combo_value(self.ratio_combo, state.settings.get("ratio"))
self.per_image_checkbox.setChecked(bool(state.settings.get("per_image_primary")))
self.history_button.setChecked(bool(state.show_history))
finally:
self._loading = False
self._refresh_originals(state)
self._rebuild_categories(state)
self._refresh_results(state)
self._apply_running_state(state)
self._refresh_elapsed()
def _set_combo_value(self, combo, value):
index = combo.findData(value)
if index >= 0:
combo.setCurrentIndex(index)
def _settings_from_controls(self):
current = self._displayed_state.settings if self._displayed_state else {}
settings = dict(current or {})
settings.update(
{
"platform": self.platform_combo.currentData(),
"country": self.country_combo.currentData(),
"language": self.language_combo.currentData(),
"ratio": self.ratio_combo.currentData(),
"per_image_primary": self.per_image_checkbox.isChecked(),
}
)
return product_suite.normalize_suite_settings(settings)
def _on_account_changed(self, index):
self._update_account_tooltip()
if self._loading or self._displayed_state is None:
return
state = self._displayed_state
alias = str(self.account_combo.currentData() or "")
if state.project_id is not None and alias != state.account_alias:
if not self._confirm(
"切换账号",
"切换账号后,当前任务会改为新的账号与商品上下文。确认继续吗?",
):
self._loading = True
try:
self._set_combo_value(self.account_combo, state.account_alias)
finally:
self._loading = False
return
self._clear_project_binding(state)
state.account_alias = alias
self._update_context_actions(state)
def _update_account_tooltip(self):
self.account_combo.setToolTip(self.account_combo.currentText())
def _on_item_finished(self):
if self._loading or self._displayed_state is None:
return
state = self._displayed_state
item_id = self.item_id_edit.text().strip()
if item_id and not item_id.isdigit():
self.item_id_edit.setStyleSheet("border: 1px solid %s;" % COLOR_DANGER)
self._status("商品ID只能输入数字", "warning")
return
self.item_id_edit.setStyleSheet("")
if state.project_id is not None and item_id != state.item_id:
if not self._confirm(
"切换商品",
"切换商品ID后,原图和生成结果会按新商品重新载入。确认继续吗?",
):
self._loading = True
try:
self.item_id_edit.setText(state.item_id)
finally:
self._loading = False
return
self._clear_project_binding(state)
state.item_id = item_id
if not item_id:
self.item_id_edit.setStyleSheet("border: 1px solid %s;" % COLOR_DANGER)
self._status("商品ID不能为空", "warning")
self._update_context_actions(state)
return
if item_id and state.account_alias:
self._bind_project(state, load_existing=True)
self._update_context_actions(state)
def _clear_project_binding(self, state):
state.project_id = None
state.current_job_ids = []
state.done = state.failed = state.total = 0
state.started_at = None
if state is self._displayed_state:
self._refresh_originals(state)
self._refresh_results(state)
def _account_for_alias(self, alias):
return next((account for account in self.accounts if account.alias == alias), None)
def _valid_context(self, state, *, show_message=True):
if not state.account_alias:
if show_message:
self._message("未选择账号", "请先在顶部选择账号。")
return False
if not state.item_id or not state.item_id.isdigit():
if show_message:
self._message("商品ID无效", "请输入正确的数字商品ID。")
return False
return True
def _bind_project(self, state, *, load_existing=False):
if not self._valid_context(state):
return None
account = self._account_for_alias(state.account_alias)
if account is None:
self._message("账号不可用", "所选账号不存在,请到④账号管理刷新账号。")
return None
previous_id = state.project_id
try:
project = image_studio.create_or_get_project(
account,
item_id=state.item_id,
path=self.db_path,
)
except Exception as exc:
self._message("创建套图任务失败", _user_error(exc))
return None
state.project_id = int(project.id)
if load_existing and previous_id != state.project_id:
state.prompt = str(project.draft_prompt or "")
state.settings = product_suite.normalize_suite_settings(
image_studio.project_suite_settings(project)
)
if state is self._displayed_state:
self._load_state(state)
return project
def _persist_state(self, state):
if state.project_id is None:
return
try:
image_studio.update_project_prompt(
state.project_id,
state.prompt,
path=self.db_path,
)
image_studio.update_project_suite_settings(
state.project_id,
state.settings,
path=self.db_path,
)
except Exception as exc:
self._status("商品套图设置保存失败:%s" % _user_error(exc), "danger")
def _on_settings_changed(self, value=None):
if self._loading or self._displayed_state is None:
return
state = self._displayed_state
state.settings = self._settings_from_controls()
self._persist_last_settings(state.settings)
self._persist_state(state)
self._refresh_totals(state)
def _persist_last_settings(self, settings):
last_settings = product_suite.last_suite_settings(settings)
try:
if os.path.exists(self.config_path):
saved = appconfig.update_config(
{"product_suite": {"last_settings": last_settings}},
path=self.config_path,
)
else:
base = dict(self.config)
suite = base.get("product_suite", {})
suite = dict(suite) if isinstance(suite, dict) else {}
suite["last_settings"] = last_settings
base["product_suite"] = suite
saved = appconfig.save_config(base, path=self.config_path)
except Exception as exc:
self._status("商品套图最近设置保存失败:%s" % _user_error(exc), "danger")
return
self.config.clear()
self.config.update(saved)
def _on_prompt_changed(self):
if self._loading or self._displayed_state is None:
return
state = self._displayed_state
state.prompt = self.prompt_edit.toPlainText()
def _update_context_actions(self, state):
self.pull_button.setEnabled(
self._valid_context(state, show_message=False) and state.pull_worker is None
)
def _start_thread(self, worker, name):
thread = run_worker(worker, thread_name=name, start=False)
token = id(thread)
_PRODUCT_SUITE_THREAD_REFS[token] = (thread, worker)
thread.finished.connect(lambda token=token: _PRODUCT_SUITE_THREAD_REFS.pop(token, None))
thread.start()
return thread
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)
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 []
try:
return image_studio.list_assets(
state.project_id,
kind=image_studio.ASSET_KIND_ORIGINAL,
include_missing=include_missing,
path=self.db_path,
)
except Exception as exc:
self._status("商品原图读取失败:%s" % _user_error(exc), "danger")
return []
def choose_images(self, checked=False):
paths, _ = QFileDialog.getOpenFileNames(
self,
"选择商品原图",
"",
"图片文件 (*.png *.jpg *.jpeg *.webp *.bmp)",
)
if paths:
self.import_files(paths)
def import_files(self, paths):
self._start_import(file_paths=list(paths or []))
def import_clipboard_image(self, image_bytes):
self._start_import(image_bytes=image_bytes)
def _start_import(self, *, file_paths=None, image_bytes=None):
state = self._displayed_state
if state is None:
return
self._save_controls_to_state(state)
if state.project_id is None and self._bind_project(state) is None:
return
if state.import_worker is not None:
self._status("当前任务正在添加图片,请稍候", "warning")
return
current_count = len(self._original_assets(state, include_missing=True))
if current_count >= image_studio_images.MAX_ORIGINAL_ASSETS:
self._message("图片数量已满", "每个商品最多添加16张原图。")
return
worker = ProductSuiteImportImagesWorker(
state.project_id,
file_paths=file_paths,
image_bytes=image_bytes,
db_path=self.db_path,
config=self.config,
)
state.import_worker = worker
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))
state.import_thread = self._start_thread(worker, "商品套图添加原图")
self._status("正在后台添加商品原图", "info")
def _on_import_failed(self, state, error):
self._status("添加商品原图失败:%s" % _user_error(error), "danger")
def _on_import_finished(self, state, result):
state.import_worker = None
state.import_thread = None
if result.get("ok") is False:
self._message("添加商品原图失败", _user_error(result.get("error")))
else:
errors = result.get("errors") or []
count = len(result.get("assets") or [])
if errors:
self._message(
"部分图片未添加",
"已添加%d张,%d张失败。\n%s" % (
count,
len(errors),
_user_error(errors[0].get("error")),
),
)
else:
self._status("已添加%d张商品原图" % count, "success")
if state is self._displayed_state:
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.project_id is None:
self._status("当前商品没有可删除的原图", "warning")
return
if state.generation_running():
self._status("生成中不能删除当前任务的商品原图", "warning")
return
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_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, preserve_checks=False)
self._status("已移除%d张商品原图" % len(normalized_ids), "success")
def reorder_originals(self, visible_ids):
state = self._displayed_state
if state is None or state.project_id is None:
return
all_assets = self._original_assets(state, include_missing=True)
hidden_ids = [int(asset.id) for asset in all_assets if int(asset.id) not in set(visible_ids)]
try:
image_studio.reorder_original_assets(
state.project_id,
list(visible_ids) + hidden_ids,
path=self.db_path,
)
except Exception as exc:
self._message("原图排序失败", _user_error(exc))
self._refresh_originals(state)
def _asset_for_item(self, item):
state = self._displayed_state
if state is None or item is None:
return None
value = item.data(Qt.UserRole)
if value is None:
return None
asset_id = int(value)
return next(
(asset for asset in self._original_assets(state, include_missing=True) if int(asset.id) == asset_id),
None,
)
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])
def _preview_original(self, item):
asset = self._asset_for_item(item)
if asset is None:
return
if not _asset_usable(asset):
self._queue_original_downloads(self._displayed_state, [asset])
self._status("原图正在后台下载,完成后可双击预览", "info")
return
ProductSuitePreviewDialog(asset.local_path, "商品原图预览", self).exec()
def pull_main_images(self, checked=False):
state = self._displayed_state
if state is None:
return
self._save_controls_to_state(state)
if not self._valid_context(state):
return
if state.pull_worker is not None:
self._status("当前任务正在拉取蝦皮主图", "info")
return
existing = [asset for asset in self._original_assets(state, include_missing=False) if _asset_usable(asset)]
if existing and not self._confirm(
"覆盖拉取蝦皮主图",
"本地已有商品原图。继续拉取会刷新蝦皮原图列表,本地上传图片会保留。确认继续吗?",
):
return
worker = ImageStudioPullImagesWorker(
state.account_alias,
state.item_id,
db_path=self.db_path,
config=self.config,
)
state.pull_worker = worker
worker.finished.connect(lambda result, state=state: self._on_pull_finished(state, result))
worker.cancelled.connect(lambda result, state=state: self._on_pull_finished(state, result))
worker.failed.connect(lambda row, error, state=state: self._on_pull_failed(state, error))
state.pull_thread = self._start_thread(worker, "商品套图拉取蝦皮主图")
if state is self._displayed_state:
self.pull_button.setText("正在拉取...")
self._update_context_actions(state)
self._status("开始拉取蝦皮主图,可继续操作其他套图任务", "info")
def _on_pull_failed(self, state, error):
self._status("拉取蝦皮主图失败:%s" % _user_error(error), "danger")
def _on_pull_finished(self, state, result):
state.pull_worker = None
state.pull_thread = None
if state is self._displayed_state:
self.pull_button.setText("拉取蝦皮主图")
if result.get("ok") is False:
self._message("拉取蝦皮主图失败", _user_error(result.get("error")))
else:
project = result.get("project")
if project is not None:
state.project_id = int(project.id)
assets = [
asset
for asset in (result.get("assets") or [])
if asset.status != image_studio.ASSET_STATUS_MISSING
][: image_studio_images.MAX_ORIGINAL_ASSETS]
self._queue_original_downloads(state, assets)
self._status("已读取%d张蝦皮主图,正在后台下载" % len(assets), "success")
if state is self._displayed_state:
self._refresh_originals(state)
self._update_context_actions(state)
def _queue_original_downloads(self, state, assets):
if state is None:
return
queued = set(state.download_queue) | set(state.downloads)
for asset in assets or []:
if _asset_usable(asset) or not getattr(asset, "remote_url", None):
continue
asset_id = int(asset.id)
if asset_id not in queued:
state.download_queue.append(asset_id)
queued.add(asset_id)
self._start_queued_downloads(state)
def _start_queued_downloads(self, state):
while state.download_queue and len(state.downloads) < ORIGINAL_DOWNLOAD_CONCURRENCY:
asset_id = int(state.download_queue.pop(0))
worker = ImageStudioDownloadOriginalWorker(
asset_id,
db_path=self.db_path,
config=self.config,
max_retries=2,
)
worker.finished.connect(
lambda result, state=state, asset_id=asset_id: self._on_download_finished(
state, asset_id, result
)
)
worker.cancelled.connect(
lambda result, state=state, asset_id=asset_id: self._on_download_finished(
state, asset_id, result
)
)
worker.failed.connect(
lambda row, error, state=state, asset_id=asset_id: self._status(
"商品原图 #%d 下载失败:%s" % (asset_id, _user_error(error)),
"danger",
)
)
thread = self._start_thread(worker, "商品套图下载原图")
state.downloads[asset_id] = (worker, thread)
def _on_download_finished(self, state, asset_id, result):
state.downloads.pop(int(asset_id), None)
if result.get("ok") is False:
self._status(
"商品原图 #%d 下载失败:%s" % (asset_id, _user_error(result.get("error"))),
"danger",
)
elif not result.get("cancelled"):
self._status("商品原图 #%d 已下载" % asset_id, "success")
if state is self._displayed_state:
self._refresh_originals(state)
self._start_queued_downloads(state)
def _rebuild_categories(self, state):
while self.category_grid.count():
item = self.category_grid.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
settings = product_suite.normalize_suite_settings(state.settings)
state.settings = settings
categories = settings["categories"]
ordered = product_suite.category_order(settings)
if state.active_category not in ordered:
state.active_category = ordered[0]
for index, name in enumerate(ordered):
name_button = CategoryChipButton("%s %s" % (name, categories.get(name, 0)))
name_button.setCheckable(True)
name_button.setChecked(name == state.active_category)
name_button.setMinimumHeight(30)
name_button.setStyleSheet(
"QPushButton { text-align: center; padding: 3px 7px; }"
"QPushButton:checked { background: #eaf2ff; border-color: #2f6fed; color: #2459c4; }"
)
name_button.clicked.connect(
lambda checked=False, category=name: self.select_category(category)
)
if name not in product_suite.FIXED_CATEGORIES:
name_button.setToolTip("单击展开数量;双击重命名")
name_button.doubleClicked.connect(
lambda old_name=name: self.rename_custom_category(old_name)
)
else:
name_button.setToolTip("单击展开数量")
self.category_grid.addWidget(name_button, index // 3, index % 3)
detail_row = (len(ordered) + 2) // 3
active_name = state.active_category
detail = QFrame()
detail.setObjectName("suiteCategoryCounter")
detail_layout = QHBoxLayout(detail)
detail_layout.setContentsMargins(6, 3, 6, 3)
active_label = QLabel(active_name)
active_label.setStyleSheet("font-weight: 600;")
detail_layout.addWidget(active_label)
detail_layout.addStretch(1)
minus = QToolButton()
minus.setText("−")
minus.setToolTip("减少%s数量" % active_name)
minus.setAccessibleName("减少%s数量" % active_name)
minus.setMinimumSize(28, 28)
minus.clicked.connect(
lambda checked=False, category=active_name: self.change_category_count(category, -1)
)
detail_layout.addWidget(minus)
count_label = QLabel(str(categories.get(active_name, 0)))
count_label.setAlignment(Qt.AlignCenter)
count_label.setMinimumWidth(28)
detail_layout.addWidget(count_label)
plus = QToolButton()
plus.setText("+")
plus.setToolTip("增加%s数量" % active_name)
plus.setAccessibleName("增加%s数量" % active_name)
plus.setMinimumSize(28, 28)
plus.clicked.connect(
lambda checked=False, category=active_name: self.change_category_count(category, 1)
)
detail_layout.addWidget(plus)
if active_name not in product_suite.FIXED_CATEGORIES:
remove = QToolButton()
remove.setText("×")
remove.setToolTip("删除%s分类" % active_name)
remove.setAccessibleName("删除%s分类" % active_name)
remove.setMinimumSize(28, 28)
remove.clicked.connect(
lambda checked=False, category=active_name: self.delete_custom_category(category)
)
detail_layout.addWidget(remove)
self.category_grid.addWidget(detail, detail_row, 0, 1, 3)
self._refresh_totals(state)
def select_category(self, name):
state = self._displayed_state
if state is None:
return
state.active_category = str(name)
self._rebuild_categories(state)
def change_category_count(self, name, delta):
state = self._displayed_state
if state is None:
return
settings = product_suite.normalize_suite_settings(state.settings)
categories = dict(settings["categories"])
categories[name] = max(0, int(categories.get(name, 0)) + int(delta))
settings["categories"] = categories
state.settings = settings
self._persist_state(state)
self._rebuild_categories(state)
def add_custom_category(self, checked=False):
if self._displayed_state is None:
return
self.add_category_button.hide()
self.custom_category_edit.clear()
self.custom_category_edit.show()
self.custom_category_edit.setFocus(Qt.OtherFocusReason)
def _commit_custom_category(self):
state = self._displayed_state
if state is None:
return
name = self.custom_category_edit.text()
settings = product_suite.normalize_suite_settings(state.settings)
error = product_suite.suite_name_error(name, settings["categories"])
if error:
self._message("分类名称无效", error)
self.custom_category_edit.setFocus(Qt.OtherFocusReason)
return
categories = dict(settings["categories"])
categories[name] = 1
settings["categories"] = categories
settings["custom_category_order"] = list(settings["custom_category_order"]) + [name]
state.settings = product_suite.normalize_suite_settings(settings)
state.active_category = name
self._persist_state(state)
self.custom_category_edit.hide()
self.add_category_button.show()
self._rebuild_categories(state)
def _finish_custom_category_edit(self):
if not self.custom_category_edit.isHidden() and not self.custom_category_edit.text().strip():
self.custom_category_edit.hide()
self.add_category_button.show()
def rename_custom_category(self, old_name):
state = self._displayed_state
if state is None or old_name in product_suite.FIXED_CATEGORIES:
return
name, ok = QInputDialog.getText(
self,
"重命名自定义分类",
"分类名称",
text=old_name,
)
if not ok or name == old_name:
return
settings = product_suite.normalize_suite_settings(state.settings)
error = product_suite.suite_name_error(name, settings["categories"], old_name=old_name)
if error:
self._message("分类名称无效", error)
return
categories = dict(settings["categories"])
count = categories.pop(old_name, 1)
categories[name] = count
settings["categories"] = categories
settings["custom_category_order"] = [
name if value == old_name else value
for value in settings["custom_category_order"]
]
state.settings = product_suite.normalize_suite_settings(settings)
state.active_category = name
self._persist_state(state)
self._rebuild_categories(state)
def delete_custom_category(self, name):
state = self._displayed_state
if state is None or name in product_suite.FIXED_CATEGORIES:
return
settings = product_suite.normalize_suite_settings(state.settings)
categories = dict(settings["categories"])
categories.pop(name, None)
settings["categories"] = categories
settings["custom_category_order"] = [
value for value in settings["custom_category_order"] if value != name
]
state.settings = product_suite.normalize_suite_settings(settings)
state.active_category = product_suite.FIXED_CATEGORIES[0]
self._persist_state(state)
self._rebuild_categories(state)
def _refresh_totals(self, state):
image_count = sum(1 for asset in self._original_assets(state) if _asset_usable(asset))
total = product_suite.suite_total_count(state.settings, image_count)
self.category_total_label.setText("合计 %d 张" % total)
if not state.generation_running():
self.generate_button.setText("生成套图(%d)" % total)
def start_ai_write(self, checked=False):
state = self._displayed_state
if state is None:
return
if state.ai_worker is not None:
self._status("当前套图任务正在AI帮写", "info")
return
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
if not local_assets:
self._message("缺少商品原图", "请先添加或拉取至少一张可用商品原图。")
return
self._save_controls_to_state(state)
context = (
"商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s"
% (
state.item_id or "未填写",
state.settings["platform"],
state.settings["country"],
state.settings["language"],
state.prompt or "无",
)
)
instruction = (
"请为电商商品图片生成简洁、可执行的商品卖点与画面要求。"
"只输出可直接用于生图的中文提示词,包含核心卖点、目标人群、使用场景和禁用元素,"
"不要虚构商品参数。"
)
state.ai_prompt_snapshot = state.prompt
state.ai_started_at = time.monotonic()
worker = ProductSuiteAiWriteWorker(
instruction,
context,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
state.ai_worker = worker
worker.finished.connect(lambda result, state=state: self._on_ai_write_finished(state, result))
worker.cancelled.connect(lambda result, state=state: self._on_ai_write_finished(state, result))
worker.failed.connect(lambda row, error, state=state: self._on_ai_write_failed(state, error))
state.ai_thread = self._start_thread(worker, "商品套图AI帮写")
if state is self._displayed_state:
self._apply_running_state(state)
self._status("AI帮写已开始,可切换到其他套图任务", "info")
def cancel_ai_write(self, checked=False):
state = self._displayed_state
if state is not None and state.ai_worker is not None:
state.ai_worker.cancel()
self._status("已请求取消AI帮写", "warning")
def _on_ai_write_failed(self, state, error):
self._status("AI帮写失败:%s" % _user_error(error), "danger")
def _on_ai_write_finished(self, state, result):
state.ai_worker = None
state.ai_thread = None
state.ai_started_at = None
if result.get("ok") is False:
self._message("AI帮写失败", _user_error(result.get("error")))
elif not result.get("cancelled"):
text = str(result.get("text") or "").strip()
if text:
if state.prompt != state.ai_prompt_snapshot:
state.pending_ai_text = text
if state is self._displayed_state:
self._offer_pending_ai_result(state)
else:
self._status("AI帮写已完成,切回对应套图任务后确认是否采用", "success")
else:
state.prompt = text
self._persist_state(state)
if state is self._displayed_state:
self._loading = True
try:
self.prompt_edit.setPlainText(text)
finally:
self._loading = False
self._status("AI帮写已完成", "success")
if state is self._displayed_state:
self._apply_running_state(state)
def _offer_pending_ai_result(self, state):
if not state.pending_ai_text:
return
if self._confirm(
"采用AI帮写结果",
"AI帮写期间卖点内容已被修改。是否用AI帮写结果覆盖当前内容?",
):
state.prompt = state.pending_ai_text
self._loading = True
try:
self.prompt_edit.setPlainText(state.prompt)
finally:
self._loading = False
self._persist_state(state)
state.pending_ai_text = ""
def toggle_generation(self, checked=False):
state = self._displayed_state
if state is None:
return
if state.generation_running():
if self._confirm(
"停止生成套图",
"确认取消当前任务吗?已提交任务会在安全边界停止。",
destructive=True,
):
state.worker.cancel()
self.generate_button.setText("正在停止...")
self.generate_button.setEnabled(False)
self._status("已请求停止当前套图任务", "warning")
return
self.start_generation(state)
def start_generation(self, state, specs=None):
if state.generation_running():
self._status("当前套图任务仍在生成", "warning")
return False
if state is self._displayed_state:
self._save_controls_to_state(state)
if state.project_id is None and self._bind_project(state) is None:
return False
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
if not local_assets:
self._message("缺少商品原图", "请先添加图片,或等待蝦皮主图下载完成。")
return False
if specs is None and not state.prompt.strip():
self._message("商品卖点不能为空", "请填写商品卖点与要求,或使用AI帮写。")
return False
specs = list(specs or product_suite.build_job_specs(
local_assets,
state.prompt,
state.settings,
state.item_id,
))
if not specs:
self._message("生成数量为0", "请至少把一个套图分类的数量设为1。")
return False
if len(specs) > product_suite.MAX_GENERATION_COUNT_WITHOUT_CONFIRM:
if not self._confirm(
"确认生成数量",
"本轮将生成%d张图片,预计耗时和点数较多。确认继续吗?" % len(specs),
):
return False
self._persist_state(state)
worker = ProductSuiteGenerateWorker(
state.project_id,
specs,
aspect_ratio=state.settings["ratio"],
db_path=self.db_path,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
state.worker = worker
state.done = 0
state.failed = 0
state.total = len(specs)
state.started_at = time.monotonic()
state.current_job_ids = []
state.show_history = False
worker.progress.connect(lambda payload, state=state: self._on_generation_progress(state, payload))
worker.finished.connect(lambda result, state=state: self._on_generation_finished(state, result))
worker.cancelled.connect(lambda result, state=state: self._on_generation_finished(state, result))
worker.failed.connect(lambda row, error, state=state: self._on_generation_failed(state, error))
state.thread = self._start_thread(worker, "商品套图生成")
if state is self._displayed_state:
self._loading = True
try:
self.history_button.setChecked(False)
finally:
self._loading = False
self._apply_running_state(state)
self._refresh_results(state)
self._status("商品套图生成已开始,共%d张;可切换到其他任务" % len(specs), "info")
return True
def _on_generation_progress(self, state, payload):
state.total = int(payload.get("total", state.total) or state.total)
state.done = int(payload.get("done", state.done) or 0)
state.failed = int(payload.get("failed", state.failed) or 0)
job_ids = payload.get("job_ids")
if job_ids:
state.current_job_ids = [int(job_id) for job_id in job_ids]
if state is self._displayed_state:
self._refresh_results(state)
self._refresh_elapsed()
def _on_generation_failed(self, state, error):
self._status("商品套图生成失败:%s" % _user_error(error), "danger")
def _on_generation_finished(self, state, result):
state.worker = None
state.thread = None
state.done = int(result.get("success", 0) or 0) + int(result.get("failed", 0) or 0) + int(
result.get("cancelled", 0) or 0
)
state.failed = int(result.get("failed", state.failed) or 0)
state.total = int(result.get("total", state.total) or state.total)
elapsed = int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0
state.started_at = None
if state is self._displayed_state:
self._apply_running_state(state)
self._refresh_results(state)
self._refresh_elapsed()
if state.key not in self._states:
return
if result.get("ok") is False:
if state is self._displayed_state:
self._message("商品套图生成失败", _user_error(result.get("error")))
else:
self._status("套图任务%d生成失败" % state.serial, "danger")
return
success = int(result.get("success", 0) or 0)
cancelled = int(result.get("cancelled", 0) or 0)
if state is self._displayed_state:
self._message(
"商品套图生成完成",
"本轮共%d张:成功%d张,失败%d张,停止%d张;总用时%d秒。"
% (state.total, success, state.failed, cancelled, elapsed),
icon=QMessageBox.Information,
)
self._status("商品套图生成完成:成功%d张,失败%d张" % (success, state.failed), "success")
def _apply_running_state(self, state):
generation_running = state.generation_running()
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.original_list.setEnabled(not generation_running)
self._refresh_original_selection_controls()
for widget in (
self.platform_combo,
self.country_combo,
self.language_combo,
self.ratio_combo,
self.per_image_checkbox,
self.prompt_edit,
self.add_category_button,
):
widget.setEnabled(not generation_running)
self.generate_button.setEnabled(True)
if generation_running:
self.generate_button.setText("停止生成")
self.generate_button.setStyleSheet(
"QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }"
"QPushButton:hover { background: #a40e26; }"
)
else:
self.generate_button.setStyleSheet(
"QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }"
"QPushButton:hover { background: #245fce; }"
)
self._refresh_totals(state)
ai_running = state.ai_worker is not None
self.ai_write_button.setEnabled(not ai_running and not generation_running)
self.ai_cancel_button.setVisible(ai_running)
self._update_context_actions(state)
def _refresh_elapsed(self):
state = self._displayed_state
if state is None:
return
elapsed = int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0
self.elapsed_label.setText(
"套图 %d/%d(%d秒) · 失败 %d" % (
state.done,
state.total,
elapsed,
state.failed,
)
)
self.progress_bar.setRange(0, max(1, state.total))
self.progress_bar.setValue(min(state.done, max(1, state.total)))
if state.ai_worker is not None and state.ai_started_at is not None:
ai_elapsed = int(max(0, time.monotonic() - state.ai_started_at))
self.ai_write_button.setText("AI 帮写中(%d秒)" % ai_elapsed)
else:
self.ai_write_button.setText("AI 帮写")
def _toggle_history(self, checked):
if self._loading or self._displayed_state is None:
return
self._displayed_state.show_history = bool(checked)
self.history_button.setText("返回本轮" if checked else "历史生成")
self._refresh_results(self._displayed_state)
def _jobs_for_state(self, state):
if state.project_id is None:
return []
try:
jobs = image_studio.list_jobs(state.project_id, path=self.db_path)
except Exception as exc:
self._status("生成结果读取失败:%s" % _user_error(exc), "danger")
return []
if state.show_history:
return jobs
current = set(state.current_job_ids)
return [job for job in jobs if int(job.id) in current]
def _refresh_results(self, state):
while self.result_grid.count():
item = self.result_grid.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
jobs = self._jobs_for_state(state)
assets = {}
if state.project_id is not None:
try:
assets = {
int(asset.id): asset
for asset in image_studio.list_assets(state.project_id, path=self.db_path)
}
except Exception:
assets = {}
success = 0
if not jobs:
empty = QLabel("还没有生成结果,点击左侧“生成套图”开始")
empty.setAlignment(Qt.AlignCenter)
empty.setStyleSheet("color: #6b7280; padding: 48px;")
self.result_grid.addWidget(empty, 0, 0, 1, 3)
else:
columns = max(2, min(4, max(1, self.result_scroll.viewport().width() // 200)))
for index, job in enumerate(jobs):
asset = assets.get(int(job.output_asset_id or 0))
if job.status == "succeeded" and asset is not None and _asset_usable(asset):
success += 1
card = SuiteResultCard(job, asset)
card.previewRequested.connect(self.preview_job)
card.retryRequested.connect(self.retry_job)
card.menuRequested.connect(self._show_job_menu)
card.deleteRequested.connect(self.delete_job_asset)
self.result_grid.addWidget(card, index // columns, index % columns)
self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success))
self.undo_button.setVisible(bool(state.undo_records))
self.history_button.setText("返回本轮" if state.show_history else "历史生成")
def preview_job(self, job):
asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None
if asset is None or not _asset_usable(asset):
self._status("这张生成图片尚不可预览", "warning")
return
ProductSuitePreviewDialog(asset.local_path, "%s预览" % job.job_type, self).exec()
def retry_job(self, job):
state = self._displayed_state
if state is None:
return
if state.generation_running():
self._message("当前任务正在生成", "请等待当前生成结束或停止后再重试单张图片。")
return
spec = {
"source_asset_id": job.source_asset_id,
"job_type": job.job_type,
"prompt": job.prompt,
}
self.start_generation(state, specs=[spec])
def _show_job_menu(self, job, global_position):
menu = QMenu(self)
preview_action = menu.addAction("预览")
copy_action = menu.addAction("复制路径")
folder_action = menu.addAction("打开文件夹")
retry_action = menu.addAction("重新生成")
delete_action = menu.addAction("删除")
action = menu.exec(global_position)
asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None
if action is preview_action:
self.preview_job(job)
elif action is copy_action:
if asset is None or not _asset_usable(asset):
self._status("当前图片没有可复制的本地路径", "warning")
else:
QApplication.clipboard().setText(asset.local_path)
self._status("生成图片路径已复制", "success")
elif action is folder_action:
if asset is not None and _asset_usable(asset):
try:
file_manager.open_in_file_manager(os.path.dirname(asset.local_path))
except Exception as exc:
self._message("打开文件夹失败", _user_error(exc))
else:
self.open_project_folder()
elif action is retry_action:
self.retry_job(job)
elif action is delete_action:
self.delete_job_asset(job)
def delete_job_asset(self, job):
state = self._displayed_state
if state is None or not job.output_asset_id:
return
if not self._confirm("删除生成图片", "确认把这张图片移到当前商品的废纸篓吗?"):
return
try:
record = image_studio_images.trash_generated_asset(
job.output_asset_id,
path=self.db_path,
config=self.config,
)
except Exception as exc:
self._message("删除生成图片失败", _user_error(exc))
return
state.undo_records.append(record)
self._refresh_results(state)
self._status("生成图片已删除,可点击“撤销删除”恢复", "success")
def undo_delete(self, checked=False):
state = self._displayed_state
if state is None or not state.undo_records:
return
record = state.undo_records.pop()
try:
image_studio_images.restore_trashed_asset(
record,
path=self.db_path,
config=self.config,
)
except Exception as exc:
state.undo_records.append(record)
self._message("撤销删除失败", _user_error(exc))
return
self._refresh_results(state)
self._status("生成图片已恢复", "success")
def _show_more_menu(self):
menu = QMenu(self)
delete_all_action = menu.addAction("删除当前视图全部图片")
if menu.exec(self.more_button.mapToGlobal(self.more_button.rect().bottomLeft())) is delete_all_action:
self.delete_all_visible_results()
def delete_all_visible_results(self):
state = self._displayed_state
if state is None:
return
jobs = [job for job in self._jobs_for_state(state) if job.output_asset_id]
if not jobs:
self._status("当前视图没有可删除的生成图片", "warning")
return
if not self._confirm(
"删除全部生成图片",
"确认把当前视图中的%d张生成图片移到废纸篓吗?" % len(jobs),
destructive=True,
):
return
failures = 0
for job in jobs:
try:
state.undo_records.append(
image_studio_images.trash_generated_asset(
job.output_asset_id,
path=self.db_path,
config=self.config,
)
)
except Exception:
failures += 1
self._refresh_results(state)
if failures:
self._message("部分图片未删除", "有%d张图片无法删除,请查看当前结果。" % failures)
else:
self._status("当前视图的生成图片已移到废纸篓", "success")
def open_project_folder(self, checked=False):
state = self._displayed_state
if state is None or state.project_id is None:
self._message("未绑定商品", "请先选择账号并输入商品ID。")
return
project = image_studio.get_project(state.project_id, path=self.db_path)
if project is None:
self._message("商品任务不存在", "当前商品套图任务已经不存在。")
return
target = image_studio.default_project_image_dirs(project, config=self.config)["generated"]
os.makedirs(target, exist_ok=True)
try:
file_manager.open_in_file_manager(target)
except Exception as exc:
self._message("打开文件夹失败", _user_error(exc))
def resizeEvent(self, event):
super().resizeEvent(event)
if not self._result_refresh_pending:
self._result_refresh_pending = True
QTimer.singleShot(80, self._refresh_after_resize)
def _refresh_after_resize(self):
self._result_refresh_pending = False
if self._displayed_state is not None:
self._refresh_results(self._displayed_state)
def closeEvent(self, event):
for state in list(self._states.values()) + list(self._retired_states):
for worker in (state.worker, state.pull_worker, state.import_worker, state.ai_worker):
if worker is not None and hasattr(worker, "cancel"):
worker.cancel()
for worker, thread in list(state.downloads.values()):
worker.cancel()
super().closeEvent(event)