3402 lines
137 KiB
Python
3402 lines
137 KiB
Python
"""Tab 6: project-bound product suite generation workspace."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
|
||
from PySide6.QtCore import (
|
||
QByteArray,
|
||
QBuffer,
|
||
QIODevice,
|
||
QRect,
|
||
QSize,
|
||
Qt,
|
||
QTimer,
|
||
Signal,
|
||
Slot,
|
||
)
|
||
from PySide6.QtGui import QColor, QIcon, QImage, QImageReader, QKeySequence, QPainter, QPixmap
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QCheckBox,
|
||
QComboBox,
|
||
QFileDialog,
|
||
QFrame,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QInputDialog,
|
||
QLabel,
|
||
QLineEdit,
|
||
QListView,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QMenu,
|
||
QMessageBox,
|
||
QPlainTextEdit,
|
||
QProgressBar,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QSplitter,
|
||
QStyle,
|
||
QStyledItemDelegate,
|
||
QStyleOptionButton,
|
||
QTabBar,
|
||
QToolButton,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ... import (
|
||
accounts,
|
||
appconfig,
|
||
diagnostics,
|
||
image_studio,
|
||
image_studio_images,
|
||
product_suite,
|
||
prompts,
|
||
)
|
||
from .. import file_manager
|
||
from ..image_preview import ImagePreviewDialog
|
||
from ..product_suite_prompt_dialog import ProductSuitePromptDialog
|
||
from ..widgets import _emit_status, run_worker
|
||
from ..workers import (
|
||
ImageStudioDownloadOriginalWorker,
|
||
ImageStudioPullImagesWorker,
|
||
ProductSuiteAiWriteWorker,
|
||
ProductSuiteGenerateWorker,
|
||
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()
|
||
|
||
|
||
def _asset_usable(asset):
|
||
path = str(getattr(asset, "local_path", "") or "")
|
||
return (
|
||
getattr(asset, "status", "") != image_studio.ASSET_STATUS_MISSING
|
||
and bool(path)
|
||
and os.path.isfile(path)
|
||
)
|
||
|
||
|
||
def _placeholder_pixmap(text, size, color="#f3f4f6"):
|
||
pixmap = QPixmap(size)
|
||
pixmap.fill(QColor(color))
|
||
painter = QPainter(pixmap)
|
||
painter.setPen(QColor("#6b7280"))
|
||
painter.drawText(pixmap.rect(), Qt.AlignCenter | Qt.TextWordWrap, str(text or ""))
|
||
painter.end()
|
||
return pixmap
|
||
|
||
|
||
def _image_pixmap(path, size, fallback="暂无图片"):
|
||
image = QImage()
|
||
if path and os.path.isfile(path):
|
||
reader = QImageReader(str(path))
|
||
reader.setAutoTransform(True)
|
||
source_size = reader.size()
|
||
if source_size.isValid():
|
||
source_size.scale(size, Qt.KeepAspectRatio)
|
||
reader.setScaledSize(source_size)
|
||
image = reader.read()
|
||
if image.isNull():
|
||
return _placeholder_pixmap(fallback, size)
|
||
scaled = QPixmap.fromImage(image).scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||
canvas = QPixmap(size)
|
||
canvas.fill(QColor("#f7f8fa"))
|
||
painter = QPainter(canvas)
|
||
painter.drawPixmap(
|
||
max(0, (size.width() - scaled.width()) // 2),
|
||
max(0, (size.height() - scaled.height()) // 2),
|
||
scaled,
|
||
)
|
||
painter.end()
|
||
return canvas
|
||
|
||
|
||
def _user_error(error):
|
||
text = diagnostics.redact_log_text(str(error or "未知错误"))
|
||
text = _URL_RE.sub("[链接已隐藏]", text).replace("\r", " ").replace("\n", " ").strip()
|
||
return text if len(text) <= 90 else text[:87] + "..."
|
||
|
||
|
||
class ProductSuitePreviewDialog(ImagePreviewDialog):
|
||
"""Current product-suite wrapper for the shared full-image preview."""
|
||
|
||
|
||
class ProductOriginalDelegate(QStyledItemDelegate):
|
||
CHECK_HIT_SIZE = 26
|
||
|
||
@classmethod
|
||
def checkbox_hit_rect(cls, item_rect):
|
||
return QRect(
|
||
item_rect.left() + 2,
|
||
item_rect.top() + 2,
|
||
cls.CHECK_HIT_SIZE,
|
||
cls.CHECK_HIT_SIZE,
|
||
)
|
||
|
||
def paint(self, painter, option, index):
|
||
super().paint(painter, option, index)
|
||
if index.data(Qt.UserRole) is None:
|
||
return
|
||
checked = index.data(ORIGINAL_CHECK_STATE_ROLE) == Qt.Checked
|
||
style = QApplication.style()
|
||
width = style.pixelMetric(QStyle.PM_IndicatorWidth)
|
||
height = style.pixelMetric(QStyle.PM_IndicatorHeight)
|
||
hit_rect = self.checkbox_hit_rect(option.rect)
|
||
checkbox = QStyleOptionButton()
|
||
checkbox.rect = QRect(
|
||
hit_rect.center().x() - width // 2,
|
||
hit_rect.center().y() - height // 2,
|
||
width,
|
||
height,
|
||
)
|
||
checkbox.state = QStyle.State_Enabled if option.state & QStyle.State_Enabled else QStyle.State_None
|
||
checkbox.state |= QStyle.State_On if checked else QStyle.State_Off
|
||
style.drawControl(QStyle.CE_CheckBox, checkbox, painter)
|
||
|
||
|
||
class ProductOriginalList(QListWidget):
|
||
MAX_VISIBLE_ASSETS = 16
|
||
EMPTY_STATE_TEXT = "暂无商品原图"
|
||
|
||
filesDropped = Signal(list)
|
||
clipboardImage = Signal(bytes)
|
||
orderChanged = Signal(list)
|
||
deleteRequested = Signal(int)
|
||
deleteAssetsRequested = Signal(list)
|
||
checkedAssetsChanged = Signal(list)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setObjectName("suiteOriginalList")
|
||
self.setViewMode(QListView.IconMode)
|
||
self.setResizeMode(QListView.Adjust)
|
||
self.setMovement(QListView.Snap)
|
||
self.setWrapping(True)
|
||
self.setIconSize(QSize(82, 64))
|
||
self.setGridSize(QSize(112, 98))
|
||
self.setSpacing(4)
|
||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
self.setAcceptDrops(True)
|
||
self.setDragEnabled(True)
|
||
self.setDropIndicatorShown(True)
|
||
self.setDragDropMode(QListWidget.InternalMove)
|
||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.customContextMenuRequested.connect(self._show_menu)
|
||
self.itemChanged.connect(lambda item: self.checkedAssetsChanged.emit(self.checked_asset_ids()))
|
||
self.setItemDelegate(ProductOriginalDelegate(self))
|
||
self.setMouseTracking(True)
|
||
self.itemEntered.connect(self._show_hover_remove)
|
||
self._hovered_asset_id = None
|
||
self._hover_remove_button = QToolButton(self.viewport())
|
||
self._hover_remove_button.setText("×")
|
||
self._hover_remove_button.setToolTip("删除这张商品原图")
|
||
self._hover_remove_button.setAccessibleName("删除商品原图")
|
||
self._hover_remove_button.setFixedSize(28, 28)
|
||
self._hover_remove_button.clicked.connect(self._remove_hovered)
|
||
self._hover_remove_button.hide()
|
||
self.setToolTip("可拖入或粘贴图片;已有缩略图可拖动排序")
|
||
|
||
def paintEvent(self, event):
|
||
super().paintEvent(event)
|
||
if self.count() != 0:
|
||
return
|
||
painter = QPainter(self.viewport())
|
||
painter.setPen(QColor("#6b7280"))
|
||
painter.drawText(
|
||
self.viewport().rect().adjusted(12, 8, -12, -8),
|
||
Qt.AlignCenter,
|
||
self.EMPTY_STATE_TEXT,
|
||
)
|
||
painter.end()
|
||
|
||
def dragEnterEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
return
|
||
super().dragEnterEvent(event)
|
||
|
||
def dragMoveEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
return
|
||
super().dragMoveEvent(event)
|
||
|
||
def dropEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()]
|
||
self.filesDropped.emit(paths)
|
||
event.acceptProposedAction()
|
||
return
|
||
super().dropEvent(event)
|
||
self.orderChanged.emit(self.asset_ids())
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
self.update_content_height()
|
||
|
||
def mousePressEvent(self, event):
|
||
position = event.position().toPoint()
|
||
item = self.itemAt(position)
|
||
if (
|
||
event.button() == Qt.LeftButton
|
||
and item is not None
|
||
and item.data(Qt.UserRole) is not None
|
||
and ProductOriginalDelegate.checkbox_hit_rect(self.visualItemRect(item)).contains(position)
|
||
):
|
||
self._set_item_checked(item, not self._item_checked(item))
|
||
event.accept()
|
||
return
|
||
super().mousePressEvent(event)
|
||
|
||
def mouseDoubleClickEvent(self, event):
|
||
position = event.position().toPoint()
|
||
item = self.itemAt(position)
|
||
if (
|
||
item is not None
|
||
and item.data(Qt.UserRole) is not None
|
||
and ProductOriginalDelegate.checkbox_hit_rect(self.visualItemRect(item)).contains(position)
|
||
):
|
||
event.accept()
|
||
return
|
||
super().mouseDoubleClickEvent(event)
|
||
|
||
def keyPressEvent(self, event):
|
||
if event.matches(QKeySequence.Paste):
|
||
image = QApplication.clipboard().image()
|
||
if not image.isNull():
|
||
payload = QByteArray()
|
||
buffer = QBuffer(payload)
|
||
buffer.open(QIODevice.WriteOnly)
|
||
image.save(buffer, "PNG")
|
||
buffer.close()
|
||
self.clipboardImage.emit(bytes(payload))
|
||
return
|
||
if event.key() in {Qt.Key_Delete, Qt.Key_Backspace}:
|
||
checked_ids = self.checked_asset_ids()
|
||
if checked_ids:
|
||
self.deleteAssetsRequested.emit(checked_ids)
|
||
return
|
||
item = self.currentItem()
|
||
if item is not None and item.data(Qt.UserRole) is not None:
|
||
self.deleteRequested.emit(int(item.data(Qt.UserRole)))
|
||
return
|
||
if event.key() == Qt.Key_Space:
|
||
item = self.currentItem()
|
||
if item is not None and item.data(Qt.UserRole) is not None:
|
||
self._set_item_checked(item, not self._item_checked(item))
|
||
return
|
||
super().keyPressEvent(event)
|
||
|
||
def asset_ids(self):
|
||
return [
|
||
int(self.item(row).data(Qt.UserRole))
|
||
for row in range(self.count())
|
||
if self.item(row).data(Qt.UserRole) is not None
|
||
]
|
||
|
||
def checked_asset_ids(self):
|
||
return [
|
||
int(self.item(row).data(Qt.UserRole))
|
||
for row in range(self.count())
|
||
if self.item(row).data(Qt.UserRole) is not None
|
||
and self._item_checked(self.item(row))
|
||
]
|
||
|
||
def set_checked_asset_ids(self, asset_ids):
|
||
checked_ids = {int(asset_id) for asset_id in asset_ids or []}
|
||
previous = self.blockSignals(True)
|
||
try:
|
||
for row in range(self.count()):
|
||
item = self.item(row)
|
||
asset_id = item.data(Qt.UserRole)
|
||
if asset_id is not None:
|
||
item.setData(
|
||
ORIGINAL_CHECK_STATE_ROLE,
|
||
Qt.Checked if int(asset_id) in checked_ids else Qt.Unchecked,
|
||
)
|
||
finally:
|
||
self.blockSignals(previous)
|
||
self.viewport().update()
|
||
self.checkedAssetsChanged.emit(self.checked_asset_ids())
|
||
|
||
def select_all_assets(self):
|
||
self.set_checked_asset_ids(self.asset_ids())
|
||
|
||
def invert_asset_checks(self):
|
||
checked = set(self.checked_asset_ids())
|
||
self.set_checked_asset_ids([asset_id for asset_id in self.asset_ids() if asset_id not in checked])
|
||
|
||
def clear_checks(self):
|
||
self.set_checked_asset_ids([])
|
||
|
||
def content_column_count(self):
|
||
step = max(1, self.gridSize().width() + self.spacing())
|
||
available = max(1, self.viewport().width() - self.spacing())
|
||
return max(1, (available + self.spacing()) // step)
|
||
|
||
def content_row_count(self):
|
||
count = min(self.MAX_VISIBLE_ASSETS, self.count())
|
||
if count == 0:
|
||
return 1
|
||
columns = self.content_column_count()
|
||
return (count + columns - 1) // columns
|
||
|
||
def update_content_height(self):
|
||
rows = self.content_row_count()
|
||
target = (
|
||
rows * self.gridSize().height()
|
||
+ (rows + 1) * self.spacing()
|
||
+ 2 * self.frameWidth()
|
||
)
|
||
if target != self.height():
|
||
self.setFixedHeight(target)
|
||
|
||
def _show_menu(self, position):
|
||
item = self.itemAt(position)
|
||
if item is None or item.data(Qt.UserRole) is None:
|
||
return
|
||
menu = QMenu(self)
|
||
actions = []
|
||
for label, asset_ids in self.context_delete_options(int(item.data(Qt.UserRole))):
|
||
actions.append((menu.addAction(label), asset_ids))
|
||
selected = menu.exec(self.viewport().mapToGlobal(position))
|
||
for action, asset_ids in actions:
|
||
if selected is action:
|
||
if len(asset_ids) == 1 and asset_ids[0] not in self.checked_asset_ids():
|
||
self.deleteRequested.emit(asset_ids[0])
|
||
else:
|
||
self.deleteAssetsRequested.emit(asset_ids)
|
||
return
|
||
|
||
def context_delete_options(self, clicked_asset_id):
|
||
clicked_asset_id = int(clicked_asset_id)
|
||
checked_ids = self.checked_asset_ids()
|
||
if clicked_asset_id in checked_ids:
|
||
if len(checked_ids) > 1:
|
||
return [("删除选中的%d张图片…" % len(checked_ids), checked_ids)]
|
||
return [("删除选中图片…", checked_ids)]
|
||
options = [("删除这张图片…", [clicked_asset_id])]
|
||
if checked_ids:
|
||
label = (
|
||
"删除选中的%d张图片…" % len(checked_ids)
|
||
if len(checked_ids) > 1
|
||
else "删除选中图片…"
|
||
)
|
||
options.append((label, checked_ids))
|
||
return options
|
||
|
||
@staticmethod
|
||
def _item_checked(item):
|
||
return item.data(ORIGINAL_CHECK_STATE_ROLE) == Qt.Checked
|
||
|
||
@staticmethod
|
||
def _set_item_checked(item, checked):
|
||
item.setData(ORIGINAL_CHECK_STATE_ROLE, Qt.Checked if checked else Qt.Unchecked)
|
||
|
||
def _show_hover_remove(self, item):
|
||
value = item.data(Qt.UserRole)
|
||
if value is None:
|
||
self._hovered_asset_id = None
|
||
self._hover_remove_button.hide()
|
||
return
|
||
self._hovered_asset_id = int(value)
|
||
rect = self.visualItemRect(item)
|
||
self._hover_remove_button.move(max(0, rect.right() - 27), rect.top() + 1)
|
||
self._hover_remove_button.show()
|
||
self._hover_remove_button.raise_()
|
||
|
||
def _remove_hovered(self):
|
||
if self._hovered_asset_id is not None:
|
||
self.deleteRequested.emit(int(self._hovered_asset_id))
|
||
|
||
def leaveEvent(self, event):
|
||
self._hovered_asset_id = None
|
||
self._hover_remove_button.hide()
|
||
super().leaveEvent(event)
|
||
|
||
|
||
class SuiteCategoryRow(QFrame):
|
||
countChangeRequested = Signal(str, int)
|
||
renameRequested = Signal(str)
|
||
deleteRequested = Signal(str)
|
||
|
||
def __init__(self, name, count, *, helper="", custom=False, parent=None):
|
||
super().__init__(parent)
|
||
self.category_name = str(name)
|
||
self._controls_enabled = True
|
||
self.setObjectName("suiteCategoryRow")
|
||
self.setProperty("categoryName", self.category_name)
|
||
self.setStyleSheet(
|
||
"QFrame#suiteCategoryRow { border: none; border-bottom: 1px solid #eaeef2; }"
|
||
)
|
||
|
||
layout = QGridLayout(self)
|
||
layout.setContentsMargins(6, 5, 4, 6)
|
||
layout.setHorizontalSpacing(8)
|
||
layout.setVerticalSpacing(2)
|
||
layout.setColumnStretch(0, 1)
|
||
|
||
self.name_label = QLabel(self.category_name)
|
||
self.name_label.setObjectName("suiteCategoryNameLabel")
|
||
self.name_label.setStyleSheet("font-weight: 600; color: #24292f;")
|
||
layout.addWidget(self.name_label, 0, 0)
|
||
|
||
controls_widget = QWidget()
|
||
controls = QHBoxLayout(controls_widget)
|
||
controls.setContentsMargins(0, 0, 0, 0)
|
||
controls.setSpacing(4)
|
||
|
||
self.minus_button = QToolButton()
|
||
self.minus_button.setObjectName("suiteCategoryMinusButton")
|
||
self.minus_button.setText("−")
|
||
self.minus_button.setToolTip("减少%s数量" % self.category_name)
|
||
self.minus_button.setAccessibleName("减少%s数量" % self.category_name)
|
||
self.minus_button.setMinimumSize(28, 28)
|
||
self.minus_button.clicked.connect(
|
||
lambda checked=False: self.countChangeRequested.emit(self.category_name, -1)
|
||
)
|
||
controls.addWidget(self.minus_button)
|
||
|
||
self.count_label = QLabel()
|
||
self.count_label.setObjectName("suiteCategoryCountLabel")
|
||
self.count_label.setAlignment(Qt.AlignCenter)
|
||
self.count_label.setMinimumWidth(28)
|
||
controls.addWidget(self.count_label)
|
||
|
||
self.plus_button = QToolButton()
|
||
self.plus_button.setObjectName("suiteCategoryPlusButton")
|
||
self.plus_button.setText("+")
|
||
self.plus_button.setToolTip("增加%s数量" % self.category_name)
|
||
self.plus_button.setAccessibleName("增加%s数量" % self.category_name)
|
||
self.plus_button.setMinimumSize(28, 28)
|
||
self.plus_button.clicked.connect(
|
||
lambda checked=False: self.countChangeRequested.emit(self.category_name, 1)
|
||
)
|
||
controls.addWidget(self.plus_button)
|
||
|
||
self.rename_button = None
|
||
self.delete_button = None
|
||
if custom:
|
||
self.rename_button = QToolButton()
|
||
self.rename_button.setObjectName("suiteCategoryRenameButton")
|
||
self.rename_button.setText("改名")
|
||
self.rename_button.setToolTip("重命名%s分类" % self.category_name)
|
||
self.rename_button.setAccessibleName("重命名%s分类" % self.category_name)
|
||
self.rename_button.setMinimumSize(48, 28)
|
||
self.rename_button.clicked.connect(
|
||
lambda checked=False: self.renameRequested.emit(self.category_name)
|
||
)
|
||
controls.addWidget(self.rename_button)
|
||
|
||
self.delete_button = QToolButton()
|
||
self.delete_button.setObjectName("suiteCategoryDeleteButton")
|
||
self.delete_button.setText("×")
|
||
self.delete_button.setToolTip("删除%s分类" % self.category_name)
|
||
self.delete_button.setAccessibleName("删除%s分类" % self.category_name)
|
||
self.delete_button.setMinimumSize(28, 28)
|
||
self.delete_button.clicked.connect(
|
||
lambda checked=False: self.deleteRequested.emit(self.category_name)
|
||
)
|
||
controls.addWidget(self.delete_button)
|
||
|
||
layout.addWidget(controls_widget, 0, 1, 2 if helper else 1, 1, Qt.AlignTop)
|
||
self.helper_label = None
|
||
if helper:
|
||
self.helper_label = QLabel(str(helper))
|
||
self.helper_label.setObjectName("suiteCategoryHelperLabel")
|
||
self.helper_label.setStyleSheet("color: #6b7280;")
|
||
self.helper_label.setWordWrap(True)
|
||
layout.addWidget(self.helper_label, 1, 0)
|
||
self.set_count(count)
|
||
|
||
def set_count(self, count):
|
||
normalized = max(0, int(count or 0))
|
||
self.count_label.setText(str(normalized))
|
||
self.minus_button.setEnabled(self._controls_enabled and normalized > 0)
|
||
|
||
def count(self):
|
||
return int(self.count_label.text() or 0)
|
||
|
||
def set_controls_enabled(self, enabled):
|
||
self._controls_enabled = bool(enabled)
|
||
self.minus_button.setEnabled(self._controls_enabled and self.count() > 0)
|
||
self.plus_button.setEnabled(self._controls_enabled)
|
||
if self.rename_button is not None:
|
||
self.rename_button.setEnabled(self._controls_enabled)
|
||
if self.delete_button is not None:
|
||
self.delete_button.setEnabled(self._controls_enabled)
|
||
|
||
|
||
class SuiteResultCard(QFrame):
|
||
previewRequested = Signal(object)
|
||
retryRequested = Signal(object)
|
||
menuRequested = Signal(object, object)
|
||
deleteRequested = Signal(object)
|
||
|
||
def __init__(self, job, asset=None, parent=None):
|
||
super().__init__(parent)
|
||
self.job = job
|
||
self.asset = asset
|
||
self.setObjectName("suiteResultCard")
|
||
self.setFrameShape(QFrame.StyledPanel)
|
||
self.setFixedSize(186, 224)
|
||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.customContextMenuRequested.connect(
|
||
lambda point: self.menuRequested.emit(self.job, self.mapToGlobal(point))
|
||
)
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(7, 7, 7, 7)
|
||
layout.setSpacing(5)
|
||
|
||
title_row = QHBoxLayout()
|
||
title = QLabel(str(getattr(job, "job_type", "套图")))
|
||
title.setStyleSheet("font-weight: 600; color: #24292f;")
|
||
title_row.addWidget(title, 1)
|
||
if asset is not None and _asset_usable(asset):
|
||
delete_button = QToolButton()
|
||
delete_button.setText("×")
|
||
delete_button.setToolTip("删除这张生成图片")
|
||
delete_button.setAccessibleName("删除生成图片")
|
||
delete_button.setMinimumSize(28, 28)
|
||
delete_button.clicked.connect(lambda: self.deleteRequested.emit(self.job))
|
||
title_row.addWidget(delete_button)
|
||
layout.addLayout(title_row)
|
||
|
||
image_label = QLabel()
|
||
image_label.setAlignment(Qt.AlignCenter)
|
||
image_label.setFixedSize(170, 150)
|
||
status = str(getattr(job, "status", "pending") or "pending")
|
||
if asset is not None and _asset_usable(asset):
|
||
image_label.setPixmap(_image_pixmap(asset.local_path, QSize(170, 150)))
|
||
else:
|
||
label = {
|
||
"pending": "等待提交",
|
||
"submitted": "排队中",
|
||
"running": "生成中",
|
||
"failed": "生成失败",
|
||
"expired": "任务过期",
|
||
"cancelled": "已停止",
|
||
"succeeded": "图片已删除",
|
||
}.get(status, "处理中")
|
||
color = "#ffebe9" if status in {"failed", "expired"} else "#f3f4f6"
|
||
image_label.setPixmap(_placeholder_pixmap(label, QSize(170, 150), color))
|
||
image_label.mousePressEvent = lambda event: (
|
||
self.previewRequested.emit(self.job)
|
||
if event.button() == Qt.LeftButton
|
||
else None
|
||
)
|
||
layout.addWidget(image_label)
|
||
|
||
footer = QHBoxLayout()
|
||
status_text = {
|
||
"pending": "等待提交",
|
||
"submitted": "已提交",
|
||
"running": "生成中",
|
||
"succeeded": "生成成功" if asset is not None and _asset_usable(asset) else "已删除",
|
||
"failed": "失败:%s" % _user_error(getattr(job, "error", "")),
|
||
"expired": "已过期",
|
||
"cancelled": "已停止",
|
||
}.get(status, status)
|
||
status_label = QLabel(status_text)
|
||
status_label.setWordWrap(False)
|
||
status_label.setToolTip(status_text)
|
||
footer.addWidget(status_label, 1)
|
||
if status in {"failed", "expired", "cancelled"}:
|
||
retry_button = QPushButton("重试")
|
||
retry_button.setMinimumWidth(52)
|
||
retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job))
|
||
footer.addWidget(retry_button)
|
||
layout.addLayout(footer)
|
||
|
||
|
||
@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)
|
||
show_history: bool = False
|
||
generation_job_ids: list = field(default_factory=list)
|
||
generation_mode: str = "batch"
|
||
generation_retry_job_id: int = None
|
||
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
|
||
import_worker: object = None
|
||
import_thread: object = None
|
||
import_created_draft: bool = False
|
||
ai_worker: object = None
|
||
ai_thread: object = None
|
||
download_queue: list = field(default_factory=list)
|
||
downloads: dict = field(default_factory=dict)
|
||
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
|
||
|
||
|
||
class ProductSuiteTab(QWidget):
|
||
"""Native PySide6 product-suite UI backed by image_studio services."""
|
||
|
||
PLATFORM_OPTIONS = product_suite.PLATFORMS
|
||
COUNTRY_OPTIONS = product_suite.COUNTRIES
|
||
LANGUAGE_OPTIONS = product_suite.LANGUAGES
|
||
|
||
def __init__(
|
||
self,
|
||
parent=None,
|
||
db_path=None,
|
||
config=None,
|
||
config_path=None,
|
||
status_callback=None,
|
||
):
|
||
super().__init__(parent)
|
||
self.setObjectName("productSuiteTab")
|
||
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
||
self.db_path = db_path or appconfig.db_path(self.config)
|
||
self.config_path = config_path or self.config.get("config_path") or appconfig.CONFIG_PATH
|
||
self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config)
|
||
self.product_suite_prompt_path = appconfig.product_suite_prompt_path(self.config)
|
||
self.status_callback = status_callback
|
||
self.accounts = []
|
||
self._states = {}
|
||
self._retired_states = []
|
||
self._next_key = 1
|
||
self._next_serial = 1
|
||
self._displayed_state = None
|
||
self._prompt_save_timers = {}
|
||
self._generation_run_states = {}
|
||
self._original_list_context = None
|
||
self._loading = False
|
||
self._result_refresh_pending = False
|
||
self._prompt_template_init_error = ""
|
||
|
||
try:
|
||
prompts.ensure_default_product_suite_prompt(self.product_suite_prompt_path)
|
||
except prompts.PromptError as exc:
|
||
self._prompt_template_init_error = str(exc)
|
||
|
||
self._build_ui()
|
||
self._connect_signals()
|
||
self.refresh_accounts()
|
||
self._restore_draft_tasks()
|
||
if not self._states:
|
||
self.add_task(inherit=False)
|
||
self.elapsed_timer = QTimer(self)
|
||
self.elapsed_timer.setInterval(1000)
|
||
self.elapsed_timer.timeout.connect(self._refresh_elapsed)
|
||
self.elapsed_timer.start()
|
||
self.generation_watchdog_timer = QTimer(self)
|
||
self.generation_watchdog_timer.setInterval(750)
|
||
self.generation_watchdog_timer.timeout.connect(self._check_generation_watchdogs)
|
||
self.generation_watchdog_timer.start()
|
||
if self._prompt_template_init_error:
|
||
self._status(self._prompt_template_init_error, "danger")
|
||
|
||
def _build_ui(self):
|
||
root = QVBoxLayout(self)
|
||
root.setContentsMargins(10, 8, 10, 8)
|
||
root.setSpacing(8)
|
||
root.addWidget(self._build_task_bar())
|
||
root.addWidget(self._build_context_bar())
|
||
|
||
self.main_splitter = QSplitter(Qt.Horizontal)
|
||
self.main_splitter.setObjectName("suiteMainSplitter")
|
||
self.main_splitter.addWidget(self._build_config_panel())
|
||
self.main_splitter.addWidget(self._build_results_panel())
|
||
self.main_splitter.setStretchFactor(0, 2)
|
||
self.main_splitter.setStretchFactor(1, 3)
|
||
self.main_splitter.setSizes([430, 700])
|
||
root.addWidget(self.main_splitter, 1)
|
||
|
||
def _build_task_bar(self):
|
||
frame = QFrame()
|
||
layout = QHBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
self.task_tabs = QTabBar()
|
||
self.task_tabs.setObjectName("suiteTaskTabs")
|
||
self.task_tabs.setTabsClosable(True)
|
||
self.task_tabs.setMovable(True)
|
||
self.task_tabs.setExpanding(False)
|
||
self.task_tabs.setFixedHeight(36)
|
||
self.task_tabs.setStyleSheet(
|
||
"""
|
||
QTabBar#suiteTaskTabs::tab {
|
||
min-width: 96px;
|
||
min-height: 26px;
|
||
max-height: 26px;
|
||
padding: 3px 10px;
|
||
margin-right: 4px;
|
||
border: 1px solid #c9d1d9;
|
||
border-bottom-color: #b8c0ca;
|
||
background: #f4f6f8;
|
||
color: #24292f;
|
||
}
|
||
QTabBar#suiteTaskTabs::tab:selected {
|
||
background: #ffffff;
|
||
border-color: #687785;
|
||
border-bottom-color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
QTabBar#suiteTaskTabs::tab:hover:!selected {
|
||
background: #eaf2ff;
|
||
}
|
||
"""
|
||
)
|
||
layout.addWidget(self.task_tabs, 1)
|
||
self.add_task_button = QToolButton()
|
||
self.add_task_button.setObjectName("suiteAddTaskButton")
|
||
self.add_task_button.setText("+")
|
||
self.add_task_button.setToolTip("新建套图任务")
|
||
self.add_task_button.setAccessibleName("新建套图任务")
|
||
self.add_task_button.setMinimumSize(32, 32)
|
||
layout.addWidget(self.add_task_button)
|
||
return frame
|
||
|
||
def _build_context_bar(self):
|
||
frame = QFrame()
|
||
frame.setObjectName("suiteContextBar")
|
||
layout = QHBoxLayout(frame)
|
||
self.context_bar_layout = layout
|
||
layout.setContentsMargins(10, 7, 10, 7)
|
||
layout.setSpacing(8)
|
||
self.history_button = QPushButton("历史生成")
|
||
self.history_button.setObjectName("suiteHistoryButton")
|
||
self.history_button.setCheckable(True)
|
||
layout.addWidget(self.history_button)
|
||
self.open_folder_button = QPushButton("打开结果文件夹")
|
||
self.open_folder_button.setObjectName("suiteOpenFolderButton")
|
||
layout.addWidget(self.open_folder_button)
|
||
self.add_images_button = QPushButton("添加图片")
|
||
self.add_images_button.setObjectName("suiteAddImagesButton")
|
||
self.add_images_button.setAccessibleName("添加商品原图")
|
||
self.add_images_button.setToolTip("添加本地商品原图")
|
||
self.add_images_button.setStyleSheet(
|
||
"QPushButton#suiteAddImagesButton { "
|
||
"color: #0969da; border: 1px solid #0969da; "
|
||
"background-color: #eef4ff; font-weight: 600; "
|
||
"}"
|
||
"QPushButton#suiteAddImagesButton:hover { background-color: #dbeafe; }"
|
||
"QPushButton#suiteAddImagesButton:pressed { background-color: #c7ddff; }"
|
||
"QPushButton#suiteAddImagesButton:disabled { "
|
||
"color: #8c959f; border-color: #d8dee4; background-color: #f6f8fa; "
|
||
"}"
|
||
)
|
||
layout.addWidget(self.add_images_button)
|
||
self.item_id_hint_label = QLabel("请输入正确的商品ID")
|
||
self.item_id_hint_label.setObjectName("suiteItemIdHintLabel")
|
||
self.item_id_hint_label.setStyleSheet("color: #9a6700; font-weight: 600;")
|
||
self.item_id_hint_label.setToolTip("未绑定正式商品ID时,可添加本地图片和生成套图,但不能拉取蝦皮主图")
|
||
self.item_id_hint_label.setAccessibleName("商品ID提示")
|
||
self.item_id_hint_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
||
layout.addWidget(self.item_id_hint_label)
|
||
layout.addStretch(1)
|
||
layout.addWidget(QLabel("账号"))
|
||
self.account_combo = QComboBox()
|
||
self.account_combo.setObjectName("suiteAccountCombo")
|
||
self.account_combo.setMinimumWidth(120)
|
||
self.account_combo.setMaximumWidth(160)
|
||
self.account_combo.setMinimumContentsLength(10)
|
||
layout.addWidget(self.account_combo)
|
||
layout.addWidget(QLabel("商品ID"))
|
||
self.item_id_edit = QLineEdit()
|
||
self.item_id_edit.setObjectName("suiteItemIdEdit")
|
||
self.item_id_edit.setPlaceholderText("输入商品ID")
|
||
item_width = max(
|
||
120,
|
||
min(140, self.item_id_edit.fontMetrics().horizontalAdvance("0" * 13) + 30),
|
||
)
|
||
self.item_id_edit.setMinimumWidth(item_width)
|
||
self.item_id_edit.setMaximumWidth(item_width)
|
||
layout.addWidget(self.item_id_edit)
|
||
self.pull_button = QPushButton("拉取蝦皮主图")
|
||
self.pull_button.setObjectName("suitePullButton")
|
||
layout.addWidget(self.pull_button)
|
||
return frame
|
||
|
||
def _build_config_panel(self):
|
||
panel = QWidget()
|
||
panel.setMinimumWidth(400)
|
||
layout = QVBoxLayout(panel)
|
||
layout.setContentsMargins(0, 0, 4, 0)
|
||
layout.setSpacing(7)
|
||
|
||
scroll = QScrollArea()
|
||
scroll.setObjectName("suiteConfigScroll")
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.NoFrame)
|
||
content = QWidget()
|
||
self.config_layout = QVBoxLayout(content)
|
||
self.config_layout.setContentsMargins(4, 2, 8, 2)
|
||
self.config_layout.setSpacing(9)
|
||
self.config_layout.addWidget(self._build_original_section())
|
||
self.config_layout.addWidget(self._build_settings_section())
|
||
self.config_layout.addWidget(self._build_prompt_section())
|
||
self.config_layout.addWidget(self._build_category_section())
|
||
self.config_layout.addStretch(1)
|
||
scroll.setWidget(content)
|
||
layout.addWidget(scroll, 1)
|
||
|
||
self.generate_button = QPushButton("生成套图(0)")
|
||
self.generate_button.setObjectName("suiteGenerateButton")
|
||
self.generate_button.setMinimumHeight(42)
|
||
self.generate_button.setStyleSheet(
|
||
"QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }"
|
||
"QPushButton:hover { background: #245fce; }"
|
||
)
|
||
layout.addWidget(self.generate_button)
|
||
self.generate_helper_label = QLabel("建议填写产品名称、核心卖点、目标人群、使用场景与禁用元素")
|
||
self.generate_helper_label.setWordWrap(True)
|
||
self.generate_helper_label.setStyleSheet("color: #6b7280;")
|
||
layout.addWidget(self.generate_helper_label)
|
||
return panel
|
||
|
||
def _build_original_section(self):
|
||
frame = QFrame()
|
||
layout = QVBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
title_row = QHBoxLayout()
|
||
title = QLabel("商品原图")
|
||
title.setStyleSheet("font-weight: 600;")
|
||
title_row.addWidget(title)
|
||
self.original_count_label = QLabel("0/16")
|
||
self.original_count_label.setStyleSheet("color: #6b7280;")
|
||
title_row.addWidget(self.original_count_label)
|
||
title_row.addStretch(1)
|
||
self.original_selected_label = QLabel("已选 0 张")
|
||
self.original_selected_label.setObjectName("suiteOriginalSelectedLabel")
|
||
self.original_selected_label.setStyleSheet("color: #6b7280;")
|
||
title_row.addWidget(self.original_selected_label)
|
||
self.select_all_originals_button = QToolButton()
|
||
self.select_all_originals_button.setObjectName("suiteSelectAllOriginalsButton")
|
||
self.select_all_originals_button.setText("全选")
|
||
self.select_all_originals_button.setToolTip("选择当前商品的全部原图")
|
||
self.select_all_originals_button.setAccessibleName("全选商品原图")
|
||
self.select_all_originals_button.setMinimumSize(48, 28)
|
||
title_row.addWidget(self.select_all_originals_button)
|
||
self.invert_originals_button = QToolButton()
|
||
self.invert_originals_button.setObjectName("suiteInvertOriginalsButton")
|
||
self.invert_originals_button.setText("反选")
|
||
self.invert_originals_button.setToolTip("反转当前商品原图的勾选状态")
|
||
self.invert_originals_button.setAccessibleName("反选商品原图")
|
||
self.invert_originals_button.setMinimumSize(48, 28)
|
||
title_row.addWidget(self.invert_originals_button)
|
||
layout.addLayout(title_row)
|
||
self.original_list = ProductOriginalList()
|
||
layout.addWidget(self.original_list)
|
||
return frame
|
||
|
||
def _build_settings_section(self):
|
||
frame = QFrame()
|
||
layout = QVBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
title = QLabel("生成设置")
|
||
title.setStyleSheet("font-weight: 600;")
|
||
layout.addWidget(title)
|
||
grid = QGridLayout()
|
||
self.settings_grid = grid
|
||
grid.setContentsMargins(0, 0, 0, 0)
|
||
grid.setHorizontalSpacing(6)
|
||
grid.setVerticalSpacing(3)
|
||
settings = (
|
||
(
|
||
"platform_label",
|
||
"platform_combo",
|
||
"平台",
|
||
"suitePlatformLabel",
|
||
"suitePlatformCombo",
|
||
self.PLATFORM_OPTIONS,
|
||
),
|
||
(
|
||
"country_label",
|
||
"country_combo",
|
||
"站点",
|
||
"suiteCountryLabel",
|
||
"suiteCountryCombo",
|
||
self.COUNTRY_OPTIONS,
|
||
),
|
||
(
|
||
"language_label",
|
||
"language_combo",
|
||
"语言",
|
||
"suiteLanguageLabel",
|
||
"suiteLanguageCombo",
|
||
self.LANGUAGE_OPTIONS,
|
||
),
|
||
(
|
||
"ratio_label",
|
||
"ratio_combo",
|
||
"比例",
|
||
"suiteRatioLabel",
|
||
"suiteRatioCombo",
|
||
product_suite.RATIOS,
|
||
),
|
||
)
|
||
for column, setting in enumerate(settings):
|
||
label_attr, combo_attr, text, label_name, combo_name, values = setting
|
||
label = QLabel(text)
|
||
label.setObjectName(label_name)
|
||
setattr(self, label_attr, label)
|
||
combo = self._value_combo(combo_name, text, values)
|
||
setattr(self, combo_attr, combo)
|
||
grid.addWidget(label, 0, column)
|
||
grid.addWidget(combo, 1, column)
|
||
grid.setColumnStretch(column, 1)
|
||
layout.addLayout(grid)
|
||
self.per_image_checkbox = QCheckBox("每张上传图分别作为主图生成")
|
||
self.per_image_checkbox.setObjectName("suitePerImageCheckbox")
|
||
layout.addWidget(self.per_image_checkbox)
|
||
return frame
|
||
|
||
def _value_combo(self, object_name, label, values):
|
||
combo = QComboBox()
|
||
combo.setObjectName(object_name)
|
||
combo.setToolTip("%s设置" % label)
|
||
combo.setAccessibleName("%s设置" % label)
|
||
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||
for value in values:
|
||
combo.addItem(value, value)
|
||
combo.setItemData(combo.count() - 1, value, Qt.ToolTipRole)
|
||
return combo
|
||
|
||
def _build_prompt_section(self):
|
||
frame = QFrame()
|
||
layout = QVBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
title_row = QHBoxLayout()
|
||
self.prompt_title_layout = title_row
|
||
title = QLabel("商品卖点与要求")
|
||
self.prompt_title_label = title
|
||
title.setStyleSheet("font-weight: 600;")
|
||
title_row.addWidget(title)
|
||
self.ai_write_button = QPushButton("AI 帮写")
|
||
self.ai_write_button.setObjectName("suiteAiWriteButton")
|
||
title_row.addWidget(self.ai_write_button)
|
||
self.ai_cancel_button = QPushButton("取消")
|
||
self.ai_cancel_button.setObjectName("suiteAiCancelButton")
|
||
self.ai_cancel_button.hide()
|
||
title_row.addWidget(self.ai_cancel_button)
|
||
title_row.addStretch(1)
|
||
self.prompt_settings_button = QPushButton("提示词设置")
|
||
self.prompt_settings_button.setObjectName("suitePromptSettingsButton")
|
||
self.prompt_settings_button.setToolTip("编辑并预览套图最终提示词")
|
||
title_row.addWidget(self.prompt_settings_button)
|
||
layout.addLayout(title_row)
|
||
self.prompt_edit = AutoHeightPlainTextEdit()
|
||
self.prompt_edit.setObjectName("suitePromptEdit")
|
||
self.prompt_edit.setPlaceholderText("输入产品名称、卖点、目标人群、使用场景和禁用元素")
|
||
layout.addWidget(self.prompt_edit)
|
||
helper = QLabel("内容越具体,生成的商品套图越稳定。AI 帮写不会阻塞其他套图任务。")
|
||
helper.setWordWrap(True)
|
||
helper.setStyleSheet("color: #6b7280;")
|
||
layout.addWidget(helper)
|
||
return frame
|
||
|
||
def _build_category_section(self):
|
||
frame = QFrame()
|
||
layout = QVBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
title_row = QHBoxLayout()
|
||
title = QLabel("套图结构配置")
|
||
title.setStyleSheet("font-weight: 600;")
|
||
title_row.addWidget(title)
|
||
title_row.addStretch(1)
|
||
self.category_total_label = QLabel("合计 0 张")
|
||
self.category_total_label.setStyleSheet("color: #2f6fed; font-weight: 600;")
|
||
title_row.addWidget(self.category_total_label)
|
||
layout.addLayout(title_row)
|
||
self.category_grid = QGridLayout()
|
||
self.category_grid.setContentsMargins(0, 0, 0, 0)
|
||
self.category_grid.setHorizontalSpacing(0)
|
||
self.category_grid.setVerticalSpacing(3)
|
||
self.category_grid.setColumnStretch(0, 1)
|
||
self.category_rows = {}
|
||
layout.addLayout(self.category_grid)
|
||
self.add_category_button = QPushButton("添加自定义分类")
|
||
self.add_category_button.setObjectName("suiteAddCategoryButton")
|
||
layout.addWidget(self.add_category_button)
|
||
self.custom_category_edit = QLineEdit()
|
||
self.custom_category_edit.setObjectName("suiteCustomCategoryEdit")
|
||
self.custom_category_edit.setPlaceholderText("输入分类名称后按回车")
|
||
self.custom_category_edit.hide()
|
||
layout.addWidget(self.custom_category_edit)
|
||
return frame
|
||
|
||
def _build_results_panel(self):
|
||
panel = QWidget()
|
||
layout = QVBoxLayout(panel)
|
||
layout.setContentsMargins(4, 0, 0, 0)
|
||
layout.setSpacing(7)
|
||
toolbar = QHBoxLayout()
|
||
self.results_toolbar_layout = toolbar
|
||
title = QLabel("生成结果")
|
||
title.setStyleSheet("font-weight: 600; font-size: 15px;")
|
||
toolbar.addWidget(title)
|
||
self.result_summary_label = QLabel("共 0 张 · 成功 0 张")
|
||
self.result_summary_label.setObjectName("suiteResultSummaryLabel")
|
||
self.result_summary_label.setStyleSheet(
|
||
"background: #eef4ff; color: #2459c4; padding: 3px 8px; border-radius: 4px;"
|
||
)
|
||
toolbar.addWidget(self.result_summary_label)
|
||
toolbar.addStretch(1)
|
||
self.undo_button = QPushButton("撤销删除")
|
||
self.undo_button.setObjectName("suiteUndoButton")
|
||
self.undo_button.hide()
|
||
toolbar.addWidget(self.undo_button)
|
||
self.more_button = QToolButton()
|
||
self.more_button.setText("⋯")
|
||
self.more_button.setToolTip("更多操作")
|
||
self.more_button.setAccessibleName("更多操作")
|
||
self.more_button.setMinimumSize(32, 32)
|
||
toolbar.addWidget(self.more_button)
|
||
layout.addLayout(toolbar)
|
||
|
||
self.result_scroll = QScrollArea()
|
||
self.result_scroll.setObjectName("suiteResultScroll")
|
||
self.result_scroll.setWidgetResizable(True)
|
||
self.result_scroll.setFrameShape(QFrame.StyledPanel)
|
||
self.result_content = QWidget()
|
||
self.result_grid = QGridLayout(self.result_content)
|
||
self.result_grid.setContentsMargins(12, 12, 12, 12)
|
||
self.result_grid.setSpacing(10)
|
||
self.result_grid.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||
self.result_scroll.setWidget(self.result_content)
|
||
layout.addWidget(self.result_scroll, 1)
|
||
|
||
bottom = QHBoxLayout()
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setObjectName("suiteProgressBar")
|
||
self.progress_bar.setRange(0, 1)
|
||
self.progress_bar.setValue(0)
|
||
self.progress_bar.setTextVisible(False)
|
||
bottom.addWidget(self.progress_bar, 1)
|
||
self.elapsed_label = QLabel("套图 0/0(0秒) · 失败 0")
|
||
self.elapsed_label.setObjectName("suiteElapsedLabel")
|
||
self.elapsed_label.setMinimumWidth(190)
|
||
bottom.addWidget(self.elapsed_label)
|
||
layout.addLayout(bottom)
|
||
return panel
|
||
|
||
def _connect_signals(self):
|
||
self.task_tabs.currentChanged.connect(self._on_task_changed)
|
||
self.task_tabs.tabCloseRequested.connect(self.close_task)
|
||
self.add_task_button.clicked.connect(lambda: self.add_task(inherit=True))
|
||
self.account_combo.currentIndexChanged.connect(self._on_account_changed)
|
||
self.item_id_edit.editingFinished.connect(self._on_item_finished)
|
||
self.pull_button.clicked.connect(self.pull_main_images)
|
||
self.add_images_button.clicked.connect(self.choose_images)
|
||
self.original_list.filesDropped.connect(self.import_files)
|
||
self.original_list.clipboardImage.connect(self.import_clipboard_image)
|
||
self.original_list.orderChanged.connect(self.reorder_originals)
|
||
self.original_list.deleteRequested.connect(self.delete_original)
|
||
self.original_list.deleteAssetsRequested.connect(self.delete_originals)
|
||
self.original_list.checkedAssetsChanged.connect(self._on_original_checks_changed)
|
||
self.original_list.itemClicked.connect(self._on_original_clicked)
|
||
self.original_list.itemDoubleClicked.connect(self._preview_original)
|
||
self.select_all_originals_button.clicked.connect(
|
||
lambda checked=False: self.original_list.select_all_assets()
|
||
)
|
||
self.invert_originals_button.clicked.connect(
|
||
lambda checked=False: self.original_list.invert_asset_checks()
|
||
)
|
||
for combo in (
|
||
self.platform_combo,
|
||
self.country_combo,
|
||
self.language_combo,
|
||
self.ratio_combo,
|
||
):
|
||
combo.currentIndexChanged.connect(self._on_settings_changed)
|
||
self.per_image_checkbox.toggled.connect(self._on_settings_changed)
|
||
self.prompt_edit.textChanged.connect(self._on_prompt_changed)
|
||
self.ai_write_button.clicked.connect(self.start_ai_write)
|
||
self.ai_cancel_button.clicked.connect(self.cancel_ai_write)
|
||
self.prompt_settings_button.clicked.connect(self.open_prompt_settings)
|
||
self.add_category_button.clicked.connect(self.add_custom_category)
|
||
self.custom_category_edit.returnPressed.connect(self._commit_custom_category)
|
||
self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit)
|
||
self.generate_button.clicked.connect(self.toggle_generation)
|
||
self.history_button.toggled.connect(self._toggle_history)
|
||
self.open_folder_button.clicked.connect(self.open_project_folder)
|
||
self.undo_button.clicked.connect(self.undo_delete)
|
||
self.more_button.clicked.connect(self._show_more_menu)
|
||
|
||
def _status(self, message, level=None):
|
||
_emit_status(self.status_callback, message, level=level)
|
||
|
||
def _message(self, title, message, icon=QMessageBox.Warning):
|
||
box = QMessageBox(self)
|
||
box.setIcon(icon)
|
||
box.setWindowTitle(str(title))
|
||
box.setText(str(message))
|
||
box.exec()
|
||
|
||
def _confirm(self, title, message, *, destructive=False):
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question)
|
||
box.setWindowTitle(str(title))
|
||
box.setText(str(message))
|
||
confirm_button = box.addButton("确认", QMessageBox.AcceptRole)
|
||
box.addButton("取消", QMessageBox.RejectRole)
|
||
if not destructive:
|
||
box.setDefaultButton(confirm_button)
|
||
box.exec()
|
||
return box.clickedButton() is confirm_button
|
||
|
||
def refresh_accounts(self):
|
||
selected = self.account_combo.currentData()
|
||
try:
|
||
self.accounts = accounts.list_accounts(path=self.db_path, config=self.config)
|
||
except Exception as exc:
|
||
self.accounts = []
|
||
self._status("商品套图账号读取失败:%s" % _user_error(exc), "danger")
|
||
self._loading = True
|
||
try:
|
||
self.account_combo.clear()
|
||
for account in self.accounts:
|
||
text = "%s(%s)" % (account.account_name, account.alias)
|
||
self.account_combo.addItem(text, account.alias)
|
||
self.account_combo.setItemData(
|
||
self.account_combo.count() - 1,
|
||
text,
|
||
Qt.ToolTipRole,
|
||
)
|
||
if not self.accounts:
|
||
self.account_combo.addItem("暂无账号,请先到④账号管理添加", "")
|
||
index = self.account_combo.findData(selected)
|
||
if index >= 0:
|
||
self.account_combo.setCurrentIndex(index)
|
||
finally:
|
||
self._loading = False
|
||
self._update_account_tooltip()
|
||
|
||
def _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)
|
||
)
|
||
state = SuiteTaskState(
|
||
key=self._next_key,
|
||
serial=self._next_serial,
|
||
account_alias=(source.account_alias if source is not None else ""),
|
||
prompt=(source.prompt if source is not None else ""),
|
||
settings=product_suite.normalize_suite_settings(initial_settings),
|
||
)
|
||
if not state.account_alias and self.accounts:
|
||
state.account_alias = self.accounts[0].alias
|
||
self._next_key += 1
|
||
self._next_serial += 1
|
||
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()
|
||
if state.ai_worker is not None:
|
||
state.ai_worker.cancel()
|
||
if state.pull_worker is not None:
|
||
state.pull_worker.cancel()
|
||
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)
|
||
self._retired_states.append(state)
|
||
self._states.pop(state.key, None)
|
||
self.task_tabs.removeTab(index)
|
||
if self.task_tabs.count() == 0:
|
||
self.add_task(inherit=False)
|
||
|
||
def _draft_close_action(self):
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Question)
|
||
box.setWindowTitle("关闭临时草稿")
|
||
box.setText("当前临时草稿包含图片或生成记录。请选择关闭后的处理方式。")
|
||
keep_button = box.addButton("保留草稿", QMessageBox.AcceptRole)
|
||
delete_button = box.addButton("删除草稿", QMessageBox.DestructiveRole)
|
||
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||
box.setDefaultButton(keep_button)
|
||
box.exec()
|
||
clicked = box.clickedButton()
|
||
if clicked is keep_button:
|
||
return "keep"
|
||
if clicked is delete_button:
|
||
return "delete"
|
||
if clicked is cancel_button:
|
||
return "cancel"
|
||
return "cancel"
|
||
|
||
def _state_for_index(self, index):
|
||
if index < 0:
|
||
return None
|
||
return self._states.get(self.task_tabs.tabData(index))
|
||
|
||
def _on_task_changed(self, index):
|
||
if self._loading:
|
||
return
|
||
if self._displayed_state is not None:
|
||
self._save_controls_to_state(self._displayed_state)
|
||
self._flush_prompt_save(self._displayed_state)
|
||
state = self._state_for_index(index)
|
||
self._displayed_state = state
|
||
if state is not None:
|
||
self._load_state(state)
|
||
self._offer_pending_ai_result(state)
|
||
|
||
def _save_controls_to_state(self, state):
|
||
if self._loading or state is None:
|
||
return
|
||
state.account_alias = str(self.account_combo.currentData() or "")
|
||
state.item_id = self.item_id_edit.text().strip()
|
||
state.prompt = self.prompt_edit.toPlainText()
|
||
state.settings = self._settings_from_controls()
|
||
|
||
def _load_state(self, state):
|
||
self._sync_state_project_binding(state)
|
||
self._loading = True
|
||
try:
|
||
self.custom_category_edit.hide()
|
||
self.add_category_button.show()
|
||
index = self.account_combo.findData(state.account_alias)
|
||
if index >= 0:
|
||
self.account_combo.setCurrentIndex(index)
|
||
self.item_id_edit.setText(state.item_id)
|
||
self.prompt_edit.setPlainText(state.prompt)
|
||
self._set_combo_value(self.platform_combo, state.settings.get("platform"))
|
||
self._set_combo_value(self.country_combo, state.settings.get("country"))
|
||
self._set_combo_value(self.language_combo, state.settings.get("language"))
|
||
self._set_combo_value(self.ratio_combo, state.settings.get("ratio"))
|
||
self.per_image_checkbox.setChecked(bool(state.settings.get("per_image_primary")))
|
||
self.history_button.setChecked(bool(state.show_history))
|
||
finally:
|
||
self._loading = False
|
||
self._refresh_originals(state)
|
||
self._rebuild_categories(state)
|
||
self._refresh_results(state)
|
||
self._apply_running_state(state)
|
||
self._refresh_elapsed()
|
||
|
||
def _set_combo_value(self, combo, value):
|
||
index = combo.findData(value)
|
||
if index >= 0:
|
||
combo.setCurrentIndex(index)
|
||
|
||
def _settings_from_controls(self):
|
||
current = self._displayed_state.settings if self._displayed_state else {}
|
||
settings = dict(current or {})
|
||
settings.update(
|
||
{
|
||
"platform": self.platform_combo.currentData(),
|
||
"country": self.country_combo.currentData(),
|
||
"language": self.language_combo.currentData(),
|
||
"ratio": self.ratio_combo.currentData(),
|
||
"per_image_primary": self.per_image_checkbox.isChecked(),
|
||
}
|
||
)
|
||
return product_suite.normalize_suite_settings(settings)
|
||
|
||
def _on_account_changed(self, index):
|
||
self._update_account_tooltip()
|
||
if self._loading or self._displayed_state is None:
|
||
return
|
||
state = self._displayed_state
|
||
alias = str(self.account_combo.currentData() or "")
|
||
if state.project_id is not None and alias != state.account_alias:
|
||
if 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._update_context_actions(state)
|
||
|
||
def _update_account_tooltip(self):
|
||
self.account_combo.setToolTip(self.account_combo.currentText())
|
||
|
||
def _on_item_finished(self):
|
||
if self._loading or self._displayed_state is None:
|
||
return
|
||
state = self._displayed_state
|
||
item_id = self.item_id_edit.text().strip()
|
||
if item_id and not item_id.isdigit():
|
||
self.item_id_edit.setStyleSheet("border: 1px solid #9a6700;")
|
||
self._status("商品ID只能输入数字", "warning")
|
||
self._update_context_actions(state)
|
||
return
|
||
self.item_id_edit.setStyleSheet("")
|
||
if self._is_draft_state(state):
|
||
if not item_id:
|
||
state.item_id = ""
|
||
self._update_context_actions(state)
|
||
return
|
||
if not self._confirm(
|
||
"绑定正式商品",
|
||
"将当前临时草稿绑定到商品%s吗?\n已添加图片和生成记录会继续保留。" % item_id,
|
||
):
|
||
self._loading = True
|
||
try:
|
||
self.item_id_edit.setText(state.item_id)
|
||
finally:
|
||
self._loading = False
|
||
self._update_context_actions(state)
|
||
return
|
||
self._flush_prompt_save(state)
|
||
try:
|
||
project = image_studio.bind_draft_project(
|
||
state.project_id,
|
||
item_id,
|
||
path=self.db_path,
|
||
)
|
||
except image_studio.ImageStudioProjectConflictError as exc:
|
||
self._message("商品项目已存在", _user_error(exc))
|
||
self._loading = True
|
||
try:
|
||
self.item_id_edit.setText(state.item_id)
|
||
finally:
|
||
self._loading = False
|
||
self._update_context_actions(state)
|
||
return
|
||
except Exception as exc:
|
||
self._message("绑定正式商品失败", _user_error(exc))
|
||
self._loading = True
|
||
try:
|
||
self.item_id_edit.setText(state.item_id)
|
||
finally:
|
||
self._loading = False
|
||
self._update_context_actions(state)
|
||
return
|
||
state.item_id = project.item_id
|
||
state.project_binding_state = project.binding_state
|
||
self._set_task_title(state)
|
||
self._status("临时草稿已绑定商品%s" % project.item_id, "success")
|
||
self._update_context_actions(state)
|
||
return
|
||
if state.project_id is not None and item_id != state.item_id:
|
||
if not self._confirm(
|
||
"切换商品",
|
||
"切换商品ID后,原图和生成结果会按新商品重新载入。确认继续吗?",
|
||
):
|
||
self._loading = True
|
||
try:
|
||
self.item_id_edit.setText(state.item_id)
|
||
finally:
|
||
self._loading = False
|
||
return
|
||
self._clear_project_binding(state)
|
||
state.item_id = item_id
|
||
if not item_id:
|
||
self._update_context_actions(state)
|
||
return
|
||
if item_id and state.account_alias:
|
||
self._bind_project(state, load_existing=True)
|
||
self._update_context_actions(state)
|
||
|
||
def _clear_project_binding(self, state):
|
||
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.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 _state_project(self, state, *, include_deleted=False):
|
||
if state is None or state.project_id is None:
|
||
return None
|
||
try:
|
||
project = image_studio.get_project(
|
||
state.project_id,
|
||
path=self.db_path,
|
||
include_deleted=include_deleted,
|
||
)
|
||
except Exception as exc:
|
||
self._status("商品套图项目读取失败:%s" % _user_error(exc), "danger")
|
||
return None
|
||
if project is not None:
|
||
state.project_binding_state = project.binding_state
|
||
return project
|
||
|
||
def _sync_state_project_binding(self, state):
|
||
return self._state_project(state)
|
||
|
||
def _is_draft_state(self, state):
|
||
if state is None:
|
||
return False
|
||
if state.project_binding_state in image_studio.PROJECT_BINDING_STATES:
|
||
return state.project_binding_state == image_studio.PROJECT_BINDING_DRAFT
|
||
project = self._state_project(state)
|
||
return image_studio.is_draft_project(project)
|
||
|
||
def _has_account_context(self, state, *, show_message=True):
|
||
if state is None or not state.account_alias:
|
||
if show_message:
|
||
self._message("未选择账号", "请先在顶部选择账号。")
|
||
return False
|
||
if self._account_for_alias(state.account_alias) is None:
|
||
if show_message:
|
||
self._message("账号不可用", "所选账号不存在,请到④账号管理刷新账号。")
|
||
return False
|
||
return True
|
||
|
||
def _valid_context(self, state, *, show_message=True):
|
||
if not self._has_account_context(state, show_message=show_message):
|
||
return False
|
||
if not state.item_id or not state.item_id.isdigit():
|
||
if show_message:
|
||
self._message("商品ID无效", "请输入正确的数字商品ID。")
|
||
return False
|
||
return True
|
||
|
||
def _ensure_project_for_local_work(self, state):
|
||
if state is None:
|
||
return None
|
||
if state.project_id is not None:
|
||
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
|
||
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):
|
||
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)
|
||
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 or state.project_id is None:
|
||
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 or state.project_id is None:
|
||
return True
|
||
prompt = str(state.prompt or "")
|
||
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 _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 "未绑定商品"
|
||
else:
|
||
settings = product_suite.default_suite_settings()
|
||
base_prompt = ""
|
||
item_id = "未绑定商品"
|
||
dialog = ProductSuitePromptDialog(
|
||
prompt_path=self.product_suite_prompt_path,
|
||
base_prompt=base_prompt,
|
||
settings=settings,
|
||
item_id=item_id,
|
||
parent=self,
|
||
)
|
||
self.prompt_settings_dialog = dialog
|
||
dialog.exec()
|
||
|
||
def _update_context_actions(self, state):
|
||
is_draft = self._is_draft_state(state)
|
||
self.pull_button.setEnabled(
|
||
state is not None
|
||
and state.pull_worker is None
|
||
and (is_draft or self._valid_context(state, show_message=False))
|
||
)
|
||
self.pull_button.setToolTip(
|
||
"需要先绑定正式商品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)
|
||
thread.start()
|
||
return thread
|
||
|
||
def _refresh_originals(self, state, *, preserve_checks=True):
|
||
context = (state.key, state.project_id) if state is not None else None
|
||
checked_ids = (
|
||
set(self.original_list.checked_asset_ids())
|
||
if preserve_checks and context == self._original_list_context
|
||
else set()
|
||
)
|
||
previous = self.original_list.blockSignals(True)
|
||
self.original_list.clear()
|
||
assets = self._original_assets(state, include_missing=False)
|
||
try:
|
||
for index, asset in enumerate(assets, 1):
|
||
label = "主图" if index == 1 else "参考%d" % (index - 1)
|
||
item = QListWidgetItem(label)
|
||
item.setData(Qt.UserRole, int(asset.id))
|
||
item.setData(
|
||
ORIGINAL_CHECK_STATE_ROLE,
|
||
Qt.Checked if int(asset.id) in checked_ids else Qt.Unchecked,
|
||
)
|
||
if _asset_usable(asset):
|
||
item.setIcon(QIcon(_image_pixmap(asset.local_path, QSize(82, 64))))
|
||
item.setToolTip("%s,勾选可批量删除;双击预览;拖动可调整顺序" % label)
|
||
else:
|
||
item.setIcon(QIcon(_placeholder_pixmap("待下载", QSize(82, 64))))
|
||
item.setToolTip("%s尚未下载;勾选可批量删除,单击缩略图后台拉取" % label)
|
||
self.original_list.addItem(item)
|
||
finally:
|
||
self.original_list.blockSignals(previous)
|
||
self._original_list_context = context
|
||
self.original_list.update_content_height()
|
||
self.original_list.viewport().update()
|
||
self.original_count_label.setText("%d/16" % len(assets))
|
||
self._refresh_original_selection_controls()
|
||
self._refresh_add_images_action(state)
|
||
self._refresh_totals(state)
|
||
|
||
def _on_original_checks_changed(self, asset_ids):
|
||
self._refresh_original_selection_controls()
|
||
|
||
def _refresh_original_selection_controls(self):
|
||
checked_count = len(self.original_list.checked_asset_ids())
|
||
asset_count = len(self.original_list.asset_ids())
|
||
self.original_selected_label.setText("已选 %d 张" % checked_count)
|
||
state = self._displayed_state
|
||
enabled = bool(asset_count) and state is not None and not state.generation_running()
|
||
self.select_all_originals_button.setEnabled(enabled)
|
||
self.invert_originals_button.setEnabled(enabled)
|
||
|
||
def _original_assets(self, state, *, include_missing=False):
|
||
if state is None or state.project_id is None:
|
||
return []
|
||
try:
|
||
return image_studio.list_assets(
|
||
state.project_id,
|
||
kind=image_studio.ASSET_KIND_ORIGINAL,
|
||
include_missing=include_missing,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._status("商品原图读取失败:%s" % _user_error(exc), "danger")
|
||
return []
|
||
|
||
def choose_images(self, checked=False):
|
||
paths, _ = QFileDialog.getOpenFileNames(
|
||
self,
|
||
"选择商品原图",
|
||
"",
|
||
"图片文件 (*.png *.jpg *.jpeg *.webp *.bmp)",
|
||
)
|
||
if paths:
|
||
self.import_files(paths)
|
||
|
||
def import_files(self, paths):
|
||
self._start_import(file_paths=list(paths or []))
|
||
|
||
def import_clipboard_image(self, image_bytes):
|
||
self._start_import(image_bytes=image_bytes)
|
||
|
||
def _start_import(self, *, file_paths=None, image_bytes=None):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
self._save_controls_to_state(state)
|
||
state.import_created_draft = False
|
||
created_project = state.project_id is None
|
||
project = self._ensure_project_for_local_work(state)
|
||
if project is None:
|
||
return
|
||
state.import_created_draft = created_project and image_studio.is_draft_project(project)
|
||
if state.import_worker is not None:
|
||
self._status("当前任务正在添加图片,请稍候", "warning")
|
||
return
|
||
current_count = len(self._original_assets(state, include_missing=True))
|
||
if current_count >= image_studio_images.MAX_ORIGINAL_ASSETS:
|
||
self._message("图片数量已满", "每个商品最多添加16张原图。")
|
||
return
|
||
worker = ProductSuiteImportImagesWorker(
|
||
state.project_id,
|
||
file_paths=file_paths,
|
||
image_bytes=image_bytes,
|
||
db_path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
state.import_worker = worker
|
||
if state is self._displayed_state:
|
||
self._refresh_add_images_action(state)
|
||
worker.finished.connect(lambda result, state=state: self._on_import_finished(state, result))
|
||
worker.cancelled.connect(lambda result, state=state: self._on_import_finished(state, result))
|
||
worker.failed.connect(lambda row, error, state=state: self._on_import_failed(state, error))
|
||
state.import_thread = self._start_thread(worker, "商品套图添加原图")
|
||
self._status("正在后台添加商品原图", "info")
|
||
|
||
def _discard_empty_import_draft(self, state):
|
||
if not state.import_created_draft:
|
||
return
|
||
state.import_created_draft = False
|
||
project_id = state.project_id
|
||
if project_id is None:
|
||
return
|
||
try:
|
||
project = image_studio.discard_empty_draft_project(project_id, path=self.db_path)
|
||
except Exception as exc:
|
||
self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger")
|
||
return
|
||
if project is not None and project.deleted_at is not None:
|
||
state.project_id = None
|
||
state.project_binding_state = ""
|
||
state.item_id = ""
|
||
self._set_task_title(state)
|
||
self._status("未添加有效图片,已丢弃空临时草稿", "warning")
|
||
|
||
def _on_import_failed(self, state, error):
|
||
state.import_worker = None
|
||
state.import_thread = None
|
||
self._discard_empty_import_draft(state)
|
||
self._status("添加商品原图失败:%s" % _user_error(error), "danger")
|
||
if state is self._displayed_state:
|
||
self._refresh_add_images_action(state)
|
||
|
||
def _on_import_finished(self, state, result):
|
||
state.import_worker = None
|
||
state.import_thread = None
|
||
self._discard_empty_import_draft(state)
|
||
if result.get("ok") is False:
|
||
self._message("添加商品原图失败", _user_error(result.get("error")))
|
||
else:
|
||
errors = result.get("errors") or []
|
||
count = len(result.get("assets") or [])
|
||
if errors:
|
||
self._message(
|
||
"部分图片未添加",
|
||
"已添加%d张,%d张失败。\n%s" % (
|
||
count,
|
||
len(errors),
|
||
_user_error(errors[0].get("error")),
|
||
),
|
||
)
|
||
else:
|
||
self._status("已添加%d张商品原图" % count, "success")
|
||
if state is self._displayed_state:
|
||
self._refresh_originals(state)
|
||
|
||
def delete_original(self, asset_id):
|
||
self.delete_originals([asset_id])
|
||
|
||
def delete_originals(self, asset_ids):
|
||
state = self._displayed_state
|
||
if state is None or state.project_id is None:
|
||
self._status("当前商品没有可删除的原图", "warning")
|
||
return
|
||
if state.generation_running():
|
||
self._status("生成中不能删除当前任务的商品原图", "warning")
|
||
return
|
||
normalized_ids = []
|
||
seen = set()
|
||
for value in asset_ids or []:
|
||
asset_id = int(value)
|
||
if asset_id not in seen:
|
||
seen.add(asset_id)
|
||
normalized_ids.append(asset_id)
|
||
if not normalized_ids:
|
||
self._status("请先勾选要删除的商品原图", "warning")
|
||
return
|
||
downloading = set(state.download_queue) | set(state.downloads)
|
||
if downloading.intersection(normalized_ids):
|
||
self._message("暂不能删除商品原图", "选中的图片仍在后台下载,请等待下载结束后再删除。")
|
||
return
|
||
visible_ids = [int(asset.id) for asset in self._original_assets(state, include_missing=False)]
|
||
message = (
|
||
"确认从当前商品原图列表移除选中的%d张图片吗?\n\n"
|
||
"此操作不会删除蝦皮线上图片,也不会删除本地源文件。"
|
||
"以后重新拉取蝦皮主图时,线上仍存在的图片可能重新出现。"
|
||
% len(normalized_ids)
|
||
)
|
||
if visible_ids and visible_ids[0] in normalized_ids:
|
||
message += "\n\n选中内容包含当前主图,删除后下一张图片将成为主图。"
|
||
if not self._confirm("删除商品原图", message, destructive=True):
|
||
return
|
||
try:
|
||
image_studio.remove_original_assets_if_unused(
|
||
state.project_id,
|
||
normalized_ids,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._message("不能删除商品原图", _user_error(exc))
|
||
return
|
||
self._refresh_originals(state, preserve_checks=False)
|
||
self._status("已移除%d张商品原图" % len(normalized_ids), "success")
|
||
|
||
def reorder_originals(self, visible_ids):
|
||
state = self._displayed_state
|
||
if state is None or state.project_id is None:
|
||
return
|
||
all_assets = self._original_assets(state, include_missing=True)
|
||
hidden_ids = [int(asset.id) for asset in all_assets if int(asset.id) not in set(visible_ids)]
|
||
try:
|
||
image_studio.reorder_original_assets(
|
||
state.project_id,
|
||
list(visible_ids) + hidden_ids,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._message("原图排序失败", _user_error(exc))
|
||
self._refresh_originals(state)
|
||
|
||
def _asset_for_item(self, item):
|
||
state = self._displayed_state
|
||
if state is None or item is None:
|
||
return None
|
||
value = item.data(Qt.UserRole)
|
||
if value is None:
|
||
return None
|
||
asset_id = int(value)
|
||
return next(
|
||
(asset for asset in self._original_assets(state, include_missing=True) if int(asset.id) == asset_id),
|
||
None,
|
||
)
|
||
|
||
def _on_original_clicked(self, item):
|
||
asset = self._asset_for_item(item)
|
||
if asset is not None and not _asset_usable(asset) and asset.remote_url:
|
||
self._queue_original_downloads(self._displayed_state, [asset])
|
||
|
||
def _preview_original(self, item):
|
||
asset = self._asset_for_item(item)
|
||
if asset is None:
|
||
return
|
||
if not _asset_usable(asset):
|
||
self._queue_original_downloads(self._displayed_state, [asset])
|
||
self._status("原图正在后台下载,完成后可双击预览", "info")
|
||
return
|
||
ProductSuitePreviewDialog(asset.local_path, "商品原图预览", self).exec()
|
||
|
||
def pull_main_images(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
self._save_controls_to_state(state)
|
||
if self._is_draft_state(state):
|
||
self._message(
|
||
"无法拉取蝦皮主图",
|
||
"当前为临时项目,无法拉取蝦皮主图。\n请先输入正式商品ID后再试。",
|
||
)
|
||
return
|
||
if not self._valid_context(state):
|
||
return
|
||
if state.pull_worker is not None:
|
||
self._status("当前任务正在拉取蝦皮主图", "info")
|
||
return
|
||
existing = [asset for asset in self._original_assets(state, include_missing=False) if _asset_usable(asset)]
|
||
if existing and not self._confirm(
|
||
"覆盖拉取蝦皮主图",
|
||
"本地已有商品原图。继续拉取会刷新蝦皮原图列表,本地上传图片会保留。确认继续吗?",
|
||
):
|
||
return
|
||
worker = ImageStudioPullImagesWorker(
|
||
state.account_alias,
|
||
state.item_id,
|
||
db_path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
state.pull_worker = worker
|
||
worker.finished.connect(lambda result, state=state: self._on_pull_finished(state, result))
|
||
worker.cancelled.connect(lambda result, state=state: self._on_pull_finished(state, result))
|
||
worker.failed.connect(lambda row, error, state=state: self._on_pull_failed(state, error))
|
||
state.pull_thread = self._start_thread(worker, "商品套图拉取蝦皮主图")
|
||
if state is self._displayed_state:
|
||
self.pull_button.setText("正在拉取...")
|
||
self._update_context_actions(state)
|
||
self._status("开始拉取蝦皮主图,可继续操作其他套图任务", "info")
|
||
|
||
def _on_pull_failed(self, state, error):
|
||
self._status("拉取蝦皮主图失败:%s" % _user_error(error), "danger")
|
||
|
||
def _on_pull_finished(self, state, result):
|
||
state.pull_worker = None
|
||
state.pull_thread = None
|
||
if state is self._displayed_state:
|
||
self.pull_button.setText("拉取蝦皮主图")
|
||
if result.get("ok") is False:
|
||
self._message("拉取蝦皮主图失败", _user_error(result.get("error")))
|
||
else:
|
||
project = result.get("project")
|
||
if project is not None:
|
||
state.project_id = int(project.id)
|
||
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]
|
||
self._queue_original_downloads(state, assets)
|
||
self._status("已读取%d张蝦皮主图,正在后台下载" % len(assets), "success")
|
||
if state is self._displayed_state:
|
||
self._refresh_originals(state)
|
||
self._update_context_actions(state)
|
||
|
||
def _queue_original_downloads(self, state, assets):
|
||
if state is None:
|
||
return
|
||
queued = set(state.download_queue) | set(state.downloads)
|
||
for asset in assets or []:
|
||
if _asset_usable(asset) or not getattr(asset, "remote_url", None):
|
||
continue
|
||
asset_id = int(asset.id)
|
||
if asset_id not in queued:
|
||
state.download_queue.append(asset_id)
|
||
queued.add(asset_id)
|
||
self._start_queued_downloads(state)
|
||
|
||
def _start_queued_downloads(self, state):
|
||
while state.download_queue and len(state.downloads) < ORIGINAL_DOWNLOAD_CONCURRENCY:
|
||
asset_id = int(state.download_queue.pop(0))
|
||
worker = ImageStudioDownloadOriginalWorker(
|
||
asset_id,
|
||
db_path=self.db_path,
|
||
config=self.config,
|
||
max_retries=2,
|
||
)
|
||
worker.finished.connect(
|
||
lambda result, state=state, asset_id=asset_id: self._on_download_finished(
|
||
state, asset_id, result
|
||
)
|
||
)
|
||
worker.cancelled.connect(
|
||
lambda result, state=state, asset_id=asset_id: self._on_download_finished(
|
||
state, asset_id, result
|
||
)
|
||
)
|
||
worker.failed.connect(
|
||
lambda row, error, state=state, asset_id=asset_id: self._status(
|
||
"商品原图 #%d 下载失败:%s" % (asset_id, _user_error(error)),
|
||
"danger",
|
||
)
|
||
)
|
||
thread = self._start_thread(worker, "商品套图下载原图")
|
||
state.downloads[asset_id] = (worker, thread)
|
||
|
||
def _on_download_finished(self, state, asset_id, result):
|
||
state.downloads.pop(int(asset_id), None)
|
||
if result.get("ok") is False:
|
||
self._status(
|
||
"商品原图 #%d 下载失败:%s" % (asset_id, _user_error(result.get("error"))),
|
||
"danger",
|
||
)
|
||
elif not result.get("cancelled"):
|
||
self._status("商品原图 #%d 已下载" % asset_id, "success")
|
||
if state is self._displayed_state:
|
||
self._refresh_originals(state)
|
||
self._start_queued_downloads(state)
|
||
|
||
def _rebuild_categories(self, state):
|
||
while self.category_grid.count():
|
||
item = self.category_grid.takeAt(0)
|
||
widget = item.widget()
|
||
if widget is not None:
|
||
widget.deleteLater()
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
state.settings = settings
|
||
categories = settings["categories"]
|
||
ordered = product_suite.category_order(settings)
|
||
self.category_rows = {}
|
||
for index, name in enumerate(ordered):
|
||
custom = name not in product_suite.FIXED_CATEGORIES
|
||
row = SuiteCategoryRow(
|
||
name,
|
||
categories.get(name, 0),
|
||
helper=product_suite.category_helper(name),
|
||
custom=custom,
|
||
)
|
||
row.countChangeRequested.connect(self.change_category_count)
|
||
if custom:
|
||
row.renameRequested.connect(self.rename_custom_category)
|
||
row.deleteRequested.connect(self.delete_custom_category)
|
||
row.set_controls_enabled(not state.generation_running())
|
||
self.category_rows[name] = row
|
||
self.category_grid.addWidget(row, index, 0)
|
||
self._refresh_totals(state)
|
||
|
||
def change_category_count(self, name, delta):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
categories = dict(settings["categories"])
|
||
categories[name] = max(0, int(categories.get(name, 0)) + int(delta))
|
||
settings["categories"] = categories
|
||
state.settings = settings
|
||
self._persist_state(state)
|
||
row = self.category_rows.get(name) if state is self._displayed_state else None
|
||
if row is None:
|
||
self._rebuild_categories(state)
|
||
else:
|
||
row.set_count(categories[name])
|
||
self._refresh_totals(state)
|
||
|
||
def add_custom_category(self, checked=False):
|
||
if self._displayed_state is None:
|
||
return
|
||
self.add_category_button.hide()
|
||
self.custom_category_edit.clear()
|
||
self.custom_category_edit.show()
|
||
self.custom_category_edit.setFocus(Qt.OtherFocusReason)
|
||
|
||
def _commit_custom_category(self):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
name = self.custom_category_edit.text()
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
error = product_suite.suite_name_error(name, settings["categories"])
|
||
if error:
|
||
self._message("分类名称无效", error)
|
||
self.custom_category_edit.setFocus(Qt.OtherFocusReason)
|
||
return
|
||
categories = dict(settings["categories"])
|
||
categories[name] = 1
|
||
settings["categories"] = categories
|
||
settings["custom_category_order"] = list(settings["custom_category_order"]) + [name]
|
||
state.settings = product_suite.normalize_suite_settings(settings)
|
||
self._persist_state(state)
|
||
self.custom_category_edit.hide()
|
||
self.add_category_button.show()
|
||
self._rebuild_categories(state)
|
||
|
||
def _finish_custom_category_edit(self):
|
||
if not self.custom_category_edit.isHidden() and not self.custom_category_edit.text().strip():
|
||
self.custom_category_edit.hide()
|
||
self.add_category_button.show()
|
||
|
||
def rename_custom_category(self, old_name):
|
||
state = self._displayed_state
|
||
if state is None or old_name in product_suite.FIXED_CATEGORIES:
|
||
return
|
||
name, ok = QInputDialog.getText(
|
||
self,
|
||
"重命名自定义分类",
|
||
"分类名称",
|
||
text=old_name,
|
||
)
|
||
if not ok or name == old_name:
|
||
return
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
error = product_suite.suite_name_error(name, settings["categories"], old_name=old_name)
|
||
if error:
|
||
self._message("分类名称无效", error)
|
||
return
|
||
categories = dict(settings["categories"])
|
||
count = categories.pop(old_name, 1)
|
||
categories[name] = count
|
||
settings["categories"] = categories
|
||
settings["custom_category_order"] = [
|
||
name if value == old_name else value
|
||
for value in settings["custom_category_order"]
|
||
]
|
||
state.settings = product_suite.normalize_suite_settings(settings)
|
||
self._persist_state(state)
|
||
self._rebuild_categories(state)
|
||
|
||
def delete_custom_category(self, name):
|
||
state = self._displayed_state
|
||
if state is None or name in product_suite.FIXED_CATEGORIES:
|
||
return
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
categories = dict(settings["categories"])
|
||
categories.pop(name, None)
|
||
settings["categories"] = categories
|
||
settings["custom_category_order"] = [
|
||
value for value in settings["custom_category_order"] if value != name
|
||
]
|
||
state.settings = product_suite.normalize_suite_settings(settings)
|
||
self._persist_state(state)
|
||
self._rebuild_categories(state)
|
||
|
||
def _refresh_totals(self, state):
|
||
image_count = sum(1 for asset in self._original_assets(state) if _asset_usable(asset))
|
||
total = product_suite.suite_total_count(state.settings, image_count)
|
||
self.category_total_label.setText("合计 %d 张" % total)
|
||
if not state.generation_running():
|
||
self.generate_button.setText("生成套图(%d)" % total)
|
||
|
||
def start_ai_write(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
if state.ai_worker is not None:
|
||
self._status("当前套图任务正在AI帮写", "info")
|
||
return
|
||
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
|
||
if not local_assets:
|
||
self._message("缺少商品原图", "请先添加或拉取至少一张可用商品原图。")
|
||
return
|
||
self._save_controls_to_state(state)
|
||
context = (
|
||
"商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s"
|
||
% (
|
||
state.item_id or "未绑定商品",
|
||
state.settings["platform"],
|
||
state.settings["country"],
|
||
state.settings["language"],
|
||
state.prompt or "无",
|
||
)
|
||
)
|
||
instruction = (
|
||
"请为电商商品图片生成简洁、可执行的商品卖点与画面要求。"
|
||
"只输出可直接用于生图的中文提示词,包含核心卖点、目标人群、使用场景和禁用元素,"
|
||
"不要虚构商品参数。"
|
||
)
|
||
state.ai_prompt_snapshot = state.prompt
|
||
state.ai_started_at = time.monotonic()
|
||
worker = ProductSuiteAiWriteWorker(
|
||
instruction,
|
||
context,
|
||
config=self.config,
|
||
cmhub_config_path=self.cmhub_config_path,
|
||
)
|
||
state.ai_worker = worker
|
||
worker.finished.connect(lambda result, state=state: self._on_ai_write_finished(state, result))
|
||
worker.cancelled.connect(lambda result, state=state: self._on_ai_write_finished(state, result))
|
||
worker.failed.connect(lambda row, error, state=state: self._on_ai_write_failed(state, error))
|
||
state.ai_thread = self._start_thread(worker, "商品套图AI帮写")
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
self._status("AI帮写已开始,可切换到其他套图任务", "info")
|
||
|
||
def cancel_ai_write(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is not None and state.ai_worker is not None:
|
||
state.ai_worker.cancel()
|
||
self._status("已请求取消AI帮写", "warning")
|
||
|
||
def _on_ai_write_failed(self, state, error):
|
||
self._status("AI帮写失败:%s" % _user_error(error), "danger")
|
||
|
||
def _on_ai_write_finished(self, state, result):
|
||
state.ai_worker = None
|
||
state.ai_thread = None
|
||
state.ai_started_at = None
|
||
if result.get("ok") is False:
|
||
self._message("AI帮写失败", _user_error(result.get("error")))
|
||
elif not result.get("cancelled"):
|
||
text = str(result.get("text") or "").strip()
|
||
if text:
|
||
if state.prompt != state.ai_prompt_snapshot:
|
||
state.pending_ai_text = text
|
||
if state is self._displayed_state:
|
||
self._offer_pending_ai_result(state)
|
||
else:
|
||
self._status("AI帮写已完成,切回对应套图任务后确认是否采用", "success")
|
||
else:
|
||
state.prompt = text
|
||
self._persist_state(state)
|
||
if state is self._displayed_state:
|
||
self._loading = True
|
||
try:
|
||
self.prompt_edit.setPlainText(text)
|
||
finally:
|
||
self._loading = False
|
||
self._status("AI帮写已完成", "success")
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
|
||
def _offer_pending_ai_result(self, state):
|
||
if not state.pending_ai_text:
|
||
return
|
||
if self._confirm(
|
||
"采用AI帮写结果",
|
||
"AI帮写期间卖点内容已被修改。是否用AI帮写结果覆盖当前内容?",
|
||
):
|
||
state.prompt = state.pending_ai_text
|
||
self._loading = True
|
||
try:
|
||
self.prompt_edit.setPlainText(state.prompt)
|
||
finally:
|
||
self._loading = False
|
||
self._persist_state(state)
|
||
state.pending_ai_text = ""
|
||
|
||
def toggle_generation(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
if state.generation_running():
|
||
retrying = state.generation_mode == "retry"
|
||
if state.generation_stop_requested:
|
||
self._status(
|
||
"正在停止当前图片重试"
|
||
if retrying
|
||
else "正在停止当前套图任务",
|
||
"warning",
|
||
)
|
||
return
|
||
if self._confirm(
|
||
"停止图片重试" if retrying else "停止生成套图",
|
||
(
|
||
"确认停止当前图片重试吗?已提交任务会在安全边界停止。"
|
||
if retrying
|
||
else "确认取消当前任务吗?已提交任务会在安全边界停止。"
|
||
),
|
||
destructive=True,
|
||
):
|
||
state.generation_stop_requested = True
|
||
state.worker.cancel()
|
||
self._log_generation_lifecycle(
|
||
state,
|
||
state.generation_run_token,
|
||
"stop_requested",
|
||
)
|
||
self._apply_running_state(state)
|
||
self._status(
|
||
"已请求停止当前图片重试"
|
||
if retrying
|
||
else "已请求停止当前套图任务",
|
||
"warning",
|
||
)
|
||
return
|
||
self.start_generation(state)
|
||
|
||
def start_generation(self, state, specs=None, *, retry_job_id=None):
|
||
if state.generation_running():
|
||
self._status("当前套图任务仍在生成", "warning")
|
||
return False
|
||
retry_job_id = int(retry_job_id) if retry_job_id is not None else None
|
||
retrying = retry_job_id is not None
|
||
if state is self._displayed_state:
|
||
self._save_controls_to_state(state)
|
||
template_text = None
|
||
if specs is None:
|
||
try:
|
||
template_text = prompts.load_product_suite_prompt(
|
||
self.product_suite_prompt_path
|
||
)
|
||
except prompts.PromptError:
|
||
self._message(
|
||
"套图提示词模板无效",
|
||
"套图提示词模板无效,请在提示词设置中修复或恢复默认。",
|
||
)
|
||
return False
|
||
if self._ensure_project_for_local_work(state) is None:
|
||
return False
|
||
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
|
||
if not local_assets:
|
||
self._message("缺少商品原图", "请先添加图片,或等待蝦皮主图下载完成。")
|
||
return False
|
||
if specs is None and not state.prompt.strip():
|
||
self._message("商品卖点不能为空", "请填写商品卖点与要求,或使用AI帮写。")
|
||
return False
|
||
specs = list(specs or product_suite.build_job_specs(
|
||
local_assets,
|
||
state.prompt,
|
||
state.settings,
|
||
state.item_id or "未绑定商品",
|
||
template_text=template_text,
|
||
))
|
||
if not specs:
|
||
self._message("生成数量为0", "请至少把一个套图分类的数量设为1。")
|
||
return False
|
||
if len(specs) > product_suite.MAX_GENERATION_COUNT_WITHOUT_CONFIRM:
|
||
if not self._confirm(
|
||
"确认生成数量",
|
||
"本轮将生成%d张图片,预计耗时和点数较多。确认继续吗?" % len(specs),
|
||
):
|
||
return False
|
||
self._persist_state(state)
|
||
run_token = uuid.uuid4().hex
|
||
worker = ProductSuiteGenerateWorker(
|
||
state.project_id,
|
||
specs,
|
||
run_token=run_token,
|
||
aspect_ratio=state.settings["ratio"],
|
||
db_path=self.db_path,
|
||
config=self.config,
|
||
cmhub_config_path=self.cmhub_config_path,
|
||
)
|
||
state.worker = worker
|
||
state.generation_run_token = run_token
|
||
state.generation_stop_requested = False
|
||
state.generation_terminal_streak = 0
|
||
state.generation_job_ids = []
|
||
state.generation_mode = "retry" if retrying else "batch"
|
||
state.generation_retry_job_id = retry_job_id
|
||
state.done = 0
|
||
state.failed = 0
|
||
state.total = len(specs)
|
||
state.started_at = time.monotonic()
|
||
if not retrying:
|
||
state.current_job_ids = []
|
||
state.show_history = False
|
||
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 and not retrying:
|
||
self._loading = True
|
||
try:
|
||
self.history_button.setChecked(False)
|
||
finally:
|
||
self._loading = False
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
self._refresh_results(state)
|
||
self._status(
|
||
"图片重试已开始;可切换到其他任务"
|
||
if retrying
|
||
else "商品套图生成已开始,共%d张;可切换到其他任务" % len(specs),
|
||
"info",
|
||
)
|
||
return True
|
||
|
||
def _generation_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
|
||
elif not state.show_history:
|
||
for job_id in normalized:
|
||
if job_id not in current:
|
||
current.append(job_id)
|
||
state.current_job_ids = current
|
||
|
||
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
|
||
)
|
||
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.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",
|
||
},
|
||
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()
|
||
self.pull_button.setText("正在拉取..." if state.pull_worker is not None else "拉取蝦皮主图")
|
||
self.account_combo.setEnabled(not generation_running and state.pull_worker is None)
|
||
self.item_id_edit.setEnabled(not generation_running and state.pull_worker is None)
|
||
self._refresh_add_images_action(state)
|
||
self.original_list.setEnabled(not generation_running)
|
||
self._refresh_original_selection_controls()
|
||
for widget in (
|
||
self.platform_combo,
|
||
self.country_combo,
|
||
self.language_combo,
|
||
self.ratio_combo,
|
||
self.per_image_checkbox,
|
||
self.prompt_edit,
|
||
self.add_category_button,
|
||
self.custom_category_edit,
|
||
):
|
||
widget.setEnabled(not generation_running)
|
||
for row in self.category_rows.values():
|
||
row.set_controls_enabled(not generation_running)
|
||
self.generate_button.setEnabled(True)
|
||
if generation_running:
|
||
self.generate_button.setText(
|
||
"正在停止..."
|
||
if state.generation_stop_requested
|
||
else "停止重试"
|
||
if state.generation_mode == "retry"
|
||
else "停止生成"
|
||
)
|
||
self.generate_button.setStyleSheet(
|
||
"QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }"
|
||
"QPushButton:hover { background: #a40e26; }"
|
||
)
|
||
else:
|
||
self.generate_button.setStyleSheet(
|
||
"QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }"
|
||
"QPushButton:hover { background: #245fce; }"
|
||
)
|
||
self._refresh_totals(state)
|
||
ai_running = state.ai_worker is not None
|
||
self.ai_write_button.setEnabled(not ai_running and not generation_running)
|
||
self.ai_cancel_button.setVisible(ai_running)
|
||
self._update_context_actions(state)
|
||
|
||
def _refresh_elapsed(self):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
elapsed = int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0
|
||
self.elapsed_label.setText(
|
||
"%s %d/%d(%d秒) · 失败 %d" % (
|
||
"重试" if state.generation_mode == "retry" else "套图",
|
||
state.done,
|
||
state.total,
|
||
elapsed,
|
||
state.failed,
|
||
)
|
||
)
|
||
self.progress_bar.setRange(0, max(1, state.total))
|
||
self.progress_bar.setValue(min(state.done, max(1, state.total)))
|
||
if state.ai_worker is not None and state.ai_started_at is not None:
|
||
ai_elapsed = int(max(0, time.monotonic() - state.ai_started_at))
|
||
self.ai_write_button.setText("AI 帮写中(%d秒)" % ai_elapsed)
|
||
else:
|
||
self.ai_write_button.setText("AI 帮写")
|
||
|
||
def _toggle_history(self, checked):
|
||
if self._loading or self._displayed_state is None:
|
||
return
|
||
self._displayed_state.show_history = bool(checked)
|
||
self.history_button.setText("返回本轮" if checked else "历史生成")
|
||
self._refresh_results(self._displayed_state)
|
||
|
||
def _jobs_for_state(self, state):
|
||
if state.project_id is None:
|
||
return []
|
||
try:
|
||
jobs = image_studio.list_jobs(state.project_id, path=self.db_path)
|
||
except Exception as exc:
|
||
self._status("生成结果读取失败:%s" % _user_error(exc), "danger")
|
||
return []
|
||
if state.show_history:
|
||
return jobs
|
||
by_id = {int(job.id): job for job in jobs}
|
||
return [
|
||
by_id[int(job_id)]
|
||
for job_id in state.current_job_ids
|
||
if int(job_id) in by_id
|
||
]
|
||
|
||
def _refresh_results(self, state):
|
||
while self.result_grid.count():
|
||
item = self.result_grid.takeAt(0)
|
||
widget = item.widget()
|
||
if widget is not None:
|
||
widget.deleteLater()
|
||
jobs = self._jobs_for_state(state)
|
||
assets = {}
|
||
if state.project_id is not None:
|
||
try:
|
||
assets = {
|
||
int(asset.id): asset
|
||
for asset in image_studio.list_assets(state.project_id, path=self.db_path)
|
||
}
|
||
except Exception:
|
||
assets = {}
|
||
success = 0
|
||
if not jobs:
|
||
empty = QLabel("还没有生成结果,点击左侧“生成套图”开始")
|
||
empty.setAlignment(Qt.AlignCenter)
|
||
empty.setStyleSheet("color: #6b7280; padding: 48px;")
|
||
self.result_grid.addWidget(empty, 0, 0, 1, 3)
|
||
else:
|
||
columns = max(2, min(4, max(1, self.result_scroll.viewport().width() // 200)))
|
||
for index, job in enumerate(jobs):
|
||
asset = assets.get(int(job.output_asset_id or 0))
|
||
if job.status == "succeeded" and asset is not None and _asset_usable(asset):
|
||
success += 1
|
||
card = SuiteResultCard(job, asset)
|
||
card.previewRequested.connect(self.preview_job)
|
||
card.retryRequested.connect(self.retry_job)
|
||
card.menuRequested.connect(self._show_job_menu)
|
||
card.deleteRequested.connect(self.delete_job_asset)
|
||
self.result_grid.addWidget(card, index // columns, index % columns)
|
||
self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success))
|
||
self.undo_button.setVisible(bool(state.undo_records))
|
||
self.history_button.setText("返回本轮" if state.show_history else "历史生成")
|
||
|
||
def preview_job(self, job):
|
||
asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None
|
||
if asset is None or not _asset_usable(asset):
|
||
self._status("这张生成图片尚不可预览", "warning")
|
||
return
|
||
ProductSuitePreviewDialog(asset.local_path, "%s预览" % job.job_type, self).exec()
|
||
|
||
def retry_job(self, job):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
if state.generation_running():
|
||
self._message("当前任务正在生成", "请等待当前生成结束或停止后再重试单张图片。")
|
||
return
|
||
if str(getattr(job, "status", "") or "") not in {
|
||
"failed",
|
||
"expired",
|
||
"cancelled",
|
||
}:
|
||
self._status("当前图片无需重试", "warning")
|
||
return
|
||
spec = {
|
||
"source_asset_id": job.source_asset_id,
|
||
"job_type": job.job_type,
|
||
"prompt": job.prompt,
|
||
}
|
||
self.start_generation(state, specs=[spec], retry_job_id=job.id)
|
||
|
||
def _show_job_menu(self, job, global_position):
|
||
menu = QMenu(self)
|
||
preview_action = menu.addAction("预览")
|
||
copy_action = menu.addAction("复制路径")
|
||
folder_action = menu.addAction("打开文件夹")
|
||
retry_action = menu.addAction("重新生成")
|
||
delete_action = menu.addAction("删除")
|
||
action = menu.exec(global_position)
|
||
asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None
|
||
if action is preview_action:
|
||
self.preview_job(job)
|
||
elif action is copy_action:
|
||
if asset is None or not _asset_usable(asset):
|
||
self._status("当前图片没有可复制的本地路径", "warning")
|
||
else:
|
||
QApplication.clipboard().setText(asset.local_path)
|
||
self._status("生成图片路径已复制", "success")
|
||
elif action is folder_action:
|
||
if asset is not None and _asset_usable(asset):
|
||
try:
|
||
file_manager.open_in_file_manager(os.path.dirname(asset.local_path))
|
||
except Exception as exc:
|
||
self._message("打开文件夹失败", _user_error(exc))
|
||
else:
|
||
self.open_project_folder()
|
||
elif action is retry_action:
|
||
self.retry_job(job)
|
||
elif action is delete_action:
|
||
self.delete_job_asset(job)
|
||
|
||
def delete_job_asset(self, job):
|
||
state = self._displayed_state
|
||
if state is None or not job.output_asset_id:
|
||
return
|
||
if not self._confirm("删除生成图片", "确认把这张图片移到当前商品的废纸篓吗?"):
|
||
return
|
||
try:
|
||
record = image_studio_images.trash_generated_asset(
|
||
job.output_asset_id,
|
||
path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
except Exception as exc:
|
||
self._message("删除生成图片失败", _user_error(exc))
|
||
return
|
||
state.undo_records.append(record)
|
||
self._refresh_results(state)
|
||
self._status("生成图片已删除,可点击“撤销删除”恢复", "success")
|
||
|
||
def undo_delete(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None or not state.undo_records:
|
||
return
|
||
record = state.undo_records.pop()
|
||
try:
|
||
image_studio_images.restore_trashed_asset(
|
||
record,
|
||
path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
except Exception as exc:
|
||
state.undo_records.append(record)
|
||
self._message("撤销删除失败", _user_error(exc))
|
||
return
|
||
self._refresh_results(state)
|
||
self._status("生成图片已恢复", "success")
|
||
|
||
def _show_more_menu(self):
|
||
menu = QMenu(self)
|
||
delete_all_action = menu.addAction("删除当前视图全部图片")
|
||
if menu.exec(self.more_button.mapToGlobal(self.more_button.rect().bottomLeft())) is delete_all_action:
|
||
self.delete_all_visible_results()
|
||
|
||
def delete_all_visible_results(self):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
jobs = [job for job in self._jobs_for_state(state) if job.output_asset_id]
|
||
if not jobs:
|
||
self._status("当前视图没有可删除的生成图片", "warning")
|
||
return
|
||
if not self._confirm(
|
||
"删除全部生成图片",
|
||
"确认把当前视图中的%d张生成图片移到废纸篓吗?" % len(jobs),
|
||
destructive=True,
|
||
):
|
||
return
|
||
failures = 0
|
||
for job in jobs:
|
||
try:
|
||
state.undo_records.append(
|
||
image_studio_images.trash_generated_asset(
|
||
job.output_asset_id,
|
||
path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
)
|
||
except Exception:
|
||
failures += 1
|
||
self._refresh_results(state)
|
||
if failures:
|
||
self._message("部分图片未删除", "有%d张图片无法删除,请查看当前结果。" % failures)
|
||
else:
|
||
self._status("当前视图的生成图片已移到废纸篓", "success")
|
||
|
||
def open_project_folder(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None or state.project_id is None:
|
||
self._message("未创建套图任务", "请先选择账号并添加本地图片,或输入商品ID后拉取蝦皮主图。")
|
||
return
|
||
project = image_studio.get_project(state.project_id, path=self.db_path)
|
||
if project is None:
|
||
self._message("商品任务不存在", "当前商品套图任务已经不存在。")
|
||
return
|
||
target = image_studio.default_project_image_dirs(project, config=self.config)["generated"]
|
||
os.makedirs(target, exist_ok=True)
|
||
try:
|
||
file_manager.open_in_file_manager(target)
|
||
except Exception as exc:
|
||
self._message("打开文件夹失败", _user_error(exc))
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
if not self._result_refresh_pending:
|
||
self._result_refresh_pending = True
|
||
QTimer.singleShot(80, self._refresh_after_resize)
|
||
|
||
def _refresh_after_resize(self):
|
||
self._result_refresh_pending = False
|
||
if self._displayed_state is not None:
|
||
self._refresh_results(self._displayed_state)
|
||
|
||
def closeEvent(self, event):
|
||
if self._displayed_state is not None:
|
||
self._save_controls_to_state(self._displayed_state)
|
||
for state in list(self._states.values()):
|
||
self._flush_prompt_save(state)
|
||
for state in list(self._states.values()) + list(self._retired_states):
|
||
for worker in (state.worker, state.pull_worker, state.import_worker, state.ai_worker):
|
||
if worker is not None and hasattr(worker, "cancel"):
|
||
worker.cancel()
|
||
for worker, thread in list(state.downloads.values()):
|
||
worker.cancel()
|
||
for state in list(self._states.values()) + list(self._retired_states):
|
||
self._release_prompt_save_timer(state)
|
||
self._generation_run_states.clear()
|
||
super().closeEvent(event)
|