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

5224 lines
208 KiB
Python
Raw Normal View History

"""Tab 6: project-bound product suite generation workspace."""
from __future__ import annotations
import os
import re
import time
import uuid
from dataclasses import dataclass, field
from PySide6.QtCore import (
QByteArray,
QBuffer,
QIODevice,
QRect,
QSize,
Qt,
QTimer,
Signal,
Slot,
)
from PySide6.QtGui import QColor, QIcon, QImage, QImageReader, QKeySequence, QPainter, QPixmap
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDialog,
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,
ai,
appconfig,
diagnostics,
image_studio,
image_studio_images,
product_suite,
prompts,
)
from .. import file_manager
from ..image_preview import ImagePreviewDialog
from ..product_suite_prompt_dialog import ProductSuitePromptDialog
from ..widgets import _emit_status, run_worker
from ..workers import (
ImageStudioDownloadOriginalWorker,
ImageStudioPullImagesWorker,
ProductSuiteAiWriteWorker,
ProductSuiteGenerateWorker,
ProductSuiteHistoryExportWorker,
ProductSuiteImportImagesWorker,
)
ORIGINAL_DOWNLOAD_CONCURRENCY = 2
ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1
2026-07-16 16:39:40 +08:00
PROMPT_AUTOSAVE_DELAY_MS = 500
_PRODUCT_SUITE_THREAD_REFS = {}
_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE)
2026-07-16 16:39:40 +08:00
class AutoHeightPlainTextEdit(QPlainTextEdit):
"""A plain-text editor that delegates scrolling to its containing page."""
def __init__(self, parent=None, minimum_height=96):
super().__init__(parent)
self._minimum_content_height = max(1, int(minimum_height))
self._height_update_pending = False
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setFixedHeight(self._minimum_content_height)
self.document().contentsChanged.connect(self.schedule_height_update)
self.document().documentLayout().documentSizeChanged.connect(
self.schedule_height_update
)
self.schedule_height_update()
def schedule_height_update(self, *args):
if self._height_update_pending:
return
self._height_update_pending = True
QTimer.singleShot(0, self.refresh_height)
def refresh_height(self):
self._height_update_pending = False
text_height = 0.0
block = self.document().firstBlock()
while block.isValid():
layout = block.layout()
line_count = max(1, layout.lineCount())
for index in range(line_count):
line = layout.lineAt(index)
text_height += (
line.height()
if line.isValid()
else self.fontMetrics().lineSpacing()
)
block = block.next()
margins = self.contentsMargins()
target = int(
text_height
+ (self.document().documentMargin() * 2)
+ margins.top()
+ margins.bottom()
+ (self.frameWidth() * 2)
+ 6
)
target = max(self._minimum_content_height, target)
if self.height() != target:
self.setFixedHeight(target)
self.updateGeometry()
def resizeEvent(self, event):
super().resizeEvent(event)
self.schedule_height_update()
def showEvent(self, event):
super().showEvent(event)
self.schedule_height_update()
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
EMPTY_STATE_TEXT = "暂无商品原图"
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 paintEvent(self, event):
super().paintEvent(event)
if self.count() != 0:
return
painter = QPainter(self.viewport())
painter.setPen(QColor("#6b7280"))
painter.drawText(
self.viewport().rect().adjusted(12, 8, -12, -8),
Qt.AlignCenter,
self.EMPTY_STATE_TEXT,
)
painter.end()
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
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, self.count())
if count == 0:
return 1
columns = self.content_column_count()
return (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 SuiteCategoryRow(QFrame):
countChangeRequested = Signal(str, int)
renameRequested = Signal(str)
deleteRequested = Signal(str)
def __init__(self, name, count, *, helper="", custom=False, parent=None):
super().__init__(parent)
self.category_name = str(name)
self._controls_enabled = True
self.setObjectName("suiteCategoryRow")
self.setProperty("categoryName", self.category_name)
self.setStyleSheet(
"QFrame#suiteCategoryRow { border: none; border-bottom: 1px solid #eaeef2; }"
)
layout = QGridLayout(self)
layout.setContentsMargins(6, 5, 4, 6)
layout.setHorizontalSpacing(8)
layout.setVerticalSpacing(2)
layout.setColumnStretch(0, 1)
self.name_label = QLabel(self.category_name)
self.name_label.setObjectName("suiteCategoryNameLabel")
self.name_label.setStyleSheet("font-weight: 600; color: #24292f;")
layout.addWidget(self.name_label, 0, 0)
controls_widget = QWidget()
controls = QHBoxLayout(controls_widget)
controls.setContentsMargins(0, 0, 0, 0)
controls.setSpacing(4)
self.minus_button = QToolButton()
self.minus_button.setObjectName("suiteCategoryMinusButton")
self.minus_button.setText("−")
self.minus_button.setToolTip("减少%s数量" % self.category_name)
self.minus_button.setAccessibleName("减少%s数量" % self.category_name)
self.minus_button.setMinimumSize(28, 28)
self.minus_button.clicked.connect(
lambda checked=False: self.countChangeRequested.emit(self.category_name, -1)
)
controls.addWidget(self.minus_button)
self.count_label = QLabel()
self.count_label.setObjectName("suiteCategoryCountLabel")
self.count_label.setAlignment(Qt.AlignCenter)
self.count_label.setMinimumWidth(28)
controls.addWidget(self.count_label)
self.plus_button = QToolButton()
self.plus_button.setObjectName("suiteCategoryPlusButton")
self.plus_button.setText("+")
self.plus_button.setToolTip("增加%s数量" % self.category_name)
self.plus_button.setAccessibleName("增加%s数量" % self.category_name)
self.plus_button.setMinimumSize(28, 28)
self.plus_button.clicked.connect(
lambda checked=False: self.countChangeRequested.emit(self.category_name, 1)
)
controls.addWidget(self.plus_button)
self.rename_button = None
self.delete_button = None
if custom:
self.rename_button = QToolButton()
self.rename_button.setObjectName("suiteCategoryRenameButton")
self.rename_button.setText("改名")
self.rename_button.setToolTip("重命名%s分类" % self.category_name)
self.rename_button.setAccessibleName("重命名%s分类" % self.category_name)
self.rename_button.setMinimumSize(48, 28)
self.rename_button.clicked.connect(
lambda checked=False: self.renameRequested.emit(self.category_name)
)
controls.addWidget(self.rename_button)
self.delete_button = QToolButton()
self.delete_button.setObjectName("suiteCategoryDeleteButton")
self.delete_button.setText("×")
self.delete_button.setToolTip("删除%s分类" % self.category_name)
self.delete_button.setAccessibleName("删除%s分类" % self.category_name)
self.delete_button.setMinimumSize(28, 28)
self.delete_button.clicked.connect(
lambda checked=False: self.deleteRequested.emit(self.category_name)
)
controls.addWidget(self.delete_button)
layout.addWidget(controls_widget, 0, 1, 2 if helper else 1, 1, Qt.AlignTop)
self.helper_label = None
if helper:
self.helper_label = QLabel(str(helper))
self.helper_label.setObjectName("suiteCategoryHelperLabel")
self.helper_label.setStyleSheet("color: #6b7280;")
self.helper_label.setWordWrap(True)
layout.addWidget(self.helper_label, 1, 0)
self.set_count(count)
def set_count(self, count):
normalized = max(0, int(count or 0))
self.count_label.setText(str(normalized))
self.minus_button.setEnabled(self._controls_enabled and normalized > 0)
def count(self):
return int(self.count_label.text() or 0)
def set_controls_enabled(self, enabled):
self._controls_enabled = bool(enabled)
self.minus_button.setEnabled(self._controls_enabled and self.count() > 0)
self.plus_button.setEnabled(self._controls_enabled)
if self.rename_button is not None:
self.rename_button.setEnabled(self._controls_enabled)
if self.delete_button is not None:
self.delete_button.setEnabled(self._controls_enabled)
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)
class SuiteHistoryImageCard(QFrame):
"""Read-only image card used by the product-suite history dialog."""
selected = Signal(object)
previewRequested = Signal(object, object)
menuRequested = Signal(object, object, object)
def __init__(self, job, asset=None, retry_count=0, parent=None):
super().__init__(parent)
self.job = job
self.asset = asset
self.retry_count = max(0, int(retry_count or 0))
self._selected = False
self.setObjectName("suiteHistoryImageCard")
self.setFixedSize(166, 190)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(
lambda point: self.menuRequested.emit(
self.job,
self.asset,
self.mapToGlobal(point),
)
)
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(4)
title = QLabel(str(getattr(job, "job_type", "套图") or "套图"))
title.setStyleSheet("font-weight: 600; color: #24292f;")
title.setAttribute(Qt.WA_TransparentForMouseEvents, True)
layout.addWidget(title)
image = QLabel()
image.setObjectName("suiteHistoryImage")
image.setAlignment(Qt.AlignCenter)
image.setFixedSize(154, 118)
image.setAttribute(Qt.WA_TransparentForMouseEvents, True)
status = str(getattr(job, "status", "pending") or "pending")
if asset is not None and _asset_usable(asset):
image.setPixmap(_image_pixmap(asset.local_path, QSize(154, 118)))
else:
image.setPixmap(
_placeholder_pixmap(
"图片文件不可用" if status == "succeeded" else self._status_text(status),
QSize(154, 118),
"#fff8c5" if status == "succeeded" else "#f3f4f6",
)
)
layout.addWidget(image)
footer_text = self._status_text(status)
if self.retry_count:
footer_text += " · 重试%d次" % self.retry_count
footer = QLabel(footer_text)
footer.setObjectName("suiteHistoryImageStatus")
footer.setWordWrap(True)
footer.setMaximumHeight(34)
footer.setAttribute(Qt.WA_TransparentForMouseEvents, True)
layout.addWidget(footer)
tooltip = [
"类型:%s" % str(getattr(job, "job_type", "套图") or "套图"),
"状态:%s" % self._status_text(status),
"生成时间:%s" % _history_time_text(getattr(job, "created_at", "")),
]
if self.retry_count:
tooltip.append("本槽位已重试%d次" % self.retry_count)
if asset is None or not _asset_usable(asset):
tooltip.append("本地图片文件不可用")
self.setToolTip("\n".join(tooltip))
self._apply_selected_style()
@staticmethod
def _status_text(status):
return {
"pending": "等待提交",
"submitted": "已提交",
"running": "生成中",
"succeeded": "生成成功",
"failed": "生成失败",
"expired": "任务过期",
"cancelled": "已停止",
}.get(str(status or ""), "处理中")
def set_selected(self, selected):
self._selected = bool(selected)
self._apply_selected_style()
def _apply_selected_style(self):
border = "#0969da" if self._selected else "#d8dee4"
background = "#eef6ff" if self._selected else "#ffffff"
self.setStyleSheet(
"QFrame#suiteHistoryImageCard {"
"border: %dpx solid %s; border-radius: 6px; background: %s;"
"}" % (2 if self._selected else 1, border, background)
)
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.selected.emit(self)
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.LeftButton:
self.previewRequested.emit(self.job, self.asset)
super().mouseDoubleClickEvent(event)
def _history_time_text(value):
text = str(value or "").strip().replace("T", " ")
if not text:
return "时间未知"
return text[:19]
class ProductSuiteHistoryDialog(QDialog):
"""Read-only, project-scoped generation history grouped by persisted rounds."""
PAGE_SIZE = 20
def __init__(self, project_id, *, db_path=None, parent=None):
super().__init__(parent)
self.project_id = int(project_id)
self.db_path = db_path
self._offset = 0
self._has_more = False
self._assets = {}
self._selected_card = None
self._round_count = 0
self._available_image_count = 0
self._project_unavailable = False
self.setObjectName("suiteHistoryDialog")
self.setWindowTitle("历史生成记录")
self.setModal(False)
self.setAttribute(Qt.WA_DeleteOnClose, True)
self.setMinimumSize(740, 520)
self.resize(940, 680)
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 14, 14, 14)
layout.setSpacing(10)
header = QHBoxLayout()
header.setSpacing(10)
header_text = QVBoxLayout()
header_text.setSpacing(2)
self.context_label = QLabel()
self.context_label.setObjectName("suiteHistoryContextLabel")
self.context_label.setStyleSheet("font-weight: 600; color: #24292f;")
self.summary_label = QLabel()
self.summary_label.setObjectName("suiteHistorySummaryLabel")
self.summary_label.setStyleSheet("color: #57606a;")
header_text.addWidget(self.context_label)
header_text.addWidget(self.summary_label)
header.addLayout(header_text, 1)
self.refresh_button = QPushButton("刷新")
self.refresh_button.setObjectName("suiteHistoryRefreshButton")
self.refresh_button.setToolTip("重新读取当前商品的生成历史")
self.refresh_button.clicked.connect(self.refresh_history)
header.addWidget(self.refresh_button)
layout.addLayout(header)
self.notice_label = QLabel()
self.notice_label.setObjectName("suiteHistoryNoticeLabel")
self.notice_label.setWordWrap(True)
self.notice_label.hide()
layout.addWidget(self.notice_label)
self.scroll = QScrollArea()
self.scroll.setObjectName("suiteHistoryScrollArea")
self.scroll.setWidgetResizable(True)
self.history_content = QWidget()
self.history_content.setObjectName("suiteHistoryContent")
self.history_layout = QVBoxLayout(self.history_content)
self.history_layout.setContentsMargins(0, 0, 0, 0)
self.history_layout.setSpacing(12)
self.history_layout.setAlignment(Qt.AlignTop)
self.scroll.setWidget(self.history_content)
layout.addWidget(self.scroll, 1)
self.load_more_button = QPushButton("加载更多")
self.load_more_button.setObjectName("suiteHistoryLoadMoreButton")
self.load_more_button.clicked.connect(self.load_more)
self.load_more_button.hide()
layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter)
self.refresh_history()
def refresh_history(self, checked=False):
scroll_value = self.scroll.verticalScrollBar().value()
self._clear_history_content()
self._offset = 0
self._has_more = False
self._assets = {}
self._round_count = 0
self._available_image_count = 0
if not self._load_next_page():
return
QTimer.singleShot(
0,
lambda: self.scroll.verticalScrollBar().setValue(
min(scroll_value, self.scroll.verticalScrollBar().maximum())
),
)
def load_more(self, checked=False):
if self._has_more:
self._load_next_page()
def _load_next_page(self):
try:
project = image_studio.get_project(self.project_id, path=self.db_path)
if project is None:
self._set_project_unavailable()
return False
self._project_unavailable = False
self._set_context(project)
if not self._assets:
self._assets = {
int(asset.id): asset
for asset in image_studio.list_assets(
self.project_id,
path=self.db_path,
)
}
rounds = image_studio.list_generation_rounds(
self.project_id,
limit=self.PAGE_SIZE,
offset=self._offset,
path=self.db_path,
)
except Exception as exc:
self._set_error("历史记录读取失败:%s" % _user_error(exc))
return False
self.notice_label.hide()
if not rounds and self._offset == 0:
self._show_empty_state()
else:
for round_info in rounds:
self._add_round(round_info)
self._offset += len(rounds)
self._has_more = len(rounds) == self.PAGE_SIZE
self.load_more_button.setVisible(self._has_more)
self.load_more_button.setEnabled(self._has_more)
self._update_summary()
return True
def _set_context(self, project):
account_name = str(project.account_name or project.account_alias or "未命名账号")
item_text = "临时草稿" if image_studio.is_draft_project(project) else "商品ID:%s" % project.item_id
self.context_label.setText("店铺:%s · %s" % (account_name, item_text))
def _update_summary(self):
refreshed = time.strftime("%H:%M:%S")
self.summary_label.setText(
"已加载 %d 轮 · 可用图片 %d 张 · 最近刷新 %s"
% (self._round_count, self._available_image_count, refreshed)
)
def _clear_history_content(self):
self._selected_card = None
while self.history_layout.count():
item = self.history_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
def _set_project_unavailable(self):
self._project_unavailable = True
self._clear_history_content()
self.context_label.setText("当前商品项目不可用")
self.summary_label.setText("该商品项目已删除或不可访问")
self._set_notice("当前商品项目已删除或不可访问,无法继续读取历史生成记录。", "#cf222e")
self.refresh_button.setEnabled(False)
self.load_more_button.hide()
def _set_error(self, message):
self._clear_history_content()
self._set_notice(message, "#cf222e")
self.load_more_button.hide()
self.refresh_button.setEnabled(True)
def _set_notice(self, text, color="#57606a"):
self.notice_label.setText(str(text))
self.notice_label.setStyleSheet("color: %s; padding: 8px 0;" % color)
self.notice_label.show()
def _show_empty_state(self):
empty = QLabel("暂无历史生成记录,完成套图生成后会自动出现在这里")
empty.setObjectName("suiteHistoryEmptyLabel")
empty.setAlignment(Qt.AlignCenter)
empty.setStyleSheet("color: #6b7280; padding: 56px;")
self.history_layout.addWidget(empty)
def _add_round(self, round_info):
try:
jobs = image_studio.list_generation_round_current_jobs(
self.project_id,
round_info.generation_round_key,
path=self.db_path,
)
attempts = image_studio.list_generation_round_attempts(
self.project_id,
round_info.generation_round_key,
path=self.db_path,
)
except Exception as exc:
self._set_error("历史轮次读取失败:%s" % _user_error(exc))
return
section = QWidget()
section.setObjectName("suiteHistoryRound")
section.setStyleSheet(
"QWidget#suiteHistoryRound { border-bottom: 1px solid #d8dee4; }"
)
layout = QVBoxLayout(section)
layout.setContentsMargins(2, 2, 2, 12)
layout.setSpacing(7)
header = QHBoxLayout()
header.setSpacing(8)
time_label = QLabel(
"旧版历史记录" if round_info.is_legacy else "生成于 %s" % _history_time_text(round_info.created_at)
)
time_label.setStyleSheet("font-weight: 600; color: #24292f;")
header.addWidget(time_label)
if round_info.is_current:
current = QLabel("当前")
current.setObjectName("suiteHistoryCurrentBadge")
current.setStyleSheet(
"color: #0969da; background: #ddf4ff; border: 1px solid #54aeff; "
"border-radius: 6px; padding: 1px 6px; font-weight: 600;"
)
header.addWidget(current)
elif round_info.is_legacy:
legacy = QLabel("旧版")
legacy.setStyleSheet(
"color: #57606a; background: #f6f8fa; border: 1px solid #d8dee4; "
"border-radius: 6px; padding: 1px 6px;"
)
header.addWidget(legacy)
header.addStretch(1)
stats = self._round_stats_text(round_info)
stats_label = QLabel(stats)
stats_label.setObjectName("suiteHistoryRoundStats")
stats_label.setStyleSheet("color: #57606a;")
header.addWidget(stats_label)
layout.addLayout(header)
attempts_by_slot = {}
for attempt in attempts:
slot = getattr(attempt, "generation_slot_index", None)
if slot is not None:
attempts_by_slot[int(slot)] = attempts_by_slot.get(int(slot), 0) + 1
if not jobs:
message = QLabel("本轮没有可展示的图片,已保留生成状态记录。")
message.setStyleSheet("color: #6b7280; padding: 12px 0;")
layout.addWidget(message)
else:
grid = QGridLayout()
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(8)
grid.setVerticalSpacing(8)
columns = 5
for index, job in enumerate(jobs):
asset = self._assets.get(int(job.output_asset_id or 0))
slot = getattr(job, "generation_slot_index", None)
retry_count = max(0, attempts_by_slot.get(int(slot), 1) - 1) if slot is not None else 0
card = SuiteHistoryImageCard(job, asset, retry_count)
card.selected.connect(self._select_card)
card.previewRequested.connect(self._preview_job)
card.menuRequested.connect(self._show_job_menu)
grid.addWidget(card, index // columns, index % columns)
if asset is not None and _asset_usable(asset):
self._available_image_count += 1
layout.addLayout(grid)
self.history_layout.addWidget(section)
self._round_count += 1
@staticmethod
def _round_stats_text(round_info):
parts = ["记录 %d" % int(round_info.job_count or 0)]
if round_info.slot_count:
parts.append("图片 %d" % int(round_info.slot_count))
parts.append("成功 %d" % int(round_info.succeeded_count or 0))
if round_info.failed_count:
parts.append("失败 %d" % int(round_info.failed_count))
if round_info.cancelled_count:
parts.append("停止 %d" % int(round_info.cancelled_count))
if round_info.active_count:
parts.append("进行中 %d" % int(round_info.active_count))
if round_info.retry_count:
parts.append("重试 %d" % int(round_info.retry_count))
return " · ".join(parts)
def _select_card(self, card):
if self._selected_card is card:
return
if self._selected_card is not None:
self._selected_card.set_selected(False)
self._selected_card = card
self._selected_card.set_selected(True)
def _preview_job(self, job, asset):
if asset is None or not _asset_usable(asset):
self._set_notice("这张图片的本地文件不可用,无法预览。", "#9a6700")
return
ProductSuitePreviewDialog(
asset.local_path,
"%s预览" % str(getattr(job, "job_type", "套图") or "套图"),
self,
).exec()
def _show_job_menu(self, job, asset, global_position):
menu = QMenu(self)
preview_action = menu.addAction("预览")
copy_action = menu.addAction("复制路径")
folder_action = menu.addAction("打开所在文件夹")
action = menu.exec(global_position)
if action is preview_action:
self._preview_job(job, asset)
elif action is copy_action:
if asset is None or not _asset_usable(asset):
self._set_notice("当前图片没有可复制的本地路径。", "#9a6700")
return
QApplication.clipboard().setText(asset.local_path)
self._set_notice("图片路径已复制。", "#1a7f37")
elif action is folder_action:
if asset is None or not _asset_usable(asset):
self._set_notice("当前图片没有可打开的本地文件夹。", "#9a6700")
return
try:
file_manager.open_in_file_manager(os.path.dirname(asset.local_path))
except Exception as exc:
self._set_notice("打开文件夹失败:%s" % _user_error(exc), "#cf222e")
def closeEvent(self, event):
self._clear_history_content()
super().closeEvent(event)
class ProductSuiteRoundPreviewDialog(ImagePreviewDialog):
"""Browse the usable output images that belong to one generation round."""
def __init__(self, entries, start_index=0, title="套图原图预览", parent=None):
self._entries = [
(str(path or ""), str(label or "生成图片"))
for path, label in list(entries or [])
if str(path or "")
]
if not self._entries:
self._entries = [("", "生成图片")]
self._entry_index = max(0, min(int(start_index or 0), len(self._entries) - 1))
self._title_prefix = str(title or "套图原图预览")
path, label = self._entries[self._entry_index]
super().__init__(path, self._entry_title(label), parent)
self.setObjectName("suiteHistoryRoundPreviewDialog")
navigation = QHBoxLayout()
navigation.setSpacing(6)
self.previous_button = QToolButton()
self.previous_button.setObjectName("suiteHistoryPreviewPreviousButton")
self.previous_button.setText("上一张")
self.previous_button.setToolTip("查看上一张生成图片")
self.previous_button.clicked.connect(self.show_previous)
navigation.addWidget(self.previous_button)
self.index_label = QLabel()
self.index_label.setObjectName("suiteHistoryPreviewIndexLabel")
self.index_label.setAlignment(Qt.AlignCenter)
self.index_label.setMinimumWidth(86)
navigation.addWidget(self.index_label)
self.next_button = QToolButton()
self.next_button.setObjectName("suiteHistoryPreviewNextButton")
self.next_button.setText("下一张")
self.next_button.setToolTip("查看下一张生成图片")
self.next_button.clicked.connect(self.show_next)
navigation.addWidget(self.next_button)
navigation.addStretch(1)
self.layout().insertLayout(1, navigation)
self._update_navigation()
def _entry_title(self, label):
return "%s · %s" % (self._title_prefix, str(label or "生成图片"))
def _update_navigation(self):
total = len(self._entries)
self.index_label.setText("%d / %d" % (self._entry_index + 1, total))
self.previous_button.setEnabled(total > 1 and self._entry_index > 0)
self.next_button.setEnabled(total > 1 and self._entry_index < total - 1)
def show_previous(self, checked=False):
if self._entry_index > 0:
self._show_entry(self._entry_index - 1)
def show_next(self, checked=False):
if self._entry_index < len(self._entries) - 1:
self._show_entry(self._entry_index + 1)
def _show_entry(self, index):
self._entry_index = max(0, min(int(index), len(self._entries) - 1))
path, label = self._entries[self._entry_index]
self._base_title = self._entry_title(label)
self._source = self._load_source(path)
self.fit_to_window = True
self._display_size = QSize()
self.setWindowTitle(self._window_title())
self._update_navigation()
self._render()
class SuiteGlobalHistoryThumbnail(QLabel):
"""Fixed-size thumbnail that opens its source image on double-click."""
previewRequested = Signal(int)
def __init__(self, job, asset, entry_index, parent=None):
super().__init__(parent)
self.entry_index = int(entry_index)
self._available = asset is not None and _asset_usable(asset)
self.setObjectName("suiteGlobalHistoryThumbnail")
self.setAlignment(Qt.AlignCenter)
self.setFixedSize(104, 78)
self.setToolTip(
"%s · %s"
% (
str(getattr(job, "job_type", "套图") or "套图"),
SuiteHistoryImageCard._status_text(getattr(job, "status", "")),
)
)
if self._available:
self.setPixmap(_image_pixmap(asset.local_path, QSize(104, 78)))
self.setCursor(Qt.PointingHandCursor)
else:
self.setPixmap(
_placeholder_pixmap(
"图片不可用",
QSize(104, 78),
"#fff8c5",
)
)
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.LeftButton and self._available:
self.previewRequested.emit(self.entry_index)
super().mouseDoubleClickEvent(event)
class SuiteGlobalHistoryRoundRow(QFrame):
"""One fixed-height, cross-project history row for a generation round."""
previewRequested = Signal(object, int)
exportRequested = Signal(object)
MAX_THUMBNAILS = 5
def __init__(self, round_info, jobs, assets_by_job, parent=None):
super().__init__(parent)
self.round_info = round_info
self.jobs = list(jobs or [])
self.assets_by_job = dict(assets_by_job or {})
self._usable_entries = [
(job, self.assets_by_job.get(int(getattr(job, "id", 0) or 0)))
for job in self.jobs
if self.assets_by_job.get(int(getattr(job, "id", 0) or 0)) is not None
and _asset_usable(self.assets_by_job.get(int(getattr(job, "id", 0) or 0)))
]
self.setObjectName("suiteGlobalHistoryRoundRow")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setFixedHeight(118)
self.setStyleSheet(
"QFrame#suiteGlobalHistoryRoundRow {"
"border: 1px solid #d8dee4; border-radius: 6px; background: #ffffff;"
"}"
)
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(10)
meta = QVBoxLayout()
meta.setSpacing(3)
meta.setContentsMargins(0, 0, 0, 0)
time_label = QLabel(
"旧版历史记录"
if bool(getattr(round_info, "is_legacy", False))
else "生成于 %s" % _history_time_text(getattr(round_info, "created_at", ""))
)
time_label.setObjectName("suiteGlobalHistoryTime")
time_label.setStyleSheet("font-weight: 600; color: #24292f;")
meta.addWidget(time_label)
account = str(
getattr(round_info, "account_name", "")
or getattr(round_info, "account_alias", "")
or "未命名店铺"
)
item_text = (
"临时草稿"
if str(getattr(round_info, "binding_state", ""))
== image_studio.PROJECT_BINDING_DRAFT
else "商品ID:%s" % str(getattr(round_info, "item_id", "") or "未填写")
)
meta.addWidget(QLabel("店铺:%s" % account))
meta.addWidget(QLabel(item_text))
layout.addLayout(meta, 0)
thumbnails = QHBoxLayout()
thumbnails.setSpacing(6)
thumbnails.setContentsMargins(0, 0, 0, 0)
shown_entries = self._usable_entries[: self.MAX_THUMBNAILS]
for entry_index, (job, asset) in enumerate(shown_entries):
thumbnail = SuiteGlobalHistoryThumbnail(job, asset, entry_index)
thumbnail.previewRequested.connect(self._emit_preview)
thumbnails.addWidget(thumbnail)
if not shown_entries:
placeholder = QLabel("本轮没有可预览图片")
placeholder.setObjectName("suiteGlobalHistoryNoImage")
placeholder.setAlignment(Qt.AlignCenter)
placeholder.setFixedSize(150, 78)
placeholder.setStyleSheet("color: #6b7280; background: #f6f8fa;")
thumbnails.addWidget(placeholder)
remaining = max(0, len(self._usable_entries) - len(shown_entries))
if remaining:
more = QLabel("+%d" % remaining)
more.setObjectName("suiteGlobalHistoryMoreImages")
more.setAlignment(Qt.AlignCenter)
more.setFixedSize(42, 78)
more.setToolTip("本轮还有%d张可预览生成图片" % remaining)
more.setStyleSheet("color: #57606a; background: #f6f8fa; border-radius: 4px;")
thumbnails.addWidget(more)
thumbnails.addStretch(1)
layout.addLayout(thumbnails, 1)
badges = QVBoxLayout()
badges.setSpacing(5)
badges.setContentsMargins(0, 0, 0, 0)
if bool(getattr(round_info, "is_current", False)):
current = QLabel("当前")
current.setObjectName("suiteGlobalHistoryCurrentBadge")
current.setAlignment(Qt.AlignCenter)
current.setStyleSheet(
"color: #0969da; background: #ddf4ff; border: 1px solid #54aeff; "
"border-radius: 5px; padding: 2px 6px; font-weight: 600;"
)
badges.addWidget(current)
elif bool(getattr(round_info, "is_legacy", False)):
legacy = QLabel("旧版")
legacy.setAlignment(Qt.AlignCenter)
legacy.setStyleSheet(
"color: #57606a; background: #f6f8fa; border: 1px solid #d8dee4; "
"border-radius: 5px; padding: 2px 6px;"
)
badges.addWidget(legacy)
stats = QLabel(self._stats_text(round_info))
stats.setObjectName("suiteGlobalHistoryStats")
stats.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
stats.setStyleSheet("color: #57606a;")
badges.addWidget(stats)
badges.addStretch(1)
self.export_button = QPushButton("导出本轮")
self.export_button.setObjectName("suiteGlobalHistoryExportButton")
self.export_button.setEnabled(bool(getattr(round_info, "succeeded_count", 0)))
self.export_button.setToolTip("复制本轮成功生成的图片到所选目录")
self.export_button.clicked.connect(lambda: self.exportRequested.emit(self))
badges.addWidget(self.export_button)
layout.addLayout(badges, 0)
@staticmethod
def _stats_text(round_info):
parts = ["成功 %d" % int(getattr(round_info, "succeeded_count", 0) or 0)]
failed = int(getattr(round_info, "failed_count", 0) or 0)
cancelled = int(getattr(round_info, "cancelled_count", 0) or 0)
active = int(getattr(round_info, "active_count", 0) or 0)
retry = int(getattr(round_info, "retry_count", 0) or 0)
if failed:
parts.append("失败 %d" % failed)
if cancelled:
parts.append("停止 %d" % cancelled)
if active:
parts.append("进行中 %d" % active)
if retry:
parts.append("重试 %d" % retry)
return " · ".join(parts)
def set_exporting(self, exporting):
self.export_button.setEnabled(
not bool(exporting) and bool(getattr(self.round_info, "succeeded_count", 0))
)
self.export_button.setText("正在导出" if exporting else "导出本轮")
def _emit_preview(self, entry_index):
self.previewRequested.emit(self, int(entry_index))
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.LeftButton and self._usable_entries:
self._emit_preview(0)
super().mouseDoubleClickEvent(event)
class ProductSuiteGlobalHistoryDialog(QDialog):
"""Read-only global product-suite history with per-round preview and export."""
PAGE_SIZE = 30
def __init__(
self,
*,
current_project_id=None,
current_project_only=False,
db_path=None,
parent=None,
):
super().__init__(parent)
self.db_path = db_path
self.current_project_id = None
self._offset = 0
self._has_more = False
self._round_count = 0
self._available_image_count = 0
self._history_rows = []
self._asset_cache = {}
self._export_worker = None
self._export_thread = None
self._export_error_handled = False
self.setObjectName("suiteGlobalHistoryDialog")
self.setWindowTitle("套图历史生成记录")
self.setModal(False)
self.setAttribute(Qt.WA_DeleteOnClose, True)
self.setMinimumSize(940, 560)
self.resize(1160, 720)
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 14, 14, 14)
layout.setSpacing(10)
header = QHBoxLayout()
title_layout = QVBoxLayout()
title_layout.setSpacing(2)
self.context_label = QLabel("全部商品的套图生成历史")
self.context_label.setObjectName("suiteGlobalHistoryContextLabel")
self.context_label.setStyleSheet("font-weight: 600; color: #24292f;")
self.summary_label = QLabel()
self.summary_label.setObjectName("suiteGlobalHistorySummaryLabel")
self.summary_label.setStyleSheet("color: #57606a;")
title_layout.addWidget(self.context_label)
title_layout.addWidget(self.summary_label)
header.addLayout(title_layout, 1)
self.refresh_button = QPushButton("刷新")
self.refresh_button.setObjectName("suiteGlobalHistoryRefreshButton")
self.refresh_button.setToolTip("重新读取套图历史生成记录")
self.refresh_button.clicked.connect(self.refresh_history)
header.addWidget(self.refresh_button)
layout.addLayout(header)
filters = QHBoxLayout()
filters.setSpacing(8)
filters.addWidget(QLabel("店铺"))
self.account_filter_edit = QLineEdit()
self.account_filter_edit.setObjectName("suiteGlobalHistoryAccountFilter")
self.account_filter_edit.setPlaceholderText("店铺或账号")
self.account_filter_edit.setClearButtonEnabled(True)
self.account_filter_edit.returnPressed.connect(self.refresh_history)
filters.addWidget(self.account_filter_edit, 1)
filters.addWidget(QLabel("商品ID"))
self.item_filter_edit = QLineEdit()
self.item_filter_edit.setObjectName("suiteGlobalHistoryItemFilter")
self.item_filter_edit.setPlaceholderText("输入商品ID")
self.item_filter_edit.setClearButtonEnabled(True)
self.item_filter_edit.returnPressed.connect(self.refresh_history)
filters.addWidget(self.item_filter_edit, 1)
self.current_project_checkbox = QCheckBox("仅当前商品")
self.current_project_checkbox.setObjectName("suiteGlobalHistoryCurrentProjectFilter")
self.current_project_checkbox.toggled.connect(self.refresh_history)
filters.addWidget(self.current_project_checkbox)
self.filter_button = QPushButton("筛选")
self.filter_button.setObjectName("suiteGlobalHistoryFilterButton")
self.filter_button.clicked.connect(self.refresh_history)
filters.addWidget(self.filter_button)
layout.addLayout(filters)
self.notice_label = QLabel()
self.notice_label.setObjectName("suiteGlobalHistoryNoticeLabel")
self.notice_label.setWordWrap(True)
self.notice_label.hide()
layout.addWidget(self.notice_label)
self.scroll = QScrollArea()
self.scroll.setObjectName("suiteGlobalHistoryScrollArea")
self.scroll.setWidgetResizable(True)
self.history_content = QWidget()
self.history_content.setObjectName("suiteGlobalHistoryContent")
self.history_layout = QVBoxLayout(self.history_content)
self.history_layout.setContentsMargins(0, 0, 0, 0)
self.history_layout.setSpacing(7)
self.history_layout.setAlignment(Qt.AlignTop)
self.scroll.setWidget(self.history_content)
layout.addWidget(self.scroll, 1)
self.load_more_button = QPushButton("加载更多")
self.load_more_button.setObjectName("suiteGlobalHistoryLoadMoreButton")
self.load_more_button.clicked.connect(self.load_more)
self.load_more_button.hide()
layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter)
self.set_current_project(current_project_id, refresh=False)
self.set_current_project_filter(current_project_only, refresh=False)
self.refresh_history()
def set_current_project(self, project_id, *, refresh=True):
try:
normalized = int(project_id) if project_id is not None else None
except (TypeError, ValueError):
normalized = None
changed = normalized != self.current_project_id
self.current_project_id = normalized
previous = self.current_project_checkbox.blockSignals(True)
self.current_project_checkbox.setEnabled(normalized is not None)
self.current_project_checkbox.setToolTip(
"只显示当前商品项目的历史生成记录"
if normalized is not None
else "当前没有可筛选的商品项目"
)
if normalized is None:
self.current_project_checkbox.setChecked(False)
self.current_project_checkbox.blockSignals(previous)
if refresh and (changed or self.current_project_checkbox.isChecked()):
self.refresh_history()
def set_current_project_filter(self, enabled, *, refresh=True):
should_filter = bool(enabled) and self.current_project_id is not None
changed = self.current_project_checkbox.isChecked() != should_filter
previous = self.current_project_checkbox.blockSignals(True)
self.current_project_checkbox.setChecked(should_filter)
self.current_project_checkbox.blockSignals(previous)
if refresh and changed:
self.refresh_history()
def refresh_history(self, checked=False):
scroll_value = self.scroll.verticalScrollBar().value()
self._clear_history_content()
self._offset = 0
self._has_more = False
self._round_count = 0
self._available_image_count = 0
self._asset_cache = {}
if not self._load_next_page():
return
QTimer.singleShot(
0,
lambda: self.scroll.verticalScrollBar().setValue(
min(scroll_value, self.scroll.verticalScrollBar().maximum())
),
)
def load_more(self, checked=False):
if self._has_more:
self._load_next_page()
def _load_next_page(self):
try:
project_id = (
self.current_project_id
if self.current_project_checkbox.isChecked()
else None
)
rounds = image_studio.list_global_generation_rounds(
account_query=self.account_filter_edit.text(),
item_query=self.item_filter_edit.text(),
project_id=project_id,
limit=self.PAGE_SIZE,
offset=self._offset,
path=self.db_path,
)
except Exception as exc:
self._set_error("历史记录读取失败:%s" % _user_error(exc))
return False
self.notice_label.hide()
if not rounds and self._offset == 0:
self._show_empty_state()
else:
for round_info in rounds:
self._add_round(round_info)
self._offset += len(rounds)
self._has_more = len(rounds) == self.PAGE_SIZE
self.load_more_button.setVisible(self._has_more)
self.load_more_button.setEnabled(self._has_more)
self._update_summary()
return True
def _add_round(self, round_info):
try:
jobs = image_studio.list_generation_round_current_jobs(
round_info.project_id,
round_info.generation_round_key,
path=self.db_path,
)
assets_by_job = {}
for job in jobs:
asset_id = getattr(job, "output_asset_id", None)
if not asset_id:
continue
asset_id = int(asset_id)
if asset_id not in self._asset_cache:
self._asset_cache[asset_id] = image_studio.get_asset(
asset_id,
path=self.db_path,
)
assets_by_job[int(job.id)] = self._asset_cache[asset_id]
except Exception as exc:
self._set_notice("部分历史记录无法读取:%s" % _user_error(exc), "#cf222e")
return
row = SuiteGlobalHistoryRoundRow(round_info, jobs, assets_by_job)
row.previewRequested.connect(self._preview_round)
row.exportRequested.connect(self._export_round)
self.history_layout.addWidget(row)
self._history_rows.append(row)
self._round_count += 1
self._available_image_count += len(row._usable_entries)
def _preview_round(self, row, start_index):
entries = [
(
asset.local_path,
str(getattr(job, "job_type", "套图") or "套图"),
)
for job, asset in row._usable_entries
]
if not entries:
self._set_notice("本轮没有可预览的本地生成图片。", "#9a6700")
return
title = "商品ID %s" % str(getattr(row.round_info, "item_id", "") or "临时草稿")
ProductSuiteRoundPreviewDialog(
entries,
start_index=start_index,
title=title,
parent=self,
).exec()
def _export_round(self, row):
if self._export_worker is not None:
self._set_notice("正在导出另一轮图片,请稍候。", "#9a6700")
return
parent_dir = QFileDialog.getExistingDirectory(self, "选择导出父目录")
if not parent_dir:
return
worker = ProductSuiteHistoryExportWorker(
row.round_info.project_id,
row.round_info.generation_round_key,
parent_dir,
db_path=self.db_path,
)
thread = run_worker(worker, thread_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))
worker.finished.connect(self._on_export_finished)
worker.cancelled.connect(self._on_export_cancelled)
worker.failed.connect(self._on_export_failed)
self._export_worker = worker
self._export_thread = thread
self._export_error_handled = False
self._set_export_controls(True)
thread.start()
def _on_export_finished(self, summary):
if dict(summary or {}).get("ok") is False:
if not self._export_error_handled:
self._on_export_failed(-1, dict(summary or {}).get("error") or "导出失败")
return
self._reset_export_state()
summary = dict(summary or {})
if summary.get("cancelled"):
self._set_notice("导出已停止,已完成的图片保留在导出目录中。", "#9a6700")
return
file_count = int(summary.get("file_count", 0) or 0)
skipped_count = int(summary.get("skipped_count", 0) or 0)
message = "本轮已导出%d张生成图片" % file_count
if skipped_count:
message += ",略过%d张不可用图片" % skipped_count
self._set_notice(message, "#1a7f37")
target_dir = str(summary.get("target_dir") or "")
if not target_dir:
return
box = QMessageBox(self)
box.setWindowTitle("导出完成")
box.setText(message)
open_button = box.addButton("打开目录", QMessageBox.AcceptRole)
box.addButton("确定", QMessageBox.RejectRole)
box.setDefaultButton(open_button)
box.exec()
if box.clickedButton() is open_button:
try:
file_manager.open_in_file_manager(target_dir)
except Exception as exc:
self._set_notice("打开导出目录失败:%s" % _user_error(exc), "#cf222e")
def _on_export_cancelled(self, summary):
self._reset_export_state()
self._set_notice("导出已停止,已完成的图片保留在导出目录中。", "#9a6700")
def _on_export_failed(self, _row, error):
if self._export_error_handled:
return
self._export_error_handled = True
self._reset_export_state()
self._set_notice("导出本轮失败:%s" % _user_error(error), "#cf222e")
def _reset_export_state(self):
self._export_worker = None
self._export_thread = None
self._set_export_controls(False)
def _set_export_controls(self, exporting):
for row in list(self._history_rows):
try:
row.set_exporting(exporting)
except RuntimeError:
continue
def _update_summary(self):
refreshed = time.strftime("%H:%M:%S")
self.summary_label.setText(
"已加载 %d 轮 · 可预览图片 %d 张 · 最近刷新 %s"
% (self._round_count, self._available_image_count, refreshed)
)
def _clear_history_content(self):
self._history_rows = []
while self.history_layout.count():
item = self.history_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
def _set_error(self, message):
self._clear_history_content()
self._set_notice(message, "#cf222e")
self.load_more_button.hide()
self.refresh_button.setEnabled(True)
def _set_notice(self, text, color="#57606a"):
self.notice_label.setText(str(text))
self.notice_label.setStyleSheet("color: %s; padding: 8px 0;" % color)
self.notice_label.show()
def _show_empty_state(self):
empty = QLabel("暂无套图历史生成记录,完成套图生成后会自动出现在这里")
empty.setObjectName("suiteGlobalHistoryEmptyLabel")
empty.setAlignment(Qt.AlignCenter)
empty.setStyleSheet("color: #6b7280; padding: 56px;")
self.history_layout.addWidget(empty)
def closeEvent(self, event):
if self._export_worker is not None:
self._export_worker.cancel()
self._clear_history_content()
super().closeEvent(event)
@dataclass
class SuiteTaskState:
key: int
serial: int
account_alias: str = ""
item_id: str = ""
project_id: int = None
project_binding_state: str = ""
prompt: str = ""
2026-07-16 16:39:40 +08:00
last_saved_prompt: str = ""
settings: dict = field(default_factory=product_suite.default_suite_settings)
current_job_ids: list = field(default_factory=list)
current_generation_round_key: str = ""
generation_job_ids: list = field(default_factory=list)
generation_mode: str = "batch"
generation_retry_job_id: int = None
generation_round_key: str = ""
worker: object = None
thread: object = None
generation_run_token: str = ""
generation_stop_requested: bool = False
generation_terminal_streak: int = 0
pull_worker: object = None
pull_thread: object = None
pull_run_token: str = ""
pull_stop_requested: bool = False
pull_cleanup_mode: str = "keep"
pull_before_asset_ids: set = field(default_factory=set)
pull_before_asset_states: list = field(default_factory=list)
pull_asset_ids: set = field(default_factory=set)
pull_download_asset_ids: set = field(default_factory=set)
pull_download_failed: int = 0
pull_started_at: float = None
import_worker: object = None
import_thread: object = None
import_created_draft: bool = False
ai_worker: object = None
ai_thread: object = None
download_queue: list = field(default_factory=list)
downloads: dict = field(default_factory=dict)
download_tokens: 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)
def generation_running(self):
return self.worker is not None
def pull_running(self):
return bool(self.pull_run_token)
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.product_suite_prompt_path = appconfig.product_suite_prompt_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
2026-07-16 16:39:40 +08:00
self._prompt_save_timers = {}
self._generation_run_states = {}
self._pull_run_states = {}
self._original_list_context = None
self._loading = False
self._result_refresh_pending = False
self._prompt_template_init_error = ""
self._history_dialog = None
try:
prompts.ensure_default_product_suite_prompt(self.product_suite_prompt_path)
except prompts.PromptError as exc:
self._prompt_template_init_error = str(exc)
self._build_ui()
self._connect_signals()
self.refresh_accounts()
self._restore_draft_tasks()
if not self._states:
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()
self.generation_watchdog_timer = QTimer(self)
self.generation_watchdog_timer.setInterval(750)
self.generation_watchdog_timer.timeout.connect(self._check_generation_watchdogs)
self.generation_watchdog_timer.start()
if self._prompt_template_init_error:
self._status(self._prompt_template_init_error, "danger")
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.setToolTip("查看当前商品的历史生成记录")
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")
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)
self.item_id_hint_label = QLabel("请输入正确的商品ID")
self.item_id_hint_label.setObjectName("suiteItemIdHintLabel")
self.item_id_hint_label.setStyleSheet("color: #9a6700; font-weight: 600;")
self.item_id_hint_label.setToolTip("未绑定正式商品ID时,可添加本地图片和生成套图,但不能拉取蝦皮主图")
self.item_id_hint_label.setAccessibleName("商品ID提示")
self.item_id_hint_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
layout.addWidget(self.item_id_hint_label)
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()
self.prompt_title_layout = title_row
title = QLabel("商品卖点与要求")
self.prompt_title_label = title
title.setStyleSheet("font-weight: 600;")
title_row.addWidget(title)
self.ai_write_button = QPushButton("AI 帮写")
self.ai_write_button.setObjectName("suiteAiWriteButton")
title_row.addWidget(self.ai_write_button)
self.ai_cancel_button = QPushButton("取消")
self.ai_cancel_button.setObjectName("suiteAiCancelButton")
self.ai_cancel_button.hide()
title_row.addWidget(self.ai_cancel_button)
title_row.addStretch(1)
self.prompt_settings_button = QPushButton("提示词设置")
self.prompt_settings_button.setObjectName("suitePromptSettingsButton")
self.prompt_settings_button.setToolTip("编辑并预览套图最终提示词")
title_row.addWidget(self.prompt_settings_button)
layout.addLayout(title_row)
2026-07-16 16:39:40 +08:00
self.prompt_edit = AutoHeightPlainTextEdit()
self.prompt_edit.setObjectName("suitePromptEdit")
self.prompt_edit.setPlaceholderText("输入产品名称、卖点、目标人群、使用场景和禁用元素")
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(0)
self.category_grid.setVerticalSpacing(3)
self.category_grid.setColumnStretch(0, 1)
self.category_rows = {}
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.prompt_settings_button.clicked.connect(self.open_prompt_settings)
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.clicked.connect(self.open_history_dialog)
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,
confirm_text="确认",
cancel_text="取消",
):
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question)
box.setWindowTitle(str(title))
box.setText(str(message))
confirm_button = box.addButton(str(confirm_text), QMessageBox.AcceptRole)
box.addButton(str(cancel_text), QMessageBox.RejectRole)
if not destructive:
box.setDefaultButton(confirm_button)
box.exec()
return box.clickedButton() is confirm_button
def _confirm_new_generation_history(self, state, summary):
item_id = str(state.item_id or "临时草稿")
account_text = self._account_context_label(state)
latest = str(summary.latest_succeeded_at or "").replace("T", " ")
lines = [
"%s" % account_text,
"商品ID:%s" % item_id,
"该商品已有成功套图:%d轮,%d张。"
% (
int(summary.successful_round_count),
int(summary.successful_image_count),
),
]
if latest:
lines.append("最近成功时间:%s" % latest)
lines.append("继续会创建新一轮生成,已有历史结果会保留。")
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("已有套图生成记录")
box.setText("\n".join(lines))
history_button = box.addButton("查看历史", QMessageBox.ActionRole)
continue_button = box.addButton("继续生成新一轮", QMessageBox.AcceptRole)
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
box.setDefaultButton(cancel_button)
box.setEscapeButton(cancel_button)
box.exec()
clicked = box.clickedButton()
if clicked is history_button:
return "history"
if clicked is continue_button:
return "continue"
return "cancel"
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 _append_task_state(self, state, title=None):
self._states[state.key] = state
index = self.task_tabs.addTab(title or "套图任务 %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 _restore_draft_tasks(self):
try:
projects = image_studio.list_recoverable_draft_projects(path=self.db_path)
except Exception as exc:
self._status("临时草稿恢复失败:%s" % _user_error(exc), "danger")
return
for project in projects:
state = SuiteTaskState(
key=self._next_key,
serial=self._next_serial,
account_alias=project.account_alias,
project_id=int(project.id),
project_binding_state=project.binding_state,
prompt=str(project.draft_prompt or ""),
2026-07-16 16:39:40 +08:00
last_saved_prompt=str(project.draft_prompt or ""),
settings=product_suite.normalize_suite_settings(
image_studio.project_suite_settings(project)
),
)
self._next_key += 1
self._next_serial += 1
self._append_task_state(state, "临时草稿 %d" % state.serial)
if projects:
self._status("已恢复%d个临时草稿" % len(projects), "info")
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)
)
valid_aliases = {account.alias for account in self.accounts}
recent_alias = appconfig.product_suite_last_account_alias(self.config)
account_alias = source.account_alias if source is not None else recent_alias
if account_alias not in valid_aliases:
account_alias = self.accounts[0].alias if self.accounts else ""
if source is None and account_alias != recent_alias:
self._persist_last_account_alias(account_alias)
state = SuiteTaskState(
key=self._next_key,
serial=self._next_serial,
account_alias=account_alias,
prompt=(source.prompt if source is not None else ""),
settings=product_suite.normalize_suite_settings(initial_settings),
)
self._next_key += 1
self._next_serial += 1
return self._append_task_state(state)
def _set_task_title(self, state):
for index in range(self.task_tabs.count()):
if self.task_tabs.tabData(index) == state.key:
title = "临时草稿 %d" % state.serial if self._is_draft_state(state) else "套图任务 %d" % state.serial
self.task_tabs.setTabText(index, title)
return
def close_task(self, index):
key = self.task_tabs.tabData(index)
state = self._states.get(key)
if state is None:
return
2026-07-16 16:39:40 +08:00
if state is self._displayed_state:
self._save_controls_to_state(state)
self._flush_prompt_save(state)
project = self._state_project(state)
draft_action = None
if image_studio.is_draft_project(project) and image_studio.project_has_content(
project.id,
path=self.db_path,
):
draft_action = self._draft_close_action()
if draft_action == "cancel":
return
if state.generation_running():
if not self._confirm(
"关闭套图任务",
"任务生成中,关闭会取消该任务。确认关闭吗?",
destructive=True,
):
return
state.generation_stop_requested = True
state.worker.cancel()
if state.ai_worker is not None:
state.ai_worker.cancel()
if state.pull_running():
state.pull_stop_requested = True
state.pull_cleanup_mode = "keep"
if state.pull_worker is not None:
state.pull_worker.cancel()
self._cancel_pull_downloads(state)
if image_studio.is_draft_project(project):
if draft_action == "delete":
try:
image_studio.soft_delete_project(
project.id,
reason="用户关闭临时草稿",
path=self.db_path,
)
except Exception as exc:
self._message("删除临时草稿失败", _user_error(exc))
return
self._status("临时草稿已删除", "success")
else:
try:
image_studio.discard_empty_draft_project(project.id, path=self.db_path)
except Exception as exc:
self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger")
2026-07-16 16:39:40 +08:00
self._release_prompt_save_timer(state)
if state.generation_run_token:
self._generation_run_states.pop(state.generation_run_token, None)
if state.pull_run_token:
self._pull_run_states.pop(state.pull_run_token, None)
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 _draft_close_action(self):
box = QMessageBox(self)
box.setIcon(QMessageBox.Question)
box.setWindowTitle("关闭临时草稿")
box.setText("当前临时草稿包含图片或生成记录。请选择关闭后的处理方式。")
keep_button = box.addButton("保留草稿", QMessageBox.AcceptRole)
delete_button = box.addButton("删除草稿", QMessageBox.DestructiveRole)
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
box.setDefaultButton(keep_button)
box.exec()
clicked = box.clickedButton()
if clicked is keep_button:
return "keep"
if clicked is delete_button:
return "delete"
if clicked is cancel_button:
return "cancel"
return "cancel"
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)
2026-07-16 16:39:40 +08:00
self._flush_prompt_save(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._sync_state_project_binding(state)
self._restore_current_generation_results(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")))
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 self._is_draft_state(state):
self._message(
"不能切换账号",
"临时草稿已关联当前账号。请先绑定正式商品ID,或关闭草稿后再切换账号。",
)
self._loading = True
try:
self._set_combo_value(self.account_combo, state.account_alias)
finally:
self._loading = False
return
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._persist_last_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 #9a6700;")
self._status("商品ID只能输入数字", "warning")
self._update_context_actions(state)
return
self.item_id_edit.setStyleSheet("")
if self._is_draft_state(state):
if not item_id:
state.item_id = ""
self._update_context_actions(state)
return
if not self._confirm(
"绑定正式商品",
"将当前临时草稿绑定到商品%s吗?\n已添加图片和生成记录会继续保留。" % item_id,
):
self._loading = True
try:
self.item_id_edit.setText(state.item_id)
finally:
self._loading = False
self._update_context_actions(state)
return
2026-07-16 16:39:40 +08:00
self._flush_prompt_save(state)
try:
project = image_studio.bind_draft_project(
state.project_id,
item_id,
path=self.db_path,
)
except image_studio.ImageStudioProjectConflictError as exc:
self._message("商品项目已存在", _user_error(exc))
self._loading = True
try:
self.item_id_edit.setText(state.item_id)
finally:
self._loading = False
self._update_context_actions(state)
return
except Exception as exc:
self._message("绑定正式商品失败", _user_error(exc))
self._loading = True
try:
self.item_id_edit.setText(state.item_id)
finally:
self._loading = False
self._update_context_actions(state)
return
state.item_id = project.item_id
state.project_binding_state = project.binding_state
self._set_task_title(state)
self._status("临时草稿已绑定商品%s" % project.item_id, "success")
self._update_context_actions(state)
return
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._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):
2026-07-16 16:39:40 +08:00
self._flush_prompt_save(state)
state.project_id = None
state.project_binding_state = ""
2026-07-16 16:39:40 +08:00
state.last_saved_prompt = ""
state.current_job_ids = []
state.generation_job_ids = []
state.generation_mode = "batch"
state.generation_retry_job_id = None
state.pull_run_token = ""
state.pull_stop_requested = False
state.pull_before_asset_ids = set()
state.pull_before_asset_states = []
state.pull_asset_ids = set()
state.pull_download_asset_ids = set()
state.pull_download_failed = 0
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 _account_context_label(self, state):
account = self._account_for_alias(getattr(state, "account_alias", ""))
alias = str(getattr(state, "account_alias", "") or "")
name = str(getattr(account, "account_name", "") or "").strip()
if name and name != alias:
return "%s(%s)" % (name, alias)
return name or alias or "未选择店铺"
def _state_project(self, state, *, include_deleted=False):
if state is None or state.project_id is None:
return None
try:
project = image_studio.get_project(
state.project_id,
path=self.db_path,
include_deleted=include_deleted,
)
except Exception as exc:
self._status("商品套图项目读取失败:%s" % _user_error(exc), "danger")
return None
if project is not None:
state.project_binding_state = project.binding_state
return project
def _sync_state_project_binding(self, state):
return self._state_project(state)
def _is_draft_state(self, state):
if state is None:
return False
if state.project_binding_state in image_studio.PROJECT_BINDING_STATES:
return state.project_binding_state == image_studio.PROJECT_BINDING_DRAFT
project = self._state_project(state)
return image_studio.is_draft_project(project)
def _has_account_context(self, state, *, show_message=True):
if state is None or not state.account_alias:
if show_message:
self._message("未选择账号", "请先在顶部选择账号。")
return False
if self._account_for_alias(state.account_alias) is None:
if show_message:
self._message("账号不可用", "所选账号不存在,请到④账号管理刷新账号。")
return False
return True
def _valid_context(self, state, *, show_message=True):
if not self._has_account_context(state, show_message=show_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 _ensure_project_for_local_work(self, state):
if state is None:
return None
if state.project_id is not None:
2026-07-16 16:39:40 +08:00
self._flush_prompt_save(state)
return self._state_project(state)
if state.item_id:
return self._bind_project(state)
return self._create_draft_project(state)
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,
2026-07-16 16:39:40 +08:00
draft_prompt=state.prompt,
path=self.db_path,
)
except Exception as exc:
self._message("创建套图任务失败", _user_error(exc))
return None
state.project_id = int(project.id)
state.project_binding_state = project.binding_state
if previous_id != state.project_id:
self._restore_current_generation_results(state, force=True)
2026-07-16 16:39:40 +08:00
stored_prompt = str(project.draft_prompt or "")
if load_existing and previous_id != state.project_id:
2026-07-16 16:39:40 +08:00
state.prompt = stored_prompt
state.settings = product_suite.normalize_suite_settings(
image_studio.project_suite_settings(project)
)
if state is self._displayed_state:
self._load_state(state)
2026-07-16 16:39:40 +08:00
state.last_saved_prompt = stored_prompt
if state.prompt != stored_prompt:
self._flush_prompt_save(state)
return project
def _create_draft_project(self, state, *, announce=True):
if not self._has_account_context(state):
return None
account = self._account_for_alias(state.account_alias)
try:
project = image_studio.create_draft_project(
account,
draft_prompt=state.prompt,
path=self.db_path,
)
except Exception as exc:
self._message("创建临时草稿失败", _user_error(exc))
return None
state.project_id = int(project.id)
state.project_binding_state = project.binding_state
state.item_id = ""
2026-07-16 16:39:40 +08:00
state.last_saved_prompt = str(project.draft_prompt or "")
self._persist_state(state)
self._set_task_title(state)
if state is self._displayed_state:
self._update_context_actions(state)
if announce:
self._status("已创建临时草稿,可继续添加本地图片", "info")
return project
2026-07-16 16:39:40 +08:00
def _prompt_save_timer(self, state):
timer = self._prompt_save_timers.get(state.key)
if timer is None:
timer = QTimer(self)
timer.setSingleShot(True)
timer.setInterval(PROMPT_AUTOSAVE_DELAY_MS)
timer.timeout.connect(
lambda key=state.key: self._on_prompt_autosave_timeout(key)
)
self._prompt_save_timers[state.key] = timer
return timer
def _schedule_prompt_save(self, state):
if state is None:
return
if state.project_id is None:
if (
not str(state.prompt or "").strip()
or not state.account_alias
or self._account_for_alias(state.account_alias) is None
):
self._cancel_prompt_save(state)
return
self._prompt_save_timer(state).start()
return
2026-07-16 16:39:40 +08:00
if state.prompt == state.last_saved_prompt:
self._cancel_prompt_save(state)
return
self._prompt_save_timer(state).start()
def _cancel_prompt_save(self, state):
timer = self._prompt_save_timers.get(state.key) if state is not None else None
if timer is not None:
timer.stop()
def _release_prompt_save_timer(self, state):
timer = (
self._prompt_save_timers.pop(state.key, None)
if state is not None
else None
)
if timer is not None:
timer.stop()
timer.deleteLater()
def _on_prompt_autosave_timeout(self, state_key):
state = self._states.get(state_key)
if state is not None:
self._persist_prompt(state)
def _persist_prompt(self, state):
if state is None:
2026-07-16 16:39:40 +08:00
return True
prompt = str(state.prompt or "")
if state.project_id is None:
if not prompt.strip():
return True
if (
not state.account_alias
or self._account_for_alias(state.account_alias) is None
):
return True
if self._create_draft_project(state, announce=False) is None:
return False
2026-07-16 16:39:40 +08:00
if prompt == state.last_saved_prompt:
return True
try:
2026-07-16 16:39:40 +08:00
project = image_studio.update_project_prompt(
state.project_id,
2026-07-16 16:39:40 +08:00
prompt,
path=self.db_path,
)
2026-07-16 16:39:40 +08:00
if project is None:
raise image_studio.ImageStudioError("商品套图项目不存在")
except Exception as exc:
self._status("商品卖点自动保存失败:%s" % _user_error(exc), "danger")
return False
state.last_saved_prompt = prompt
return True
def _flush_prompt_save(self, state):
self._cancel_prompt_save(state)
return self._persist_prompt(state)
def _persist_state(self, state):
if state.project_id is None:
return True
prompt_saved = self._flush_prompt_save(state)
try:
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")
2026-07-16 16:39:40 +08:00
return False
return prompt_saved
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 _persist_last_account_alias(self, alias):
value = str(alias or "").strip()
if value == appconfig.product_suite_last_account_alias(self.config):
return
try:
if os.path.exists(self.config_path):
saved = appconfig.update_config(
{"product_suite": {"last_account_alias": value}},
path=self.config_path,
)
else:
base = dict(self.config)
suite = base.get("product_suite", {})
suite = dict(suite) if isinstance(suite, dict) else {}
suite["last_account_alias"] = value
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()
2026-07-16 16:39:40 +08:00
self._schedule_prompt_save(state)
def open_prompt_settings(self, checked=False):
state = self._displayed_state
if state is not None:
self._save_controls_to_state(state)
settings = state.settings
base_prompt = state.prompt
item_id = state.item_id or "未绑定商品"
else:
settings = product_suite.default_suite_settings()
base_prompt = ""
item_id = "未绑定商品"
dialog = ProductSuitePromptDialog(
prompt_path=self.product_suite_prompt_path,
base_prompt=base_prompt,
settings=settings,
item_id=item_id,
parent=self,
)
self.prompt_settings_dialog = dialog
dialog.exec()
def _update_context_actions(self, state):
is_draft = self._is_draft_state(state)
pull_running = bool(state and state.pull_running())
self.pull_button.setEnabled(
state is not None
and (
pull_running
or is_draft
or self._valid_context(state, show_message=False)
)
)
self.pull_button.setToolTip(
"正在停止拉取"
if pull_running and state.pull_stop_requested
else "停止拉取蝦皮主图"
if pull_running
else "需要先绑定正式商品ID"
if is_draft
else "拉取蝦皮主图"
)
self._refresh_item_id_hint(state)
self._refresh_add_images_action(state)
def _refresh_item_id_hint(self, state):
displayed_item_id = (
self.item_id_edit.text().strip()
if state is self._displayed_state
else str(state.item_id or "").strip()
)
item_id_invalid = bool(displayed_item_id and not image_studio.is_formal_item_id(displayed_item_id))
is_bound = bool(
state
and state.project_binding_state == image_studio.PROJECT_BINDING_BOUND
and image_studio.is_formal_item_id(state.item_id)
and not item_id_invalid
)
self.item_id_hint_label.setVisible(not is_bound)
if self._is_draft_state(state):
tooltip = "当前为临时草稿,可添加本地图片和生成套图;绑定正式商品ID后才能拉取蝦皮主图"
elif item_id_invalid:
tooltip = "商品ID只能输入数字;修正后可绑定或拉取蝦皮主图"
else:
tooltip = "未绑定正式商品ID时,可添加本地图片和生成套图,但不能拉取蝦皮主图"
self.item_id_hint_label.setToolTip(tooltip)
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 self._has_account_context(state, show_message=False)
and not full
and not generation_running
and not importing
)
if not self._has_account_context(state, show_message=False):
tooltip = "请先选择账号"
elif full:
tooltip = "已达到16张商品原图上限"
elif generation_running:
tooltip = "生成中不能添加商品原图"
elif importing:
tooltip = "正在添加商品原图"
else:
tooltip = "添加本地商品原图"
self.add_images_button.setToolTip(tooltip)
def _start_thread(self, worker, name):
thread = run_worker(worker, thread_name=name, start=False)
token = id(thread)
_PRODUCT_SUITE_THREAD_REFS[token] = (thread, worker)
thread.finished.connect(lambda token=token: _PRODUCT_SUITE_THREAD_REFS.pop(token, None))
run_token = str(getattr(worker, "run_token", "") or "")
if run_token:
thread.setProperty("productSuiteRunToken", run_token)
thread.finished.connect(self._on_generation_thread_finished_signal)
pull_run_token = str(getattr(worker, "pull_run_token", "") or "")
if pull_run_token:
thread.setProperty("productSuitePullRunToken", pull_run_token)
thread.finished.connect(self._on_pull_thread_finished_signal)
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)
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_add_images_action(state)
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)
state.import_created_draft = False
created_project = state.project_id is None
project = self._ensure_project_for_local_work(state)
if project is None:
return
state.import_created_draft = created_project and image_studio.is_draft_project(project)
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
if state is self._displayed_state:
self._refresh_add_images_action(state)
worker.finished.connect(lambda result, state=state: self._on_import_finished(state, result))
worker.cancelled.connect(lambda result, state=state: self._on_import_finished(state, result))
worker.failed.connect(lambda row, error, state=state: self._on_import_failed(state, error))
state.import_thread = self._start_thread(worker, "商品套图添加原图")
self._status("正在后台添加商品原图", "info")
def _discard_empty_import_draft(self, state):
if not state.import_created_draft:
return
state.import_created_draft = False
project_id = state.project_id
if project_id is None:
return
try:
project = image_studio.discard_empty_draft_project(project_id, path=self.db_path)
except Exception as exc:
self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger")
return
if project is not None and project.deleted_at is not None:
state.project_id = None
state.project_binding_state = ""
state.item_id = ""
self._set_task_title(state)
self._status("未添加有效图片,已丢弃空临时草稿", "warning")
def _on_import_failed(self, state, error):
state.import_worker = None
state.import_thread = None
self._discard_empty_import_draft(state)
self._status("添加商品原图失败:%s" % _user_error(error), "danger")
if state is self._displayed_state:
self._refresh_add_images_action(state)
def _on_import_finished(self, state, result):
state.import_worker = None
state.import_thread = None
self._discard_empty_import_draft(state)
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
if state.pull_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 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
if state.pull_running():
self._request_stop_pull(state)
return
self._save_controls_to_state(state)
if self._is_draft_state(state):
self._message(
"无法拉取蝦皮主图",
"当前为临时项目,无法拉取蝦皮主图。\n请先输入正式商品ID后再试。",
)
return
if not self._valid_context(state):
return
existing = [asset for asset in self._original_assets(state, include_missing=False) if _asset_usable(asset)]
message = (
"店铺:%s\n"
"商品ID:%s\n"
"当前可用商品原图:%d张\n\n"
"将读取蝦皮主图并在后台下载,不会修改蝦皮线上商品。"
% (
self._account_context_label(state),
state.item_id,
len(existing),
)
)
if existing:
message += "\n继续拉取会刷新蝦皮原图列表,本地手动添加图片会保留。"
if not self._confirm(
"确认拉取蝦皮主图",
message,
confirm_text="确认拉取",
cancel_text="取消",
):
return
if state.project_id is None:
project = image_studio.get_project_by_account_item(
state.account_alias,
state.item_id,
path=self.db_path,
)
if project is not None:
state.project_id = int(project.id)
state.project_binding_state = project.binding_state
before_assets = (
image_studio.list_assets(
state.project_id,
kind=image_studio.ASSET_KIND_ORIGINAL,
path=self.db_path,
)
if state.project_id is not None
else []
)
pull_run_token = uuid.uuid4().hex
worker = ImageStudioPullImagesWorker(
state.account_alias,
state.item_id,
pull_run_token=pull_run_token,
db_path=self.db_path,
config=self.config,
)
state.pull_worker = worker
state.pull_run_token = pull_run_token
state.pull_stop_requested = False
state.pull_cleanup_mode = "keep"
state.pull_before_asset_ids = {int(asset.id) for asset in before_assets}
state.pull_before_asset_states = [
{
"id": int(asset.id),
"status": asset.status,
"source_order": int(asset.source_order or 0),
}
for asset in before_assets
]
state.pull_asset_ids = set()
state.pull_download_asset_ids = set()
state.pull_download_failed = 0
state.pull_started_at = time.monotonic()
self._pull_run_states[pull_run_token] = state.key
self._log_pull_lifecycle(
state,
pull_run_token,
"started",
{
"before_count": len(state.pull_before_asset_ids),
},
)
worker.finished.connect(
lambda result, token=pull_run_token: self._on_pull_finished(
token,
result,
)
)
worker.cancelled.connect(
lambda result, token=pull_run_token: self._on_pull_finished(
token,
result,
)
)
worker.failed.connect(
lambda row, error, token=pull_run_token: self._on_pull_failed(
token,
error,
)
)
state.pull_thread = self._start_thread(worker, "商品套图拉取蝦皮主图")
if state is self._displayed_state:
self._apply_running_state(state)
self._status("开始拉取蝦皮主图,可继续操作其他套图任务", "info")
def _pull_state(self, pull_run_token):
token = str(pull_run_token or "")
state = self._states.get(self._pull_run_states.get(token))
if state is None or state.pull_run_token != token:
return None
return state
def _pull_stop_action(self):
box = QMessageBox(self)
box.setIcon(QMessageBox.Question)
box.setWindowTitle("停止拉取蝦皮主图")
box.setText("请选择停止后如何处理本轮已经拉取的图片。")
keep_button = box.addButton("停止并保留", QMessageBox.AcceptRole)
clear_button = box.addButton(
"停止并清除本次新增",
QMessageBox.DestructiveRole,
)
continue_button = box.addButton("继续拉取", QMessageBox.RejectRole)
box.setDefaultButton(keep_button)
box.exec()
clicked = box.clickedButton()
if clicked is keep_button:
return "keep"
if clicked is clear_button:
return "clear_current"
if clicked is continue_button:
return "continue"
return "continue"
def _request_stop_pull(self, state):
if state.pull_stop_requested:
self._status("正在停止当前拉取任务", "warning")
return
action = self._pull_stop_action()
if action == "continue":
return
state.pull_stop_requested = True
state.pull_cleanup_mode = action
if state.pull_worker is not None:
state.pull_worker.cancel()
self._cancel_pull_downloads(state)
self._log_pull_lifecycle(
state,
state.pull_run_token,
"stop_requested",
{"cleanup_mode": action},
level="WARNING",
)
if state is self._displayed_state:
self._apply_running_state(state)
self._status("已请求停止拉取蝦皮主图", "warning")
self._maybe_finalize_pull(state, state.pull_run_token)
def _cancel_pull_downloads(self, state):
pull_ids = set(state.pull_download_asset_ids)
if not pull_ids:
return
state.download_queue = [
asset_id
for asset_id in state.download_queue
if int(asset_id) not in pull_ids
]
for asset_id in list(pull_ids):
running = state.downloads.get(int(asset_id))
if running is None:
state.pull_download_asset_ids.discard(int(asset_id))
state.download_tokens.pop(int(asset_id), None)
continue
worker, _ = running
worker.cancel()
def _on_pull_failed(self, pull_run_token, error):
state = self._pull_state(pull_run_token)
if state is None or state.pull_stop_requested:
return
self._status("拉取蝦皮主图失败:%s" % _user_error(error), "danger")
def _on_pull_finished(self, pull_run_token, result):
state = self._pull_state(pull_run_token)
if state is None:
return
state.pull_worker = None
state.pull_thread = None
result = dict(result or {})
if result.get("ok") is False and not state.pull_stop_requested:
self._finalize_pull(
state,
pull_run_token,
error=_user_error(result.get("error")),
)
return
project = result.get("project")
if project is not None:
state.project_id = int(project.id)
state.project_binding_state = project.binding_state
assets = [
asset
for asset in (result.get("assets") or [])
if asset.status != image_studio.ASSET_STATUS_MISSING
][: image_studio_images.MAX_ORIGINAL_ASSETS]
state.pull_asset_ids.update(
int(asset.id)
for asset in assets
if getattr(asset, "remote_url", None)
)
if result.get("cancelled") or state.pull_stop_requested:
self._cancel_pull_downloads(state)
else:
self._queue_original_downloads(
state,
assets,
pull_run_token=pull_run_token,
)
self._status("已读取%d张蝦皮主图,正在后台下载" % len(assets), "success")
if state is self._displayed_state:
self._refresh_originals(state)
self._apply_running_state(state)
self._maybe_finalize_pull(state, pull_run_token)
@Slot()
def _on_pull_thread_finished_signal(self):
sender = self.sender()
token = str(
sender.property("productSuitePullRunToken")
if sender is not None
else ""
)
QTimer.singleShot(0, lambda token=token: self._handle_pull_thread_finished(token))
def _handle_pull_thread_finished(self, pull_run_token):
state = self._pull_state(pull_run_token)
if state is None or state.pull_worker is None:
return
state.pull_worker = None
state.pull_thread = None
if state.pull_stop_requested:
self._maybe_finalize_pull(state, pull_run_token)
else:
self._finalize_pull(
state,
pull_run_token,
error="拉取线程已结束,请稍后重试",
)
def _maybe_finalize_pull(self, state, pull_run_token):
if self._pull_state(pull_run_token) is not state:
return False
if state.pull_worker is not None or state.pull_download_asset_ids:
return False
return self._finalize_pull(state, pull_run_token)
def _clear_current_pull_assets(self, state):
new_ids = sorted(
set(state.pull_asset_ids) - set(state.pull_before_asset_ids)
)
removable = []
retained = 0
for asset_id in new_ids:
asset = image_studio.get_asset(asset_id, path=self.db_path)
if (
asset is None
or int(asset.project_id) != int(state.project_id or 0)
or asset.kind != image_studio.ASSET_KIND_ORIGINAL
or not asset.remote_url
):
continue
if image_studio.asset_reference_counts(
asset.id,
path=self.db_path,
)["total"]:
retained += 1
else:
removable.append(asset.id)
removed = 0
if removable:
image_studio.remove_original_assets_if_unused(
state.project_id,
removable,
path=self.db_path,
)
removed = len(removable)
existing_ids = {
int(asset.id)
for asset in image_studio.list_assets(
state.project_id,
kind=image_studio.ASSET_KIND_ORIGINAL,
path=self.db_path,
)
}
snapshot = [
item
for item in state.pull_before_asset_states
if int(item["id"]) in existing_ids
]
if snapshot:
image_studio.restore_original_asset_snapshot(
state.project_id,
snapshot,
path=self.db_path,
)
return removed, retained
def _finalize_pull(self, state, pull_run_token, *, error=""):
if self._pull_state(pull_run_token) is not state:
return False
cancelled = state.pull_stop_requested
cleanup_mode = state.pull_cleanup_mode
pulled_count = len(state.pull_asset_ids)
new_count = len(set(state.pull_asset_ids) - set(state.pull_before_asset_ids))
removed = 0
retained = 0
cleanup_error = ""
if cancelled and cleanup_mode == "clear_current" and state.project_id is not None:
try:
removed, retained = self._clear_current_pull_assets(state)
except Exception as exc:
cleanup_error = _user_error(exc)
elapsed = int(
max(0, time.monotonic() - state.pull_started_at)
if state.pull_started_at
else 0
)
self._pull_run_states.pop(pull_run_token, None)
state.pull_worker = None
state.pull_thread = None
state.pull_run_token = ""
state.pull_stop_requested = False
state.pull_cleanup_mode = "keep"
state.pull_before_asset_ids = set()
state.pull_before_asset_states = []
state.pull_asset_ids = set()
state.pull_download_asset_ids = set()
failed = int(state.pull_download_failed or 0)
self._log_pull_lifecycle(
state,
pull_run_token,
"finalized",
{
"cancelled": cancelled,
"cleanup_mode": cleanup_mode,
"pulled_count": pulled_count,
"new_count": new_count,
"removed_count": removed,
"retained_count": retained,
"failed_count": failed,
"elapsed_seconds": elapsed,
"has_error": bool(error or cleanup_error),
},
level="WARNING" if cancelled or error or cleanup_error else "INFO",
)
state.pull_download_failed = 0
state.pull_started_at = None
if state is self._displayed_state:
self._refresh_originals(state)
self._apply_running_state(state)
if cleanup_error:
self._message(
"停止拉取后清理失败",
"拉取已停止,但本轮图片清理失败:%s" % cleanup_error,
)
self._status("拉取已停止,本轮图片清理失败", "danger")
return True
if error:
if state is self._displayed_state:
self._message("拉取蝦皮主图失败", error)
self._status("拉取蝦皮主图失败:%s" % error, "danger")
return True
if cancelled:
kept = max(0, new_count - removed)
if state is self._displayed_state:
self._message(
"拉取蝦皮主图已停止",
"本轮新增%d张:保留%d张,清理%d张,"
"因引用保留%d张,下载失败%d张;总用时%d秒。"
% (
new_count,
kept,
removed,
retained,
failed,
elapsed,
),
icon=QMessageBox.Information,
)
self._status(
"拉取已停止:保留%d张,清理%d张" % (kept, removed),
"warning",
)
return True
self._status(
"蝦皮主图拉取完成:本轮%d张,下载失败%d张,用时%d秒"
% (pulled_count, failed, elapsed),
"success" if not failed else "warning",
)
return True
def _log_pull_lifecycle(
self,
state,
pull_run_token,
event,
payload=None,
*,
level="INFO",
):
data = {
"pull_run_token": str(pull_run_token or "")[:8],
"project_id": getattr(state, "project_id", None),
"event": str(event or ""),
}
data.update(dict(payload or {}))
try:
diagnostics.write_diagnostic_log(
"商品套图拉取生命周期",
level=level,
step="product_suite_pull",
task_id=getattr(state, "project_id", None),
item_id=getattr(state, "item_id", None),
payload=data,
log_dir=appconfig.diagnostic_log_dir(self.config),
)
except Exception:
pass
def _queue_original_downloads(self, state, assets, *, pull_run_token=None):
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)
state.download_tokens[asset_id] = str(pull_run_token or "")
if pull_run_token:
state.pull_download_asset_ids.add(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,
)
download_token = str(state.download_tokens.get(asset_id, "") or "")
worker.finished.connect(
lambda result, state=state, asset_id=asset_id, token=download_token: self._on_download_finished(
state, asset_id, result, pull_run_token=token
)
)
worker.cancelled.connect(
lambda result, state=state, asset_id=asset_id, token=download_token: self._on_download_finished(
state, asset_id, result, pull_run_token=token
)
)
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,
*,
pull_run_token="",
):
expected_token = str(state.download_tokens.get(int(asset_id), "") or "")
if expected_token != str(pull_run_token or ""):
return
state.downloads.pop(int(asset_id), None)
state.download_tokens.pop(int(asset_id), None)
if pull_run_token:
state.pull_download_asset_ids.discard(int(asset_id))
if result.get("ok") is False:
if pull_run_token:
state.pull_download_failed += 1
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)
if pull_run_token:
self._maybe_finalize_pull(state, pull_run_token)
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)
self.category_rows = {}
for index, name in enumerate(ordered):
custom = name not in product_suite.FIXED_CATEGORIES
row = SuiteCategoryRow(
name,
categories.get(name, 0),
helper=product_suite.category_helper(name),
custom=custom,
)
row.countChangeRequested.connect(self.change_category_count)
if custom:
row.renameRequested.connect(self.rename_custom_category)
row.deleteRequested.connect(self.delete_custom_category)
row.set_controls_enabled(not state.generation_running())
self.category_rows[name] = row
self.category_grid.addWidget(row, index, 0)
self._refresh_totals(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)
row = self.category_rows.get(name) if state is self._displayed_state else None
if row is None:
self._rebuild_categories(state)
else:
row.set_count(categories[name])
self._refresh_totals(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)
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)
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)
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
selected_assets = local_assets[: ai.CMHUB_VISION_MAX_IMAGES]
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,
image_paths=[asset.local_path for asset in selected_assets],
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)
message = "AI帮写已开始,可切换到其他套图任务"
if len(local_assets) > ai.CMHUB_VISION_MAX_IMAGES:
message += ";已使用前%d张商品原图进行理解" % ai.CMHUB_VISION_MAX_IMAGES
self._status(message, "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(
self._ai_write_success_message(result)
+ ",切回对应套图任务后确认是否采用",
"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(self._ai_write_success_message(result), "success")
if state is self._displayed_state:
self._apply_running_state(state)
def _ai_write_success_message(self, result):
image_count = max(0, int(dict(result or {}).get("image_count", 0) or 0))
parts = ["AI帮写完成"]
if image_count:
parts.append("已理解%d张商品原图" % image_count)
metadata = dict(dict(result or {}).get("metadata") or {})
if metadata.get("points_cost") is not None:
parts.append("图片理解扣点%s" % metadata["points_cost"])
if metadata.get("points_balance") is not None:
parts.append("当前余额%s" % metadata["points_balance"])
return ":".join(parts[:2]) + ("," + ",".join(parts[2:]) if len(parts) > 2 else "")
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():
retrying = state.generation_mode == "retry"
if state.generation_stop_requested:
self._status(
"正在停止当前图片重试"
if retrying
else "正在停止当前套图任务",
"warning",
)
return
if self._confirm(
"停止图片重试" if retrying else "停止生成套图",
(
"确认停止当前图片重试吗?已提交任务会在安全边界停止。"
if retrying
else "确认取消当前任务吗?已提交任务会在安全边界停止。"
),
destructive=True,
):
state.generation_stop_requested = True
state.worker.cancel()
self._log_generation_lifecycle(
state,
state.generation_run_token,
"stop_requested",
)
self._apply_running_state(state)
self._status(
"已请求停止当前图片重试"
if retrying
else "已请求停止当前套图任务",
"warning",
)
return
self.start_generation(state)
def start_generation(self, state, specs=None, *, retry_job_id=None):
if state.generation_running():
self._status("当前套图任务仍在生成", "warning")
return False
retry_job_id = int(retry_job_id) if retry_job_id is not None else None
retrying = retry_job_id is not None
confirm_batch = specs is None and not retrying
if state is self._displayed_state:
self._save_controls_to_state(state)
template_text = None
if specs is None:
try:
template_text = prompts.load_product_suite_prompt(
self.product_suite_prompt_path
)
except prompts.PromptError:
self._message(
"套图提示词模板无效",
"套图提示词模板无效,请在提示词设置中修复或恢复默认。",
)
return False
if self._ensure_project_for_local_work(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 or "未绑定商品",
template_text=template_text,
))
if not specs:
self._message("生成数量为0", "请至少把一个套图分类的数量设为1。")
return False
generation_round_key = ""
if retrying:
try:
original_job = image_studio.get_job(retry_job_id, path=self.db_path)
except Exception as exc:
self._message("读取重试图片失败", _user_error(exc))
return False
if original_job is None or int(original_job.project_id) != int(state.project_id):
self._message("重试图片无效", "该图片不属于当前商品套图任务。")
return False
for spec in specs:
spec["generation_round_key"] = original_job.generation_round_key
spec["generation_slot_index"] = original_job.generation_slot_index
generation_round_key = str(original_job.generation_round_key or "")
else:
if confirm_batch:
try:
history_summary = image_studio.get_successful_generation_history_summary(
state.project_id,
path=self.db_path,
)
except Exception:
self._message(
"读取套图历史失败",
"暂时无法确认该商品是否已有成功套图,请稍后重试。",
)
return False
if history_summary.successful_round_count:
decision = self._confirm_new_generation_history(
state,
history_summary,
)
if decision == "history":
self.open_history_dialog(
current_project_only=True,
current_project_id=state.project_id,
)
return False
if decision != "continue":
return False
generation_round_key = uuid.uuid4().hex
for slot_index, spec in enumerate(specs):
spec["generation_round_key"] = generation_round_key
spec["generation_slot_index"] = slot_index
if confirm_batch and not self._confirm(
"确认生成商品套图",
self._generation_confirmation_message(
state,
local_assets,
specs,
),
confirm_text="确认生成",
cancel_text="返回修改",
):
return False
self._persist_state(state)
run_token = uuid.uuid4().hex
worker = ProductSuiteGenerateWorker(
state.project_id,
specs,
run_token=run_token,
generation_round_key=generation_round_key or None,
aspect_ratio=state.settings["ratio"],
db_path=self.db_path,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
state.worker = worker
state.generation_run_token = run_token
state.generation_stop_requested = False
state.generation_terminal_streak = 0
state.generation_job_ids = []
state.generation_mode = "retry" if retrying else "batch"
state.generation_retry_job_id = retry_job_id
state.generation_round_key = generation_round_key
state.done = 0
state.failed = 0
state.total = len(specs)
state.started_at = time.monotonic()
if not retrying:
state.current_job_ids = []
self._generation_run_states[run_token] = state.key
worker.progress.connect(self._on_generation_progress_signal)
worker.finished.connect(self._on_generation_finished_signal)
worker.cancelled.connect(self._on_generation_finished_signal)
worker.failed.connect(self._on_generation_failed_signal)
state.thread = self._start_thread(worker, "商品套图生成")
self._log_generation_lifecycle(
state,
run_token,
"started",
{
"total": len(specs),
"job_ids": 0,
"mode": state.generation_mode,
},
)
if state is self._displayed_state:
self._apply_running_state(state)
self._refresh_results(state)
self._status(
"图片重试已开始;可切换到其他任务"
if retrying
else "商品套图生成已开始,共%d张;可切换到其他任务" % len(specs),
"info",
)
return True
def _generation_confirmation_message(self, state, local_assets, specs):
counts = {}
for spec in specs:
category = str(
spec.get("category")
or spec.get("job_type")
or "未分类"
)
counts[category] = counts.get(category, 0) + 1
ordered = list(product_suite.category_order(state.settings))
ordered.extend(name for name in counts if name not in ordered)
category_lines = [
"%s:%d张" % (name, counts[name])
for name in ordered
if counts.get(name, 0) > 0
]
return (
"店铺:%s\n"
"商品ID:%s\n"
"可用商品原图:%d张\n"
"每张上传图分别作为主图生成:%s\n"
"%s\n"
"图片比例:%s\n"
"生成总数:%d张\n"
"商品卖点:已填写\n\n"
"本次生成会消耗 cmhub 点数。"
% (
self._account_context_label(state),
state.item_id or "未绑定商品",
len(local_assets),
"是" if state.settings.get("per_image_primary") else "否",
"\n".join(category_lines),
state.settings.get("ratio") or "1:1",
len(specs),
)
)
def _generation_signal_token(self, payload=None):
token = str((payload or {}).get("run_token") or "")
sender = self.sender()
return token or str(getattr(sender, "run_token", "") or "")
def _generation_state(self, run_token):
token = str(run_token or "")
state = self._states.get(self._generation_run_states.get(token))
if state is None or state.generation_run_token != token:
return None
return state
@Slot(dict)
def _on_generation_progress_signal(self, payload):
token = self._generation_signal_token(payload)
state = self._generation_state(token)
if state is None:
return
self._on_generation_progress(state, payload)
def _on_generation_progress(self, state, payload):
if "total" in payload:
state.total = max(0, int(payload.get("total") or 0))
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 is not None:
self._set_generation_job_ids(state, job_ids)
state.generation_terminal_streak = 0
if state is self._displayed_state:
self._refresh_results(state)
self._refresh_elapsed()
@Slot(int, str)
def _on_generation_failed_signal(self, row, error):
token = self._generation_signal_token()
state = self._generation_state(token)
if state is None:
return
self._log_generation_lifecycle(
state,
token,
"worker_failed",
{"has_error": True},
level="ERROR",
)
self._status(
(
"图片重试失败:%s"
if state.generation_mode == "retry"
else "商品套图生成失败:%s"
)
% _user_error(error),
"danger",
)
@Slot(dict)
def _on_generation_finished_signal(self, result):
token = self._generation_signal_token(result)
state = self._generation_state(token)
if state is None:
return
if result.get("job_ids") is not None:
self._set_generation_job_ids(state, result.get("job_ids"))
self._log_generation_lifecycle(
state,
token,
"worker_finished",
{
"ok": result.get("ok", True),
"success": result.get("success", 0),
"failed": result.get("failed", 0),
"cancelled": result.get(
"cancelled_count",
result.get("cancelled", 0),
),
},
)
if result.get("ok") is False:
self._reconcile_generation_jobs(state, "生成线程异常结束")
if (
state.generation_stop_requested
and not self._generation_job_ids(state)
and result.get("cancelled") is True
):
result = dict(result)
result["total"] = state.total
result["cancelled_count"] = state.total
self._finalize_generation(state, token, result, source="worker")
@Slot()
def _on_generation_thread_finished_signal(self):
sender = self.sender()
token = str(
sender.property("productSuiteRunToken")
if sender is not None
else ""
)
self._handle_generation_thread_finished(token)
def _handle_generation_thread_finished(self, run_token):
state = self._generation_state(run_token)
if state is None:
return
snapshot = self._generation_job_snapshot(state)
had_active = bool(snapshot["active"])
self._log_generation_lifecycle(
state,
run_token,
"thread_finished_fallback",
snapshot,
level="WARNING" if snapshot["active"] else "INFO",
)
if had_active:
self._reconcile_generation_jobs(state, "生成线程已结束")
snapshot = self._generation_job_snapshot(state)
result = self._generation_result_from_snapshot(snapshot)
if not snapshot["job_ids"] and state.generation_stop_requested:
result.update(
{
"total": state.total,
"cancelled_count": state.total,
}
)
elif had_active:
result.update(
{
"ok": False,
"error": "生成线程已结束,部分任务可稍后继续查询",
}
)
self._finalize_generation(
state,
run_token,
result,
source="thread_finished",
)
def _check_generation_watchdogs(self):
for state in list(self._states.values()):
token = state.generation_run_token
if not token or not state.generation_running():
continue
snapshot = self._generation_job_snapshot(state)
if snapshot["all_terminal"]:
state.generation_terminal_streak += 1
else:
state.generation_terminal_streak = 0
if state.generation_terminal_streak < 2:
continue
self._log_generation_lifecycle(
state,
token,
"terminal_watchdog_finalize",
snapshot,
level="WARNING",
)
self._finalize_generation(
state,
token,
self._generation_result_from_snapshot(snapshot),
source="terminal_watchdog",
)
def _generation_job_ids(self, state):
job_ids = list(state.generation_job_ids)
if not job_ids and state.worker is not None:
worker_job_ids = getattr(state.worker, "job_ids", [])
if not isinstance(worker_job_ids, (list, tuple, set)):
worker_job_ids = []
job_ids = [
int(job_id)
for job_id in list(worker_job_ids or [])
]
if job_ids:
self._set_generation_job_ids(state, job_ids)
return job_ids
def _set_generation_job_ids(self, state, job_ids):
normalized = []
for job_id in job_ids or []:
value = int(job_id)
if value not in normalized:
normalized.append(value)
state.generation_job_ids = normalized
if state.generation_mode != "retry":
state.current_job_ids = list(normalized)
return
retry_job_id = state.generation_retry_job_id
current = [int(job_id) for job_id in state.current_job_ids]
if retry_job_id in current:
index = current.index(retry_job_id)
current[index:index + 1] = normalized
else:
for job_id in normalized:
if job_id not in current:
current.append(job_id)
state.current_job_ids = current
def _restore_current_generation_results(self, state, *, force=False, allow_running=False):
if state.project_id is None or (
state.generation_running() and not allow_running
):
return False
if state.current_job_ids and not force:
return False
try:
round_key = image_studio.get_current_generation_round(
state.project_id,
path=self.db_path,
)
jobs = image_studio.list_generation_round_current_jobs(
state.project_id,
round_key,
path=self.db_path,
)
except Exception as exc:
self._status("当前生成结果恢复失败:%s" % _user_error(exc), "danger")
return False
state.current_generation_round_key = str(round_key or "")
state.current_job_ids = [int(job.id) for job in jobs]
return True
def _generation_job_snapshot(self, state):
job_ids = self._generation_job_ids(state)
counts = {
"success": 0,
"failed": 0,
"cancelled": 0,
"active": 0,
"job_ids": len(job_ids),
"all_terminal": False,
}
if not job_ids:
counts["active"] = max(0, int(state.total or 0))
return counts
for job_id in job_ids:
try:
job = image_studio.get_job(job_id, path=self.db_path)
except Exception:
job = None
status = str(getattr(job, "status", "") or "")
if status == "succeeded":
counts["success"] += 1
elif status in {"failed", "expired"}:
counts["failed"] += 1
elif status == "cancelled":
counts["cancelled"] += 1
else:
counts["active"] += 1
counts["all_terminal"] = (
len(job_ids) == int(state.total or 0)
and counts["active"] == 0
)
return counts
def _generation_result_from_snapshot(self, snapshot):
return {
"total": int(snapshot.get("job_ids", 0) or 0),
"success": int(snapshot.get("success", 0) or 0),
"failed": int(snapshot.get("failed", 0) or 0),
"cancelled_count": int(snapshot.get("cancelled", 0) or 0),
}
def _reconcile_generation_jobs(self, state, reason):
for job_id in self._generation_job_ids(state):
try:
job = image_studio.get_job(job_id, path=self.db_path)
if job is None or job.status in {
"succeeded",
"failed",
"expired",
"cancelled",
}:
continue
recovery = (
image_studio.JOB_RECOVERY_RESUME
if job.task_id
else image_studio.JOB_RECOVERY_REGENERATE
)
image_studio.update_job_status(
job.id,
"cancelled",
error="%s,任务可稍后继续处理" % reason,
recovery_action=recovery,
path=self.db_path,
)
except Exception as exc:
self._status(
"商品套图任务状态收尾失败:%s" % _user_error(exc),
"danger",
)
def _finalize_generation(self, state, run_token, result, *, source):
if self._generation_state(run_token) is not state:
return False
retrying = state.generation_mode == "retry"
snapshot = self._generation_job_snapshot(state)
if snapshot["job_ids"] and (
snapshot["all_terminal"]
or source in {"thread_finished", "terminal_watchdog"}
):
result = dict(result or {})
result.update(self._generation_result_from_snapshot(snapshot))
result = dict(result or {})
stop_requested = state.generation_stop_requested
success = int(result.get("success", 0) or 0)
failed = int(result.get("failed", 0) or 0)
cancelled = int(
result.get("cancelled_count", result.get("cancelled", 0)) or 0
)
total = int(result.get("total", state.total) or state.total)
active = max(0, total - success - failed - cancelled)
elapsed = (
int(max(0, time.monotonic() - state.started_at))
if state.started_at
else 0
)
promoted = False
if not retrying and state.generation_round_key and success:
try:
promoted = image_studio.promote_generation_round_if_success(
state.project_id,
state.generation_round_key,
path=self.db_path,
)
except Exception as exc:
self._status("当前生成轮次保存失败:%s" % _user_error(exc), "danger")
self._restore_current_generation_results(
state,
force=True,
allow_running=True,
)
elif not retrying:
self._restore_current_generation_results(
state,
force=True,
allow_running=True,
)
elif state.generation_round_key:
self._restore_current_generation_results(
state,
force=True,
allow_running=True,
)
self._generation_run_states.pop(run_token, None)
state.generation_run_token = ""
state.generation_stop_requested = False
state.generation_terminal_streak = 0
state.generation_job_ids = []
state.generation_retry_job_id = None
state.generation_round_key = ""
state.worker = None
state.thread = None
state.done = success + failed + cancelled
state.failed = failed
state.total = total
state.started_at = None
if state is self._displayed_state:
self._apply_running_state(state)
self._refresh_results(state)
self._refresh_elapsed()
self._log_generation_lifecycle(
state,
run_token,
"finalized",
{
"source": source,
"total": total,
"success": success,
"failed": failed,
"cancelled": cancelled,
"active": active,
"elapsed_seconds": elapsed,
"mode": "retry" if retrying else "batch",
"current_round_promoted": promoted,
},
level="WARNING" if active or result.get("ok") is False else "INFO",
)
if state.key not in self._states:
return True
if active or result.get("ok") is False:
message = str(
result.get("error")
or "生成线程已结束,部分任务可稍后继续查询"
)
if state is self._displayed_state:
self._message(
"图片重试未完整结束"
if retrying
else "商品套图生成未完整结束",
_user_error(message),
)
else:
self._status(
(
"套图任务%d图片重试未完整结束"
if retrying
else "套图任务%d生成未完整结束"
)
% state.serial,
"danger",
)
return True
if stop_requested or cancelled:
if state is self._displayed_state:
if retrying:
self._message(
"图片重试已停止",
"本次重试:成功%d张,失败%d张,停止%d张;"
"已提交任务可稍后继续查询;总用时%d秒。"
% (success, failed, cancelled, elapsed),
icon=QMessageBox.Information,
)
else:
self._message(
"商品套图生成已停止",
"本轮共%d张:成功%d张,失败%d张,停止%d张;"
"已提交任务可稍后继续查询;总用时%d秒。"
% (total, success, failed, cancelled, elapsed),
icon=QMessageBox.Information,
)
self._status(
(
"图片重试已停止:成功%d张,失败%d张,停止%d张"
if retrying
else "商品套图生成已停止:成功%d张,失败%d张,停止%d张"
)
% (success, failed, cancelled),
"warning",
)
return True
if state is self._displayed_state:
if retrying:
self._message(
"图片重试成功" if success and not failed else "图片重试失败",
"本次重试:成功%d张,失败%d张,停止%d张;总用时%d秒。"
% (success, failed, cancelled, elapsed),
icon=QMessageBox.Information,
)
else:
self._message(
"商品套图生成完成",
"本轮共%d张:成功%d张,失败%d张,停止%d张;总用时%d秒。"
% (total, success, failed, cancelled, elapsed),
icon=QMessageBox.Information,
)
if retrying:
self._status(
"图片重试成功" if success and not failed else "图片重试失败,请查看失败卡片",
"success" if success and not failed else "danger",
)
else:
self._status(
"商品套图生成完成:成功%d张,失败%d张" % (success, failed),
"success",
)
return True
def _log_generation_lifecycle(
self,
state,
run_token,
event,
payload=None,
*,
level="INFO",
):
data = {
"run_token": str(run_token or "")[:8],
"project_id": getattr(state, "project_id", None),
"event": str(event or ""),
}
data.update(dict(payload or {}))
try:
diagnostics.write_diagnostic_log(
"商品套图生成生命周期",
level=level,
step="product_suite_generation",
task_id=getattr(state, "project_id", None),
item_id=getattr(state, "item_id", None),
payload=data,
log_dir=appconfig.diagnostic_log_dir(self.config),
)
except Exception:
pass
def _apply_running_state(self, state):
generation_running = state.generation_running()
pull_running = state.pull_running()
self.pull_button.setText(
"正在停止..."
if pull_running and state.pull_stop_requested
else "停止拉取蝦皮"
if pull_running
else "拉取蝦皮主图"
)
self.account_combo.setEnabled(not generation_running and not pull_running)
self.item_id_edit.setEnabled(not generation_running and not pull_running)
self._refresh_add_images_action(state)
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,
self.custom_category_edit,
):
widget.setEnabled(not generation_running)
for row in self.category_rows.values():
row.set_controls_enabled(not generation_running)
self.generate_button.setEnabled(True)
if generation_running:
self.generate_button.setText(
"正在停止..."
if state.generation_stop_requested
else "停止重试"
if state.generation_mode == "retry"
else "停止生成"
)
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(
"%s %d/%d(%d秒) · 失败 %d" % (
"重试" if state.generation_mode == "retry" else "套图",
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 _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 []
by_id = {int(job.id): job for job in jobs}
return [
by_id[int(job_id)]
for job_id in state.current_job_ids
if int(job_id) in by_id
]
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))
def open_history_dialog(
self,
checked=False,
*,
current_project_only=False,
current_project_id=None,
):
state = self._displayed_state
if current_project_id is None:
current_project_id = state.project_id if state is not None else None
dialog = self._history_dialog
if dialog is not None:
try:
dialog.set_current_project(current_project_id, refresh=False)
dialog.set_current_project_filter(
current_project_only,
refresh=False,
)
dialog.refresh_history()
dialog.show()
dialog.raise_()
dialog.activateWindow()
return
except RuntimeError:
pass
self._history_dialog = None
dialog = ProductSuiteGlobalHistoryDialog(
current_project_id=current_project_id,
current_project_only=current_project_only,
db_path=self.db_path,
parent=self,
)
dialog.destroyed.connect(
lambda _object=None, current=dialog: self._clear_history_dialog(current)
)
self._history_dialog = dialog
dialog.show()
dialog.raise_()
dialog.activateWindow()
def _clear_history_dialog(self, dialog):
if self._history_dialog is dialog:
self._history_dialog = None
def _close_history_dialog_for_project(self, project_id=None):
if project_id is not None:
return
dialog = self._history_dialog
if dialog is None:
return
try:
dialog.close()
self._history_dialog = None
except RuntimeError:
self._history_dialog = None
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
if str(getattr(job, "status", "") or "") not in {
"failed",
"expired",
"cancelled",
}:
self._status("当前图片无需重试", "warning")
return
spec = {
"source_asset_id": job.source_asset_id,
"job_type": job.job_type,
"prompt": job.prompt,
}
self.start_generation(state, specs=[spec], retry_job_id=job.id)
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):
self._close_history_dialog_for_project()
2026-07-16 16:39:40 +08:00
if self._displayed_state is not None:
self._save_controls_to_state(self._displayed_state)
for state in list(self._states.values()):
self._flush_prompt_save(state)
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()
2026-07-16 16:39:40 +08:00
for state in list(self._states.values()) + list(self._retired_states):
self._release_prompt_save_timer(state)
self._generation_run_states.clear()
self._pull_run_states.clear()
super().closeEvent(event)