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

6294 lines
250 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tab 6: project-bound product suite generation workspace."""
from __future__ import annotations
import os
import re
import time
import uuid
from dataclasses import dataclass, field
from PySide6.QtCore import (
QByteArray,
QBuffer,
QIODevice,
QPointF,
QRect,
QRegularExpression,
QSize,
Qt,
QTimer,
Signal,
Slot,
)
from PySide6.QtGui import (
QColor,
QFont,
QIcon,
QImage,
QImageReader,
QKeySequence,
QPainter,
QPixmap,
QRegularExpressionValidator,
QWheelEvent,
)
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,
cmhub_models,
diagnostics,
image_studio,
image_studio_generation,
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 (
CMHubModelCatalogWorker,
ImageStudioDownloadOriginalWorker,
ImageStudioPullImagesWorker,
ImageStudioResumeJobsWorker,
ProductSuiteAiWriteWorker,
ProductSuiteGenerateWorker,
ProductSuiteHistoryExportWorker,
ProductSuiteImportImagesWorker,
)
ORIGINAL_DOWNLOAD_CONCURRENCY = 2
ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1
PROMPT_AUTOSAVE_DELAY_MS = 500
_PRODUCT_SUITE_THREAD_REFS = {}
_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE)
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()
class SuiteSettingsComboBox(QComboBox):
"""Keep wheel scrolling from silently changing a suite generation setting."""
def wheelEvent(self, event):
target = self.view().viewport() if self.view().isVisible() else self._outer_scroll_viewport()
if target is None:
event.ignore()
return
global_position = event.globalPosition()
local_position = target.mapFromGlobal(global_position.toPoint())
forwarded = QWheelEvent(
QPointF(local_position),
global_position,
event.pixelDelta(),
event.angleDelta(),
event.buttons(),
event.modifiers(),
event.phase(),
event.inverted(),
)
QApplication.sendEvent(target, forwarded)
event.accept()
def _outer_scroll_viewport(self):
parent = self.parentWidget()
while parent is not None:
if isinstance(parent, QScrollArea):
return parent.viewport()
parent = parent.parentWidget()
return None
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._reordering_enabled = True
self.setDragEnabled(True)
self.setDropIndicatorShown(True)
self.setDragDropMode(QListWidget.InternalMove)
self.setDefaultDropAction(Qt.MoveAction)
self._dragged_item = None
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._update_reordering_tooltip()
def reordering_enabled(self):
return self._reordering_enabled
def set_reordering_enabled(self, enabled):
self._reordering_enabled = bool(enabled)
self.setDragEnabled(self._reordering_enabled)
self.setDropIndicatorShown(self._reordering_enabled)
self._update_reordering_tooltip()
item_tooltip = (
"拖动到第一位即可设为主图"
if self._reordering_enabled
else "正在拉取蝦皮主图,完成后可调整图片顺序"
)
for row in range(self.count()):
item = self.item(row)
if item.data(Qt.UserRole) is not None:
item.setToolTip(item_tooltip)
def _update_reordering_tooltip(self):
if self._reordering_enabled:
self.setToolTip("可拖入或粘贴图片;拖动图片到第一位即可设为主图")
else:
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
if event.source() is self and self._reordering_enabled and self._dragged_item is not None:
event.setDropAction(Qt.MoveAction)
event.accept()
return
super().dragEnterEvent(event)
def dragMoveEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
return
if not self._reordering_enabled:
event.ignore()
return
if event.source() is self and self._dragged_item is not None:
event.setDropAction(Qt.MoveAction)
event.accept()
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
if not self._reordering_enabled:
event.ignore()
return
if event.source() is not self:
event.ignore()
return
self._move_dragged_item_to(event.position().toPoint())
event.setDropAction(Qt.MoveAction)
event.accept()
def startDrag(self, supported_actions):
item = self.currentItem()
if (
not self._reordering_enabled
or item is None
or item.data(Qt.UserRole) is None
):
return
self._dragged_item = item
try:
super().startDrag(Qt.MoveAction)
finally:
self._dragged_item = None
def _move_dragged_item_to(self, position):
item = self._dragged_item
if item is None:
return False
source_row = self.row(item)
before_ids = self.asset_ids()
if source_row < 0 or len(before_ids) != len(set(before_ids)):
return False
target = self.itemAt(position)
if target is None:
target_row = self.count()
else:
target_row = self.row(target)
if position.x() > self.visualItemRect(target).center().x():
target_row += 1
if target_row > source_row:
target_row -= 1
moved_item = self.takeItem(source_row)
target_row = max(0, min(target_row, self.count()))
self.insertItem(target_row, moved_item)
self.setCurrentItem(moved_item)
moved_item.setSelected(True)
after_ids = self.asset_ids()
if len(after_ids) != len(set(after_ids)):
self.takeItem(target_row)
self.insertItem(source_row, moved_item)
self.setCurrentItem(moved_item)
return False
if after_ids == before_ids:
return False
self.orderChanged.emit(after_ids)
return True
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)
source_label = QLabel(_generation_source_label(job))
source_label.setObjectName("suiteResultCardSource")
source_label.setStyleSheet("color: #57606a; font-size: 11px;")
source_label.setToolTip("生成来源:%s" % _generation_source_label(job))
title_row.addWidget(source_label)
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)
self.retry_button = None
if status in {"failed", "expired", "cancelled"}:
self.retry_button = QPushButton("重试")
self.retry_button.setMinimumWidth(52)
self.retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job))
footer.addWidget(self.retry_button)
layout.addLayout(footer)
def set_retry_enabled(self, enabled, tooltip=""):
if self.retry_button is None:
return
self.retry_button.setEnabled(bool(enabled))
self.retry_button.setToolTip(str(tooltip or ""))
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 = "%s · %s" % (
_generation_source_label(job),
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" % _generation_source_label(job),
"状态:%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]
def _generation_source_label(job):
"""Return the persisted job source as a user-facing Chinese label."""
source = str(getattr(job, "generation_source", "") or "").strip().lower()
provider = str(getattr(job, "provider", "") or "").strip().lower()
if (
source == image_studio.GENERATION_SOURCE_CMHUB
and provider == image_studio.PROVIDER_CMHUB
):
return "默认网关"
if (
source == image_studio.GENERATION_SOURCE_DIRECT
and provider == image_studio.PROVIDER_OPENAI_IMAGES_EDITS
):
return "自定义网关"
return "来源未知"
def _generation_source_summary(jobs):
labels = []
for job in jobs:
label = _generation_source_label(job)
if label not in labels:
labels.append(label)
return "、".join(labels) if labels else "来源未知"
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)
source_label = QLabel("来源:%s" % _generation_source_summary(jobs))
source_label.setObjectName("suiteHistoryRoundSource")
source_label.setStyleSheet("color: #57606a;")
header.addWidget(source_label)
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 · %s"
% (
str(getattr(job, "job_type", "套图") or "套图"),
_generation_source_label(job),
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))
source_label = QLabel("来源:%s" % _generation_source_summary(self.jobs))
source_label.setObjectName("suiteGlobalHistorySource")
source_label.setStyleSheet("color: #57606a;")
meta.addWidget(source_label)
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_combo = QComboBox()
self.account_filter_combo.setObjectName("suiteGlobalHistoryAccountFilter")
self.account_filter_combo.setToolTip("选择要查看历史生成记录的店铺")
self.account_filter_combo.currentIndexChanged.connect(self.refresh_history)
filters.addWidget(self.account_filter_combo, 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()
try:
self._refresh_account_filter_options()
except Exception as exc:
self._set_error("历史店铺读取失败:%s" % _user_error(exc))
return
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_alias=self.account_filter_combo.currentData(),
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 _refresh_account_filter_options(self):
selected_alias = self.account_filter_combo.currentData()
current_accounts = accounts.list_accounts(path=self.db_path)
history_accounts = image_studio.list_global_history_accounts(path=self.db_path)
active_by_alias = {}
for account in current_accounts:
alias = str(getattr(account, "alias", "") or "").strip()
if alias:
active_by_alias[alias] = account
previous = self.account_filter_combo.blockSignals(True)
try:
self.account_filter_combo.clear()
self.account_filter_combo.addItem("全部店铺", None)
for alias in sorted(
active_by_alias,
key=lambda value: (value.casefold(), value),
):
account = active_by_alias[alias]
account_name = str(getattr(account, "account_name", "") or "").strip()
self.account_filter_combo.addItem(
"%s(%s)" % (account_name or alias, alias),
alias,
)
for history_account in history_accounts:
alias = str(getattr(history_account, "account_alias", "") or "").strip()
if alias and alias not in active_by_alias:
self.account_filter_combo.addItem(
"历史店铺:%s(账号已删除)" % alias,
alias,
)
selected_index = self.account_filter_combo.findData(selected_alias)
self.account_filter_combo.setCurrentIndex(
selected_index if selected_index >= 0 else 0
)
finally:
self.account_filter_combo.blockSignals(previous)
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 = ""
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 = ""
generation_source: 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
ai_price_worker: object = None
ai_price_thread: object = None
ai_confirmation_open: bool = False
generation_price_worker: object = None
generation_price_thread: object = None
generation_confirmation_open: bool = False
resume_worker: object = None
resume_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,
subscription_preflight_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.subscription_preflight_callback = subscription_preflight_callback
self.accounts = []
self._states = {}
self._retired_states = []
self._next_key = 1
self._next_serial = 1
self._displayed_state = None
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 showEvent(self, event):
super().showEvent(event)
self.refresh_gateway_state()
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.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")
self.item_id_edit.setValidator(
QRegularExpressionValidator(QRegularExpression("^[0-9]*$"), self.item_id_edit)
)
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.resume_submitted_button = QPushButton("继续查询已提交图片")
self.resume_submitted_button.setObjectName("suiteResumeSubmittedButton")
self.resume_submitted_button.setToolTip("继续查询已提交到默认网关的图片,不会重新生成或再次扣点")
self.resume_submitted_button.setVisible(False)
layout.addWidget(self.resume_submitted_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)
per_image_row = QHBoxLayout()
per_image_row.setContentsMargins(0, 0, 0, 0)
per_image_row.setSpacing(8)
self.per_image_checkbox = QCheckBox("每张上传图分别作为主图生成")
self.per_image_checkbox.setObjectName("suitePerImageCheckbox")
per_image_row.addWidget(self.per_image_checkbox)
self.per_image_helper_label = QLabel(
"多款式或多SKU图请勾选;同一商品多角度图不勾选,其余图会作为参考图一同提交。"
)
self.per_image_helper_label.setObjectName("suitePerImageHelperLabel")
self.per_image_helper_label.setWordWrap(True)
self.per_image_helper_label.setSizePolicy(
QSizePolicy.Expanding,
QSizePolicy.Preferred,
)
self.per_image_helper_label.setStyleSheet("color: #57606a; font-size: 12px;")
per_image_row.addWidget(self.per_image_helper_label, 1)
layout.addLayout(per_image_row)
return frame
def _value_combo(self, object_name, label, values):
combo = SuiteSettingsComboBox()
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)
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.resume_submitted_button.clicked.connect(self.resume_submitted_jobs)
self.history_button.clicked.connect(self.open_history_dialog)
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="取消",
default_cancel=False,
):
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question)
box.setWindowTitle(str(title))
box.setText(str(message))
confirm_button = box.addButton(str(confirm_text), QMessageBox.AcceptRole)
cancel_button = box.addButton(str(cancel_text), QMessageBox.RejectRole)
if default_cancel:
box.setDefaultButton(cancel_button)
box.setEscapeButton(cancel_button)
elif 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 ""),
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
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()
for worker in (
state.ai_worker,
state.ai_price_worker,
state.generation_price_worker,
):
if worker is not None:
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")
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:
for worker in (
self._displayed_state.ai_price_worker,
self._displayed_state.generation_price_worker,
):
if worker is not None:
worker.cancel()
self._save_controls_to_state(self._displayed_state)
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.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.item_id_edit.setStyleSheet("")
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):
self._commit_item_id(self._displayed_state)
def _restore_committed_item_id(self, state):
self._loading = True
try:
self.item_id_edit.setText(state.item_id)
finally:
self._loading = False
def _commit_item_id(self, state):
"""Validate and commit the visible product ID before an operation uses it."""
if self._loading or state is None:
return False
raw_item_id = self.item_id_edit.text()
item_id = image_studio.normalize_item_id_input(raw_item_id)
if item_id != raw_item_id:
self._loading = True
try:
self.item_id_edit.setText(item_id)
finally:
self._loading = False
if item_id and not image_studio.is_formal_item_id(item_id):
self.item_id_edit.setStyleSheet("border: 1px solid #9a6700;")
self._status("商品ID只能输入数字", "warning")
self._update_context_actions(state)
return False
self.item_id_edit.setStyleSheet("")
if self._is_draft_state(state):
if not item_id:
state.item_id = ""
self._update_context_actions(state)
return True
if not self._confirm(
"绑定正式商品",
"将当前临时草稿绑定到商品%s吗?\n已添加图片和生成记录会继续保留。" % item_id,
):
self._restore_committed_item_id(state)
self._update_context_actions(state)
return False
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._restore_committed_item_id(state)
self._update_context_actions(state)
return False
except Exception as exc:
self._message("绑定正式商品失败", _user_error(exc))
self._restore_committed_item_id(state)
self._update_context_actions(state)
return False
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 True
if state.project_id is not None and item_id != state.item_id:
if not self._confirm(
"切换商品",
"切换商品ID后,原图和生成结果会按新商品重新载入。确认继续吗?",
):
self._restore_committed_item_id(state)
return False
self._clear_project_binding(state)
state.item_id = item_id
if not item_id:
self._update_context_actions(state)
return True
if item_id and state.account_alias:
self._bind_project(state, load_existing=True)
self._update_context_actions(state)
return True
def _clear_project_binding(self, state):
self._flush_prompt_save(state)
state.project_id = None
state.project_binding_state = ""
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 image_studio.is_formal_item_id(state.item_id):
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:
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,
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)
stored_prompt = str(project.draft_prompt or "")
if load_existing and previous_id != state.project_id:
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)
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 = ""
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
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
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:
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
if prompt == state.last_saved_prompt:
return True
try:
project = image_studio.update_project_prompt(
state.project_id,
prompt,
path=self.db_path,
)
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")
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()
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 "未绑定商品"
source_image_count = len(
[asset for asset in self._original_assets(state) if _asset_usable(asset)]
)
else:
settings = product_suite.default_suite_settings()
base_prompt = ""
item_id = "未绑定商品"
source_image_count = 0
dialog = ProductSuitePromptDialog(
prompt_path=self.product_suite_prompt_path,
base_prompt=base_prompt,
settings=settings,
item_id=item_id,
source_image_count=source_image_count,
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())
generation_running = bool(state and state.generation_running())
self.pull_button.setEnabled(
state is not None
and not generation_running
and (
pull_running
or is_draft
or self._valid_context(state, show_message=False)
)
)
self.pull_button.setToolTip(
"生成中不能拉取蝦皮主图"
if generation_running
else "正在停止拉取"
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.setFlags(item.flags() | Qt.ItemIsDragEnabled)
if index == 1:
font = QFont(item.font())
font.setBold(True)
item.setFont(font)
item.setForeground(QColor("#1f5fbf"))
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,勾选可批量删除;双击预览;%s"
% (
label,
"拖动到第一位即可设为主图"
if self.original_list.reordering_enabled()
else "正在拉取蝦皮主图,完成后可调整图片顺序",
)
)
else:
item.setIcon(QIcon(_placeholder_pixmap("待下载", QSize(82, 64))))
item.setToolTip(
"%s尚未下载;勾选可批量删除,单击缩略图后台拉取;%s"
% (
label,
"拖动到第一位即可设为主图"
if self.original_list.reordering_enabled()
else "正在拉取蝦皮主图,完成后可调整图片顺序",
)
)
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
if not self._commit_item_id(state):
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
if state.generation_running():
self._refresh_originals(state)
self._status("生成中不能调整商品原图顺序", "warning")
return
if state.pull_running():
self._refresh_originals(state)
self._status("正在拉取蝦皮主图,完成后可调整图片顺序", "warning")
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)]
ordered_ids = list(visible_ids) + hidden_ids
before_ids = [int(asset.id) for asset in all_assets]
if ordered_ids == before_ids:
self._refresh_originals(state)
return
try:
image_studio.reorder_original_assets(
state.project_id,
ordered_ids,
path=self.db_path,
)
except Exception as exc:
self._message("原图排序失败", _user_error(exc))
else:
self._status("已更新商品原图顺序,第一张图片为主图", "success")
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
if not self._commit_item_id(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 _is_default_gateway(self):
try:
return appconfig.ai_backend(self.config) == "cmhub"
except Exception:
return False
def _is_direct_gateway(self):
try:
return appconfig.ai_backend(self.config) == "direct"
except Exception:
return False
def _direct_generation_config_error(self):
"""Return a user-safe direct image-model preflight error, if any."""
try:
ai_config = appconfig.ai_config(self.config)
model_name = str(ai_config.get("default_image_model") or "").strip()
if not model_name:
return "尚未选择图片模型"
model = appconfig.get_model(
model_name,
path=appconfig.ai_models_config_path(self.config),
)
except Exception:
return "当前图片模型不存在或无法读取"
if not model.get("enabled", True):
return "当前图片模型未启用"
return appconfig.image_model_config_error(model)
def _ensure_generation_gateway(self):
if self._is_default_gateway():
return True
if self._is_direct_gateway():
error = self._direct_generation_config_error()
if not error:
return True
self._message(
"自定义网关配置不完整",
"商品套图无法生成:%s。\n请到⑤设置补齐并保存图片模型配置。" % error,
)
return False
self._message("生成网关不可用", "请到⑤设置选择默认网关或配置自定义网关。")
return False
@staticmethod
def _has_default_gateway_source(job):
return (
str(getattr(job, "generation_source", "") or "").strip().lower() == "cmhub"
and str(getattr(job, "provider", "") or "").strip().lower() == "cmhub"
)
@classmethod
def _is_default_gateway_job(cls, job):
return cls._has_default_gateway_source(job) and bool(
str(getattr(job, "task_id", "") or "").strip()
)
@staticmethod
def _is_direct_gateway_job(job):
return (
str(getattr(job, "generation_source", "") or "").strip().lower()
== image_studio.GENERATION_SOURCE_DIRECT
and str(getattr(job, "provider", "") or "").strip().lower()
== image_studio.PROVIDER_OPENAI_IMAGES_EDITS
)
def _retry_availability(self, job):
if self._has_default_gateway_source(job):
if self._is_default_gateway():
return True, ""
return False, "默认网关图片仅能在⑤设置切换为默认网关后重新生成"
if self._is_direct_gateway_job(job):
if not self._is_direct_gateway():
return False, "自定义网关图片仅能在⑤设置切换为自定义网关后重新生成"
error = self._direct_generation_config_error()
if error:
return False, "自定义网关配置不完整:%s" % error
return True, ""
return False, "当前图片的生成来源无法确认,不能重新生成"
def _resumable_default_gateway_jobs(self, state):
if state is None or state.project_id is None:
return []
try:
jobs = image_studio.list_resumable_jobs(
project_id=state.project_id,
include_failed_downloads=True,
path=self.db_path,
)
except Exception:
return []
return [job for job in jobs if self._is_default_gateway_job(job)]
def refresh_gateway_state(self):
state = self._displayed_state
if state is not None:
self._apply_running_state(state)
self._refresh_results(state)
def _update_resume_submitted_action(self, state):
jobs = self._resumable_default_gateway_jobs(state)
self.resume_submitted_button.setVisible(bool(jobs))
self.resume_submitted_button.setEnabled(
bool(jobs)
and state.resume_worker is None
and not state.generation_running()
and state.generation_price_worker is None
)
def resume_submitted_jobs(self, checked=False):
state = self._displayed_state
if state is None or state.project_id is None:
return
if state.resume_worker is not None:
self._status("正在继续查询已提交图片", "info")
return
jobs = self._resumable_default_gateway_jobs(state)
if not jobs:
self._status("当前没有可继续查询的默认网关图片", "info")
self._update_resume_submitted_action(state)
return
worker = ImageStudioResumeJobsWorker(
project_id=state.project_id,
aspect_ratio=state.settings.get("ratio", "1:1"),
db_path=self.db_path,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
state.resume_worker = worker
worker.finished.connect(
lambda result, state=state: self._on_resume_submitted_finished(state, result)
)
worker.cancelled.connect(
lambda result, state=state: self._on_resume_submitted_finished(state, result)
)
state.resume_thread = self._start_thread(worker, "商品套图继续查询")
self._apply_running_state(state)
self._status("开始继续查询%d张已提交图片,不会重新生成或再次扣点" % len(jobs), "info")
def _on_resume_submitted_finished(self, state, result):
state.resume_worker = None
state.resume_thread = None
self._refresh_results(state)
self._update_resume_submitted_action(state)
if result.get("ok") is False:
error = str(result.get("error") or "")
if "cmhub_not_configured" in error or "缺少" in error:
self._message(
"继续查询失败",
"默认网关配置不可用,请恢复原默认网关配置后继续查询已提交图片。",
)
else:
self._message("继续查询失败", _user_error(error))
return
self._status("已完成已提交图片查询,请查看生成结果", "success")
def _require_default_gateway(self, action):
if self._is_default_gateway():
return True
self._message("当前不可用", "%s仅支持默认网关,请到⑤设置切换后再使用。" % action)
return False
def start_ai_write(self, checked=False):
if not self._require_default_gateway("商品套图AI帮写"):
return
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("开始 AI 帮写"):
return
state = self._displayed_state
if state is None:
return
if state.ai_confirmation_open:
self._status("正在等待AI帮写确认", "info")
return
if state.ai_worker is not None:
self._status("当前套图任务正在AI帮写", "info")
return
if state.ai_price_worker is not None:
self._status("正在读取图片理解预计扣点", "info")
return
selected_assets = self._ai_write_selected_assets(state)
if not selected_assets:
self._message("缺少可用商品原图", "请先添加商品原图,或等待已拉取的商品原图下载完成。")
return
self._save_controls_to_state(state)
selected_asset_ids = tuple(int(asset.id) for asset in selected_assets)
catalog_params = self._ai_write_catalog_params()
cached_models = (
cmhub_models.cached_model_catalog(catalog_params["base_url"], catalog_params["alias"])
if catalog_params is not None
else None
)
if cached_models is not None:
self._confirm_ai_write_request(
state,
selected_asset_ids,
self._ai_write_points_cost(cached_models, catalog_params["alias"]),
)
return
if catalog_params is None:
self._confirm_ai_write_request(state, selected_asset_ids, None)
return
self._start_ai_write_catalog_lookup(state, selected_asset_ids, catalog_params)
def _ai_write_selected_assets(self, state):
return [
asset
for asset in self._original_assets(state)
if _asset_usable(asset)
][: ai.CMHUB_VISION_MAX_IMAGES]
def _ai_write_catalog_params(self):
return self._cmhub_catalog_params("vision_alias")
def _cmhub_catalog_params(self, alias_key):
try:
cmhub_config = appconfig.cmhub_config(self.config)
base_url = appconfig.normalize_cmhub_base_url(cmhub_config.get("base_url"))
alias = str(cmhub_config.get(alias_key) or "").strip()
api_key = appconfig.get_cmhub_api_key(self.cmhub_config_path)
except Exception:
return None
if not base_url or not alias or not api_key:
return None
return {
"base_url": base_url,
"alias": alias,
"api_key": api_key,
"connect_timeout": cmhub_config.get("connect_timeout", 10),
"use_system_proxy": bool(appconfig.ai_config(self.config).get("use_system_proxy")),
}
@staticmethod
def _ai_write_points_cost(models, alias):
return cmhub_models.unambiguous_points_cost(
models,
alias,
"vision",
requires_image=True,
)
def _start_ai_write_catalog_lookup(self, state, selected_asset_ids, params):
worker = CMHubModelCatalogWorker(
params["base_url"],
params["api_key"],
connect_timeout=params["connect_timeout"],
use_system_proxy=params["use_system_proxy"],
)
state.ai_price_worker = worker
worker.finished.connect(
lambda result, state=state, asset_ids=selected_asset_ids, alias=params["alias"]:
self._on_ai_write_catalog_finished(state, asset_ids, alias, result)
)
worker.cancelled.connect(
lambda result, state=state: self._on_ai_write_catalog_cancelled(state, result)
)
state.ai_price_thread = self._start_thread(worker, "商品套图读取扣点")
if state is self._displayed_state:
self._apply_running_state(state)
self._status("正在读取图片理解预计扣点", "info")
def _on_ai_write_catalog_finished(self, state, selected_asset_ids, alias, result):
state.ai_price_worker = None
state.ai_price_thread = None
if not self._is_open_suite_state(state):
return
if result.get("ok") is False:
self._status("暂时无法取得图片理解预计扣点,实际以网关返回为准", "warning")
self._confirm_ai_write_request(state, selected_asset_ids, None)
elif not result.get("cancelled"):
self._confirm_ai_write_request(
state,
selected_asset_ids,
self._ai_write_points_cost(result.get("models") or [], alias),
)
if state is self._displayed_state:
self._apply_running_state(state)
def _on_ai_write_catalog_cancelled(self, state, result):
state.ai_price_worker = None
state.ai_price_thread = None
if self._is_open_suite_state(state):
self._status("已取消读取图片理解预计扣点", "warning")
if state is self._displayed_state:
self._apply_running_state(state)
def _is_open_suite_state(self, state):
return self._states.get(getattr(state, "key", None)) is state
def _confirm_ai_write_request(self, state, selected_asset_ids, points_cost):
if not self._is_open_suite_state(state) or state.ai_worker is not None:
return
current_asset_ids = tuple(int(asset.id) for asset in self._ai_write_selected_assets(state))
if current_asset_ids != tuple(selected_asset_ids):
self._status("商品原图已变化,请重新点击AI帮写", "warning")
return
message = self._ai_write_confirmation_message(len(current_asset_ids), points_cost)
state.ai_confirmation_open = True
accepted = False
try:
box = QMessageBox(self)
box.setIcon(QMessageBox.Question)
box.setWindowTitle("开始AI帮写")
box.setText(message)
start_button = box.addButton("开始AI帮写", QMessageBox.AcceptRole)
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
box.setDefaultButton(cancel_button)
box.setEscapeButton(cancel_button)
box.exec()
accepted = box.clickedButton() is start_button
finally:
state.ai_confirmation_open = False
if not accepted:
if state is self._displayed_state:
self._apply_running_state(state)
return
self._start_confirmed_ai_write(state, selected_asset_ids)
@staticmethod
def _ai_write_confirmation_message(image_count, points_cost):
lines = [
"本次会理解当前商品前%d张可用商品原图(最多%d张),并生成商品卖点与要求。"
% (int(image_count), ai.CMHUB_VISION_MAX_IMAGES),
]
price_text = cmhub_models.format_points_cost(points_cost)
if price_text:
lines.append("预计扣点:%s 点,实际以网关返回为准。" % price_text)
else:
lines.append("暂时无法取得预计扣点,实际以网关返回为准。")
lines.append("开始后可取消本地等待;请求已提交到网关时,仍可能产生扣点。")
return "\n".join(lines)
def _start_confirmed_ai_write(self, state, selected_asset_ids):
if not self._is_open_suite_state(state) or state.ai_worker is not None:
return
selected_assets = self._ai_write_selected_assets(state)
if tuple(int(asset.id) for asset in selected_assets) != tuple(selected_asset_ids):
self._status("商品原图已变化,请重新点击AI帮写", "warning")
return
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([asset for asset in self._original_assets(state) if _asset_usable(asset)]) > 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_price_worker is not None:
state.ai_price_worker.cancel()
self._status("已请求取消读取图片理解预计扣点", "warning")
elif 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_price_worker is not None:
state.generation_price_worker.cancel()
self._status("已请求取消读取套图预计扣点", "warning")
return
if state.generation_confirmation_open:
self._status("正在等待套图生成确认", "info")
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 "确认停止当前任务吗?正在请求的自定义网关图片无法立即撤销;如果图片已返回,程序会先保存,再结束后续任务。"
if state.generation_source == image_studio.GENERATION_SOURCE_DIRECT
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 "正在停止,等待当前图片返回后结束"
if state.generation_source == image_studio.GENERATION_SOURCE_DIRECT
else "已请求停止当前套图任务",
"warning",
)
return
self.start_generation(state)
def start_generation(
self,
state,
specs=None,
*,
retry_job_id=None,
confirm_direct_retry=False,
):
if state is self._displayed_state:
if not self._commit_item_id(state):
return False
self._save_controls_to_state(state)
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("生成商品套图"):
return False
if not self._ensure_generation_gateway():
return False
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
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
try:
specs = list(specs or product_suite.build_job_specs(
local_assets,
state.prompt,
state.settings,
state.item_id or "未绑定商品",
template_text=template_text,
))
except product_suite.ProductSuitePromptError:
message = "提示词渲染失败,请检查提示词设置中的变量格式。"
self._message("无法生成商品套图", message)
self._status(message, "danger")
return False
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:
return self._start_generation_price_confirmation(
state,
local_assets,
specs,
generation_round_key,
template_text,
)
if confirm_direct_retry:
if not self._confirm_direct_retry(state, local_assets, specs):
return False
return self._start_generation_worker(
state,
specs,
generation_round_key,
retrying=retrying,
retry_job_id=retry_job_id,
)
def _confirm_direct_retry(self, state, local_assets, specs):
message = "\n".join(
[
"这张图片上次通过自定义网关生成但未完成。",
"服务商可能对上次未确认请求已计费,本次重新生成可能再次收费。",
"",
self._generation_confirmation_message(
state,
local_assets,
specs,
estimate=None,
),
]
)
return self._confirm(
"确认重新生成商品套图",
message,
destructive=True,
confirm_text="确认重新生成",
cancel_text="取消",
default_cancel=True,
)
def _start_generation_worker(
self,
state,
specs,
generation_round_key,
*,
retrying,
retry_job_id,
):
self._persist_state(state)
source = image_studio_generation.generation_source_for_config(self.config)
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_source = source["generation_source"]
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 _start_generation_price_confirmation(
self,
state,
local_assets,
specs,
generation_round_key,
template_text,
):
if state.generation_price_worker is not None or state.generation_confirmation_open:
self._status("正在读取套图预计扣点", "info")
return False
snapshot = self._generation_plan_snapshot(state, local_assets, specs, template_text)
if self._is_direct_gateway():
return self._confirm_generation_price_request(
state,
local_assets,
specs,
generation_round_key,
snapshot,
None,
)
params = self._cmhub_catalog_params("image_alias")
cached_models = (
cmhub_models.cached_model_catalog(params["base_url"], params["alias"])
if params is not None
else None
)
if cached_models is not None:
return self._confirm_generation_price_request(
state,
local_assets,
specs,
generation_round_key,
snapshot,
self._generation_points_estimate(cached_models, params["alias"], specs),
)
if params is None:
return self._confirm_generation_price_request(
state,
local_assets,
specs,
generation_round_key,
snapshot,
None,
)
worker = CMHubModelCatalogWorker(
params["base_url"],
params["api_key"],
connect_timeout=params["connect_timeout"],
use_system_proxy=params["use_system_proxy"],
)
state.generation_price_worker = worker
worker.finished.connect(
lambda result, state=state, assets=list(local_assets), specs=list(specs), key=generation_round_key, plan=snapshot, alias=params["alias"]:
self._on_generation_catalog_finished(
state,
assets,
specs,
key,
plan,
alias,
result,
)
)
worker.cancelled.connect(
lambda result, state=state: self._on_generation_catalog_cancelled(state, result)
)
state.generation_price_thread = self._start_thread(worker, "商品套图读取生图扣点")
if state is self._displayed_state:
self._apply_running_state(state)
self._status("正在读取套图预计扣点", "info")
return False
@staticmethod
def _generation_points_estimate(models, alias, specs):
unit_cost = cmhub_models.unambiguous_points_cost(
models,
alias,
"image",
requires_image=True,
)
if unit_cost is None:
return None
return (unit_cost, unit_cost * len(specs))
@staticmethod
def _generation_specs_signature(specs):
return tuple(
(
int(spec.get("source_asset_id") or 0),
tuple(int(asset_id) for asset_id in (spec.get("reference_asset_ids") or [])),
str(spec.get("category") or spec.get("job_type") or ""),
int(spec.get("category_index") or 0),
int(spec.get("source_index") or 0),
str(spec.get("prompt") or ""),
)
for spec in specs
)
def _generation_plan_snapshot(self, state, local_assets, specs, template_text):
return {
"asset_ids": tuple(int(asset.id) for asset in local_assets),
"settings": product_suite.normalize_suite_settings(state.settings),
"prompt": str(state.prompt or ""),
"item_id": str(state.item_id or ""),
"template_text": str(template_text or ""),
"specs": self._generation_specs_signature(specs),
"generation_backend": appconfig.ai_backend(self.config),
}
def _generation_plan_is_current(self, state, snapshot):
if not self._is_open_suite_state(state) or state.generation_running():
return False
try:
template_text = prompts.load_product_suite_prompt(self.product_suite_prompt_path)
except prompts.PromptError:
return False
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
try:
current_specs = product_suite.build_job_specs(
local_assets,
state.prompt,
state.settings,
state.item_id or "未绑定商品",
template_text=template_text,
)
except product_suite.ProductSuitePromptError:
return False
return snapshot == self._generation_plan_snapshot(
state,
local_assets,
current_specs,
template_text,
)
def _on_generation_catalog_finished(
self,
state,
local_assets,
specs,
generation_round_key,
snapshot,
alias,
result,
):
state.generation_price_worker = None
state.generation_price_thread = None
if state is not self._displayed_state or not self._is_open_suite_state(state):
return
estimate = None
if result.get("ok") is False:
self._status("暂时无法取得套图预计扣点,实际以网关返回为准", "warning")
elif result.get("cancelled"):
self._apply_running_state(state)
return
else:
estimate = self._generation_points_estimate(result.get("models") or [], alias, specs)
self._confirm_generation_price_request(
state,
local_assets,
specs,
generation_round_key,
snapshot,
estimate,
)
if state is self._displayed_state:
self._apply_running_state(state)
def _on_generation_catalog_cancelled(self, state, result):
state.generation_price_worker = None
state.generation_price_thread = None
if state is self._displayed_state and self._is_open_suite_state(state):
self._status("已取消读取套图预计扣点", "warning")
self._apply_running_state(state)
def _confirm_generation_price_request(
self,
state,
local_assets,
specs,
generation_round_key,
snapshot,
estimate,
):
if state is not self._displayed_state or not self._is_open_suite_state(state):
return False
if not self._generation_plan_is_current(state, snapshot):
self._status("商品原图或生成设置已变化,请重新点击生成套图", "warning")
return False
state.generation_confirmation_open = True
try:
confirmed = self._confirm(
"确认生成商品套图",
self._generation_confirmation_message(
state,
local_assets,
specs,
estimate=estimate,
),
confirm_text="确认生成",
cancel_text="返回修改",
default_cancel=True,
)
finally:
state.generation_confirmation_open = False
if not confirmed:
if state is self._displayed_state:
self._apply_running_state(state)
return False
if not self._generation_plan_is_current(state, snapshot):
self._status("商品原图或生成设置已变化,请重新点击生成套图", "warning")
return False
return self._start_generation_worker(
state,
specs,
generation_round_key,
retrying=False,
retry_job_id=None,
)
def _generation_confirmation_message(self, state, local_assets, specs, *, estimate=None):
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
]
per_image_primary = bool(state.settings.get("per_image_primary"))
reference_count = 0
if not per_image_primary:
reference_count = max(
[len(spec.get("reference_asset_ids") or []) for spec in specs] or [0]
)
ignored_reference_count = max(
0,
len(local_assets) - 1 - reference_count,
)
output_spec = image_studio_generation.requested_output_spec(
state.settings.get("ratio") or "1:1"
)
direct_gateway = self._is_direct_gateway()
lines = [
"店铺:%s" % self._account_context_label(state),
"商品ID:%s" % (state.item_id or "未绑定商品"),
"可用商品原图:%d张" % len(local_assets),
"",
"逐图主图:%s" % ("已开启" if per_image_primary else "未开启"),
(
"说明:白底图只使用第一张原图;场景图、卖点图和自定义分类会按每张原图分别生成。"
if per_image_primary
else (
"说明:所有分类都使用第1张可用原图作为主图,另有%d张参考图会随每次请求一同提交。"
% reference_count
if reference_count
else "说明:所有分类都只使用第一张可用原图生成。"
)
),
"",
]
if not per_image_primary and ignored_reference_count:
lines.extend(
[
"参考图仅取前7张,其余%d张不参与本轮生成。" % ignored_reference_count,
"",
]
)
lines.extend(category_lines)
lines.extend(
[
"图片比例:%s" % (state.settings.get("ratio") or "1:1"),
"本次生成总数:%d张" % len(specs),
"商品卖点:已填写",
"",
]
)
lines.extend(
[
"输出尺寸:%s%s"
% (
output_spec["requested_output_size"],
"(接近比例生成)" if output_spec["approximate_ratio"] else "",
),
"",
]
)
if direct_gateway:
lines.extend(
[
"生成来源:自定义网关",
"自定义网关不计点数,实际费用以服务商为准。",
"首图作为主要商品参考;参考图效果取决于模型,商品主体一致性可能弱于默认网关。",
]
)
elif estimate is None:
lines.append("本次会消耗 cmhub 点数,暂时无法取得预计扣点,实际以网关返回为准。")
else:
unit_cost, total_cost = estimate
lines.extend(
[
"预计单张扣点:%s 点" % cmhub_models.format_points_cost(unit_cost),
"预计总扣点:%s 点" % cmhub_models.format_points_cost(total_cost),
"实际扣点以 cmhub 返回为准。",
]
)
return "\n".join(lines)
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.generation_source = ""
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()
generation_price_pending = state.generation_price_worker is not None
pull_running = state.pull_running()
default_gateway = self._is_default_gateway()
supported_gateway = default_gateway or self._is_direct_gateway()
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.original_list.set_reordering_enabled(not generation_running and not pull_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.prompt_settings_button,
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(
generation_running or generation_price_pending or supported_gateway
)
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; }"
)
elif generation_price_pending:
self.generate_button.setText("取消读取扣点")
self.generate_button.setStyleSheet(
"QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }"
"QPushButton:hover { background: #a40e26; }"
)
else:
self.generate_button.setStyleSheet(
"QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }"
"QPushButton:hover { background: #245fce; }"
)
self._refresh_totals(state)
ai_running = state.ai_worker is not None or state.ai_price_worker is not None
self.ai_write_button.setEnabled(
default_gateway and not ai_running and not generation_running
)
if not default_gateway and not ai_running:
self.ai_write_button.setToolTip("商品套图AI帮写仅支持默认网关")
self.generate_button.setToolTip("自定义网关生成不计点数,实际费用以服务商为准")
else:
self.ai_write_button.setToolTip("")
self.generate_button.setToolTip("")
self.ai_cancel_button.setVisible(ai_running)
self._update_resume_submitted_action(state)
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_price_worker is not None:
self.ai_write_button.setText("读取扣点中")
elif 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)
retry_enabled, retry_tooltip = self._retry_availability(job)
card.set_retry_enabled(retry_enabled, retry_tooltip)
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
retry_enabled, retry_tooltip = self._retry_availability(job)
if not retry_enabled:
self._message("当前不可重新生成", retry_tooltip)
return
try:
reference_asset_ids = image_studio.job_reference_asset_ids(job)
except Exception as exc:
self._message("读取重试图片失败", _user_error(exc))
return
spec = {
"source_asset_id": job.source_asset_id,
"reference_asset_ids": reference_asset_ids,
"job_type": job.job_type,
"prompt": job.prompt,
}
self.start_generation(
state,
specs=[spec],
retry_job_id=job.id,
confirm_direct_retry=self._is_direct_gateway_job(job),
)
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("重新生成")
retry_enabled, retry_tooltip = self._retry_availability(job)
retry_action.setEnabled(retry_enabled)
retry_action.setToolTip(retry_tooltip)
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()
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,
state.ai_price_worker,
state.generation_price_worker,
):
if worker is not None and hasattr(worker, "cancel"):
worker.cancel()
for worker, thread in list(state.downloads.values()):
worker.cancel()
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)