6240 lines
248 KiB
Python
6240 lines
248 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,
|
||
QPointF,
|
||
QRect,
|
||
QRegularExpression,
|
||
QSize,
|
||
Qt,
|
||
QTimer,
|
||
Signal,
|
||
Slot,
|
||
)
|
||
from PySide6.QtGui import (
|
||
QColor,
|
||
QFont,
|
||
QIcon,
|
||
QImage,
|
||
QImageReader,
|
||
QKeySequence,
|
||
QPainter,
|
||
QPixmap,
|
||
QRegularExpressionValidator,
|
||
QWheelEvent,
|
||
)
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QCheckBox,
|
||
QComboBox,
|
||
QDialog,
|
||
QFileDialog,
|
||
QFrame,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QInputDialog,
|
||
QLabel,
|
||
QLineEdit,
|
||
QListView,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QMenu,
|
||
QMessageBox,
|
||
QPlainTextEdit,
|
||
QProgressBar,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QSplitter,
|
||
QStyle,
|
||
QStyledItemDelegate,
|
||
QStyleOptionButton,
|
||
QTabBar,
|
||
QToolButton,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from ... import (
|
||
accounts,
|
||
ai,
|
||
appconfig,
|
||
cmhub_models,
|
||
diagnostics,
|
||
image_studio,
|
||
image_studio_generation,
|
||
image_studio_images,
|
||
product_suite,
|
||
prompts,
|
||
)
|
||
from .. import file_manager
|
||
from ..image_preview import ImagePreviewDialog
|
||
from ..product_suite_prompt_dialog import ProductSuitePromptDialog
|
||
from ..widgets import _emit_status, run_worker
|
||
from ..workers import (
|
||
CMHubModelCatalogWorker,
|
||
ImageStudioDownloadOriginalWorker,
|
||
ImageStudioPullImagesWorker,
|
||
ImageStudioResumeJobsWorker,
|
||
ProductSuiteAiWriteWorker,
|
||
ProductSuiteGenerateWorker,
|
||
ProductSuiteHistoryExportWorker,
|
||
ProductSuiteImportImagesWorker,
|
||
)
|
||
|
||
|
||
ORIGINAL_DOWNLOAD_CONCURRENCY = 2
|
||
ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1
|
||
PROMPT_AUTOSAVE_DELAY_MS = 500
|
||
_PRODUCT_SUITE_THREAD_REFS = {}
|
||
_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE)
|
||
|
||
|
||
class AutoHeightPlainTextEdit(QPlainTextEdit):
|
||
"""A plain-text editor that delegates scrolling to its containing page."""
|
||
|
||
def __init__(self, parent=None, minimum_height=96):
|
||
super().__init__(parent)
|
||
self._minimum_content_height = max(1, int(minimum_height))
|
||
self._height_update_pending = False
|
||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||
self.setFixedHeight(self._minimum_content_height)
|
||
self.document().contentsChanged.connect(self.schedule_height_update)
|
||
self.document().documentLayout().documentSizeChanged.connect(
|
||
self.schedule_height_update
|
||
)
|
||
self.schedule_height_update()
|
||
|
||
def schedule_height_update(self, *args):
|
||
if self._height_update_pending:
|
||
return
|
||
self._height_update_pending = True
|
||
QTimer.singleShot(0, self.refresh_height)
|
||
|
||
def refresh_height(self):
|
||
self._height_update_pending = False
|
||
text_height = 0.0
|
||
block = self.document().firstBlock()
|
||
while block.isValid():
|
||
layout = block.layout()
|
||
line_count = max(1, layout.lineCount())
|
||
for index in range(line_count):
|
||
line = layout.lineAt(index)
|
||
text_height += (
|
||
line.height()
|
||
if line.isValid()
|
||
else self.fontMetrics().lineSpacing()
|
||
)
|
||
block = block.next()
|
||
margins = self.contentsMargins()
|
||
target = int(
|
||
text_height
|
||
+ (self.document().documentMargin() * 2)
|
||
+ margins.top()
|
||
+ margins.bottom()
|
||
+ (self.frameWidth() * 2)
|
||
+ 6
|
||
)
|
||
target = max(self._minimum_content_height, target)
|
||
if self.height() != target:
|
||
self.setFixedHeight(target)
|
||
self.updateGeometry()
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
self.schedule_height_update()
|
||
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
self.schedule_height_update()
|
||
|
||
|
||
class SuiteSettingsComboBox(QComboBox):
|
||
"""Keep wheel scrolling from silently changing a suite generation setting."""
|
||
|
||
def wheelEvent(self, event):
|
||
target = self.view().viewport() if self.view().isVisible() else self._outer_scroll_viewport()
|
||
if target is None:
|
||
event.ignore()
|
||
return
|
||
global_position = event.globalPosition()
|
||
local_position = target.mapFromGlobal(global_position.toPoint())
|
||
forwarded = QWheelEvent(
|
||
QPointF(local_position),
|
||
global_position,
|
||
event.pixelDelta(),
|
||
event.angleDelta(),
|
||
event.buttons(),
|
||
event.modifiers(),
|
||
event.phase(),
|
||
event.inverted(),
|
||
)
|
||
QApplication.sendEvent(target, forwarded)
|
||
event.accept()
|
||
|
||
def _outer_scroll_viewport(self):
|
||
parent = self.parentWidget()
|
||
while parent is not None:
|
||
if isinstance(parent, QScrollArea):
|
||
return parent.viewport()
|
||
parent = parent.parentWidget()
|
||
return None
|
||
|
||
|
||
def _asset_usable(asset):
|
||
path = str(getattr(asset, "local_path", "") or "")
|
||
return (
|
||
getattr(asset, "status", "") != image_studio.ASSET_STATUS_MISSING
|
||
and bool(path)
|
||
and os.path.isfile(path)
|
||
)
|
||
|
||
|
||
def _placeholder_pixmap(text, size, color="#f3f4f6"):
|
||
pixmap = QPixmap(size)
|
||
pixmap.fill(QColor(color))
|
||
painter = QPainter(pixmap)
|
||
painter.setPen(QColor("#6b7280"))
|
||
painter.drawText(pixmap.rect(), Qt.AlignCenter | Qt.TextWordWrap, str(text or ""))
|
||
painter.end()
|
||
return pixmap
|
||
|
||
|
||
def _image_pixmap(path, size, fallback="暂无图片"):
|
||
image = QImage()
|
||
if path and os.path.isfile(path):
|
||
reader = QImageReader(str(path))
|
||
reader.setAutoTransform(True)
|
||
source_size = reader.size()
|
||
if source_size.isValid():
|
||
source_size.scale(size, Qt.KeepAspectRatio)
|
||
reader.setScaledSize(source_size)
|
||
image = reader.read()
|
||
if image.isNull():
|
||
return _placeholder_pixmap(fallback, size)
|
||
scaled = QPixmap.fromImage(image).scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||
canvas = QPixmap(size)
|
||
canvas.fill(QColor("#f7f8fa"))
|
||
painter = QPainter(canvas)
|
||
painter.drawPixmap(
|
||
max(0, (size.width() - scaled.width()) // 2),
|
||
max(0, (size.height() - scaled.height()) // 2),
|
||
scaled,
|
||
)
|
||
painter.end()
|
||
return canvas
|
||
|
||
|
||
def _user_error(error):
|
||
text = diagnostics.redact_log_text(str(error or "未知错误"))
|
||
text = _URL_RE.sub("[链接已隐藏]", text).replace("\r", " ").replace("\n", " ").strip()
|
||
return text if len(text) <= 90 else text[:87] + "..."
|
||
|
||
|
||
class ProductSuitePreviewDialog(ImagePreviewDialog):
|
||
"""Current product-suite wrapper for the shared full-image preview."""
|
||
|
||
|
||
class ProductOriginalDelegate(QStyledItemDelegate):
|
||
CHECK_HIT_SIZE = 26
|
||
|
||
@classmethod
|
||
def checkbox_hit_rect(cls, item_rect):
|
||
return QRect(
|
||
item_rect.left() + 2,
|
||
item_rect.top() + 2,
|
||
cls.CHECK_HIT_SIZE,
|
||
cls.CHECK_HIT_SIZE,
|
||
)
|
||
|
||
def paint(self, painter, option, index):
|
||
super().paint(painter, option, index)
|
||
if index.data(Qt.UserRole) is None:
|
||
return
|
||
checked = index.data(ORIGINAL_CHECK_STATE_ROLE) == Qt.Checked
|
||
style = QApplication.style()
|
||
width = style.pixelMetric(QStyle.PM_IndicatorWidth)
|
||
height = style.pixelMetric(QStyle.PM_IndicatorHeight)
|
||
hit_rect = self.checkbox_hit_rect(option.rect)
|
||
checkbox = QStyleOptionButton()
|
||
checkbox.rect = QRect(
|
||
hit_rect.center().x() - width // 2,
|
||
hit_rect.center().y() - height // 2,
|
||
width,
|
||
height,
|
||
)
|
||
checkbox.state = QStyle.State_Enabled if option.state & QStyle.State_Enabled else QStyle.State_None
|
||
checkbox.state |= QStyle.State_On if checked else QStyle.State_Off
|
||
style.drawControl(QStyle.CE_CheckBox, checkbox, painter)
|
||
|
||
|
||
class ProductOriginalList(QListWidget):
|
||
MAX_VISIBLE_ASSETS = 16
|
||
EMPTY_STATE_TEXT = "暂无商品原图"
|
||
|
||
filesDropped = Signal(list)
|
||
clipboardImage = Signal(bytes)
|
||
orderChanged = Signal(list)
|
||
deleteRequested = Signal(int)
|
||
deleteAssetsRequested = Signal(list)
|
||
checkedAssetsChanged = Signal(list)
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setObjectName("suiteOriginalList")
|
||
self.setViewMode(QListView.IconMode)
|
||
self.setResizeMode(QListView.Adjust)
|
||
self.setMovement(QListView.Snap)
|
||
self.setWrapping(True)
|
||
self.setIconSize(QSize(82, 64))
|
||
self.setGridSize(QSize(112, 98))
|
||
self.setSpacing(4)
|
||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||
self.setAcceptDrops(True)
|
||
self._reordering_enabled = True
|
||
self.setDragEnabled(True)
|
||
self.setDropIndicatorShown(True)
|
||
self.setDragDropMode(QListWidget.InternalMove)
|
||
self.setDefaultDropAction(Qt.MoveAction)
|
||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.customContextMenuRequested.connect(self._show_menu)
|
||
self.itemChanged.connect(lambda item: self.checkedAssetsChanged.emit(self.checked_asset_ids()))
|
||
self.setItemDelegate(ProductOriginalDelegate(self))
|
||
self.setMouseTracking(True)
|
||
self.itemEntered.connect(self._show_hover_remove)
|
||
self._hovered_asset_id = None
|
||
self._hover_remove_button = QToolButton(self.viewport())
|
||
self._hover_remove_button.setText("×")
|
||
self._hover_remove_button.setToolTip("删除这张商品原图")
|
||
self._hover_remove_button.setAccessibleName("删除商品原图")
|
||
self._hover_remove_button.setFixedSize(28, 28)
|
||
self._hover_remove_button.clicked.connect(self._remove_hovered)
|
||
self._hover_remove_button.hide()
|
||
self._update_reordering_tooltip()
|
||
|
||
def reordering_enabled(self):
|
||
return self._reordering_enabled
|
||
|
||
def set_reordering_enabled(self, enabled):
|
||
self._reordering_enabled = bool(enabled)
|
||
self.setDragEnabled(self._reordering_enabled)
|
||
self.setDropIndicatorShown(self._reordering_enabled)
|
||
self._update_reordering_tooltip()
|
||
item_tooltip = (
|
||
"拖动到第一位即可设为主图"
|
||
if self._reordering_enabled
|
||
else "正在拉取蝦皮主图,完成后可调整图片顺序"
|
||
)
|
||
for row in range(self.count()):
|
||
item = self.item(row)
|
||
if item.data(Qt.UserRole) is not None:
|
||
item.setToolTip(item_tooltip)
|
||
|
||
def _update_reordering_tooltip(self):
|
||
if self._reordering_enabled:
|
||
self.setToolTip("可拖入或粘贴图片;拖动图片到第一位即可设为主图")
|
||
else:
|
||
self.setToolTip("正在拉取蝦皮主图,完成后可调整图片顺序")
|
||
|
||
def paintEvent(self, event):
|
||
super().paintEvent(event)
|
||
if self.count() != 0:
|
||
return
|
||
painter = QPainter(self.viewport())
|
||
painter.setPen(QColor("#6b7280"))
|
||
painter.drawText(
|
||
self.viewport().rect().adjusted(12, 8, -12, -8),
|
||
Qt.AlignCenter,
|
||
self.EMPTY_STATE_TEXT,
|
||
)
|
||
painter.end()
|
||
|
||
def dragEnterEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
return
|
||
super().dragEnterEvent(event)
|
||
|
||
def dragMoveEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
return
|
||
if not self._reordering_enabled:
|
||
event.ignore()
|
||
return
|
||
super().dragMoveEvent(event)
|
||
|
||
def dropEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()]
|
||
self.filesDropped.emit(paths)
|
||
event.acceptProposedAction()
|
||
return
|
||
if not self._reordering_enabled:
|
||
event.ignore()
|
||
return
|
||
internal_move = event.source() is self
|
||
if internal_move:
|
||
event.setDropAction(Qt.MoveAction)
|
||
super().dropEvent(event)
|
||
if internal_move and event.isAccepted() and event.dropAction() == Qt.MoveAction:
|
||
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)
|
||
source_label = QLabel(_generation_source_label(job))
|
||
source_label.setObjectName("suiteResultCardSource")
|
||
source_label.setStyleSheet("color: #57606a; font-size: 11px;")
|
||
source_label.setToolTip("生成来源:%s" % _generation_source_label(job))
|
||
title_row.addWidget(source_label)
|
||
if asset is not None and _asset_usable(asset):
|
||
delete_button = QToolButton()
|
||
delete_button.setText("×")
|
||
delete_button.setToolTip("删除这张生成图片")
|
||
delete_button.setAccessibleName("删除生成图片")
|
||
delete_button.setMinimumSize(28, 28)
|
||
delete_button.clicked.connect(lambda: self.deleteRequested.emit(self.job))
|
||
title_row.addWidget(delete_button)
|
||
layout.addLayout(title_row)
|
||
|
||
image_label = QLabel()
|
||
image_label.setAlignment(Qt.AlignCenter)
|
||
image_label.setFixedSize(170, 150)
|
||
status = str(getattr(job, "status", "pending") or "pending")
|
||
if asset is not None and _asset_usable(asset):
|
||
image_label.setPixmap(_image_pixmap(asset.local_path, QSize(170, 150)))
|
||
else:
|
||
label = {
|
||
"pending": "等待提交",
|
||
"submitted": "排队中",
|
||
"running": "生成中",
|
||
"failed": "生成失败",
|
||
"expired": "任务过期",
|
||
"cancelled": "已停止",
|
||
"succeeded": "图片已删除",
|
||
}.get(status, "处理中")
|
||
color = "#ffebe9" if status in {"failed", "expired"} else "#f3f4f6"
|
||
image_label.setPixmap(_placeholder_pixmap(label, QSize(170, 150), color))
|
||
image_label.mousePressEvent = lambda event: (
|
||
self.previewRequested.emit(self.job)
|
||
if event.button() == Qt.LeftButton
|
||
else None
|
||
)
|
||
layout.addWidget(image_label)
|
||
|
||
footer = QHBoxLayout()
|
||
status_text = {
|
||
"pending": "等待提交",
|
||
"submitted": "已提交",
|
||
"running": "生成中",
|
||
"succeeded": "生成成功" if asset is not None and _asset_usable(asset) else "已删除",
|
||
"failed": "失败:%s" % _user_error(getattr(job, "error", "")),
|
||
"expired": "已过期",
|
||
"cancelled": "已停止",
|
||
}.get(status, status)
|
||
status_label = QLabel(status_text)
|
||
status_label.setWordWrap(False)
|
||
status_label.setToolTip(status_text)
|
||
footer.addWidget(status_label, 1)
|
||
self.retry_button = None
|
||
if status in {"failed", "expired", "cancelled"}:
|
||
self.retry_button = QPushButton("重试")
|
||
self.retry_button.setMinimumWidth(52)
|
||
self.retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job))
|
||
footer.addWidget(self.retry_button)
|
||
layout.addLayout(footer)
|
||
|
||
def set_retry_enabled(self, enabled, tooltip=""):
|
||
if self.retry_button is None:
|
||
return
|
||
self.retry_button.setEnabled(bool(enabled))
|
||
self.retry_button.setToolTip(str(tooltip or ""))
|
||
|
||
|
||
class SuiteHistoryImageCard(QFrame):
|
||
"""Read-only image card used by the product-suite history dialog."""
|
||
|
||
selected = Signal(object)
|
||
previewRequested = Signal(object, object)
|
||
menuRequested = Signal(object, object, object)
|
||
|
||
def __init__(self, job, asset=None, retry_count=0, parent=None):
|
||
super().__init__(parent)
|
||
self.job = job
|
||
self.asset = asset
|
||
self.retry_count = max(0, int(retry_count or 0))
|
||
self._selected = False
|
||
self.setObjectName("suiteHistoryImageCard")
|
||
self.setFixedSize(166, 190)
|
||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.customContextMenuRequested.connect(
|
||
lambda point: self.menuRequested.emit(
|
||
self.job,
|
||
self.asset,
|
||
self.mapToGlobal(point),
|
||
)
|
||
)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(6, 6, 6, 6)
|
||
layout.setSpacing(4)
|
||
|
||
title = QLabel(str(getattr(job, "job_type", "套图") or "套图"))
|
||
title.setStyleSheet("font-weight: 600; color: #24292f;")
|
||
title.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||
layout.addWidget(title)
|
||
|
||
image = QLabel()
|
||
image.setObjectName("suiteHistoryImage")
|
||
image.setAlignment(Qt.AlignCenter)
|
||
image.setFixedSize(154, 118)
|
||
image.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||
status = str(getattr(job, "status", "pending") or "pending")
|
||
if asset is not None and _asset_usable(asset):
|
||
image.setPixmap(_image_pixmap(asset.local_path, QSize(154, 118)))
|
||
else:
|
||
image.setPixmap(
|
||
_placeholder_pixmap(
|
||
"图片文件不可用" if status == "succeeded" else self._status_text(status),
|
||
QSize(154, 118),
|
||
"#fff8c5" if status == "succeeded" else "#f3f4f6",
|
||
)
|
||
)
|
||
layout.addWidget(image)
|
||
|
||
footer_text = "%s · %s" % (
|
||
_generation_source_label(job),
|
||
self._status_text(status),
|
||
)
|
||
if self.retry_count:
|
||
footer_text += " · 重试%d次" % self.retry_count
|
||
footer = QLabel(footer_text)
|
||
footer.setObjectName("suiteHistoryImageStatus")
|
||
footer.setWordWrap(True)
|
||
footer.setMaximumHeight(34)
|
||
footer.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||
layout.addWidget(footer)
|
||
|
||
tooltip = [
|
||
"类型:%s" % str(getattr(job, "job_type", "套图") or "套图"),
|
||
"生成来源:%s" % _generation_source_label(job),
|
||
"状态:%s" % self._status_text(status),
|
||
"生成时间:%s" % _history_time_text(getattr(job, "created_at", "")),
|
||
]
|
||
if self.retry_count:
|
||
tooltip.append("本槽位已重试%d次" % self.retry_count)
|
||
if asset is None or not _asset_usable(asset):
|
||
tooltip.append("本地图片文件不可用")
|
||
self.setToolTip("\n".join(tooltip))
|
||
self._apply_selected_style()
|
||
|
||
@staticmethod
|
||
def _status_text(status):
|
||
return {
|
||
"pending": "等待提交",
|
||
"submitted": "已提交",
|
||
"running": "生成中",
|
||
"succeeded": "生成成功",
|
||
"failed": "生成失败",
|
||
"expired": "任务过期",
|
||
"cancelled": "已停止",
|
||
}.get(str(status or ""), "处理中")
|
||
|
||
def set_selected(self, selected):
|
||
self._selected = bool(selected)
|
||
self._apply_selected_style()
|
||
|
||
def _apply_selected_style(self):
|
||
border = "#0969da" if self._selected else "#d8dee4"
|
||
background = "#eef6ff" if self._selected else "#ffffff"
|
||
self.setStyleSheet(
|
||
"QFrame#suiteHistoryImageCard {"
|
||
"border: %dpx solid %s; border-radius: 6px; background: %s;"
|
||
"}" % (2 if self._selected else 1, border, background)
|
||
)
|
||
|
||
def mousePressEvent(self, event):
|
||
if event.button() == Qt.LeftButton:
|
||
self.selected.emit(self)
|
||
super().mousePressEvent(event)
|
||
|
||
def mouseDoubleClickEvent(self, event):
|
||
if event.button() == Qt.LeftButton:
|
||
self.previewRequested.emit(self.job, self.asset)
|
||
super().mouseDoubleClickEvent(event)
|
||
|
||
|
||
def _history_time_text(value):
|
||
text = str(value or "").strip().replace("T", " ")
|
||
if not text:
|
||
return "时间未知"
|
||
return text[:19]
|
||
|
||
|
||
def _generation_source_label(job):
|
||
"""Return the persisted job source as a user-facing Chinese label."""
|
||
|
||
source = str(getattr(job, "generation_source", "") or "").strip().lower()
|
||
provider = str(getattr(job, "provider", "") or "").strip().lower()
|
||
if (
|
||
source == image_studio.GENERATION_SOURCE_CMHUB
|
||
and provider == image_studio.PROVIDER_CMHUB
|
||
):
|
||
return "默认网关"
|
||
if (
|
||
source == image_studio.GENERATION_SOURCE_DIRECT
|
||
and provider == image_studio.PROVIDER_OPENAI_IMAGES_EDITS
|
||
):
|
||
return "自定义网关"
|
||
return "来源未知"
|
||
|
||
|
||
def _generation_source_summary(jobs):
|
||
labels = []
|
||
for job in jobs:
|
||
label = _generation_source_label(job)
|
||
if label not in labels:
|
||
labels.append(label)
|
||
return "、".join(labels) if labels else "来源未知"
|
||
|
||
|
||
class ProductSuiteHistoryDialog(QDialog):
|
||
"""Read-only, project-scoped generation history grouped by persisted rounds."""
|
||
|
||
PAGE_SIZE = 20
|
||
|
||
def __init__(self, project_id, *, db_path=None, parent=None):
|
||
super().__init__(parent)
|
||
self.project_id = int(project_id)
|
||
self.db_path = db_path
|
||
self._offset = 0
|
||
self._has_more = False
|
||
self._assets = {}
|
||
self._selected_card = None
|
||
self._round_count = 0
|
||
self._available_image_count = 0
|
||
self._project_unavailable = False
|
||
|
||
self.setObjectName("suiteHistoryDialog")
|
||
self.setWindowTitle("历史生成记录")
|
||
self.setModal(False)
|
||
self.setAttribute(Qt.WA_DeleteOnClose, True)
|
||
self.setMinimumSize(740, 520)
|
||
self.resize(940, 680)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(14, 14, 14, 14)
|
||
layout.setSpacing(10)
|
||
|
||
header = QHBoxLayout()
|
||
header.setSpacing(10)
|
||
header_text = QVBoxLayout()
|
||
header_text.setSpacing(2)
|
||
self.context_label = QLabel()
|
||
self.context_label.setObjectName("suiteHistoryContextLabel")
|
||
self.context_label.setStyleSheet("font-weight: 600; color: #24292f;")
|
||
self.summary_label = QLabel()
|
||
self.summary_label.setObjectName("suiteHistorySummaryLabel")
|
||
self.summary_label.setStyleSheet("color: #57606a;")
|
||
header_text.addWidget(self.context_label)
|
||
header_text.addWidget(self.summary_label)
|
||
header.addLayout(header_text, 1)
|
||
self.refresh_button = QPushButton("刷新")
|
||
self.refresh_button.setObjectName("suiteHistoryRefreshButton")
|
||
self.refresh_button.setToolTip("重新读取当前商品的生成历史")
|
||
self.refresh_button.clicked.connect(self.refresh_history)
|
||
header.addWidget(self.refresh_button)
|
||
layout.addLayout(header)
|
||
|
||
self.notice_label = QLabel()
|
||
self.notice_label.setObjectName("suiteHistoryNoticeLabel")
|
||
self.notice_label.setWordWrap(True)
|
||
self.notice_label.hide()
|
||
layout.addWidget(self.notice_label)
|
||
|
||
self.scroll = QScrollArea()
|
||
self.scroll.setObjectName("suiteHistoryScrollArea")
|
||
self.scroll.setWidgetResizable(True)
|
||
self.history_content = QWidget()
|
||
self.history_content.setObjectName("suiteHistoryContent")
|
||
self.history_layout = QVBoxLayout(self.history_content)
|
||
self.history_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.history_layout.setSpacing(12)
|
||
self.history_layout.setAlignment(Qt.AlignTop)
|
||
self.scroll.setWidget(self.history_content)
|
||
layout.addWidget(self.scroll, 1)
|
||
|
||
self.load_more_button = QPushButton("加载更多")
|
||
self.load_more_button.setObjectName("suiteHistoryLoadMoreButton")
|
||
self.load_more_button.clicked.connect(self.load_more)
|
||
self.load_more_button.hide()
|
||
layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter)
|
||
|
||
self.refresh_history()
|
||
|
||
def refresh_history(self, checked=False):
|
||
scroll_value = self.scroll.verticalScrollBar().value()
|
||
self._clear_history_content()
|
||
self._offset = 0
|
||
self._has_more = False
|
||
self._assets = {}
|
||
self._round_count = 0
|
||
self._available_image_count = 0
|
||
if not self._load_next_page():
|
||
return
|
||
QTimer.singleShot(
|
||
0,
|
||
lambda: self.scroll.verticalScrollBar().setValue(
|
||
min(scroll_value, self.scroll.verticalScrollBar().maximum())
|
||
),
|
||
)
|
||
|
||
def load_more(self, checked=False):
|
||
if self._has_more:
|
||
self._load_next_page()
|
||
|
||
def _load_next_page(self):
|
||
try:
|
||
project = image_studio.get_project(self.project_id, path=self.db_path)
|
||
if project is None:
|
||
self._set_project_unavailable()
|
||
return False
|
||
self._project_unavailable = False
|
||
self._set_context(project)
|
||
if not self._assets:
|
||
self._assets = {
|
||
int(asset.id): asset
|
||
for asset in image_studio.list_assets(
|
||
self.project_id,
|
||
path=self.db_path,
|
||
)
|
||
}
|
||
rounds = image_studio.list_generation_rounds(
|
||
self.project_id,
|
||
limit=self.PAGE_SIZE,
|
||
offset=self._offset,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._set_error("历史记录读取失败:%s" % _user_error(exc))
|
||
return False
|
||
|
||
self.notice_label.hide()
|
||
if not rounds and self._offset == 0:
|
||
self._show_empty_state()
|
||
else:
|
||
for round_info in rounds:
|
||
self._add_round(round_info)
|
||
self._offset += len(rounds)
|
||
self._has_more = len(rounds) == self.PAGE_SIZE
|
||
self.load_more_button.setVisible(self._has_more)
|
||
self.load_more_button.setEnabled(self._has_more)
|
||
self._update_summary()
|
||
return True
|
||
|
||
def _set_context(self, project):
|
||
account_name = str(project.account_name or project.account_alias or "未命名账号")
|
||
item_text = "临时草稿" if image_studio.is_draft_project(project) else "商品ID:%s" % project.item_id
|
||
self.context_label.setText("店铺:%s · %s" % (account_name, item_text))
|
||
|
||
def _update_summary(self):
|
||
refreshed = time.strftime("%H:%M:%S")
|
||
self.summary_label.setText(
|
||
"已加载 %d 轮 · 可用图片 %d 张 · 最近刷新 %s"
|
||
% (self._round_count, self._available_image_count, refreshed)
|
||
)
|
||
|
||
def _clear_history_content(self):
|
||
self._selected_card = None
|
||
while self.history_layout.count():
|
||
item = self.history_layout.takeAt(0)
|
||
widget = item.widget()
|
||
if widget is not None:
|
||
widget.deleteLater()
|
||
|
||
def _set_project_unavailable(self):
|
||
self._project_unavailable = True
|
||
self._clear_history_content()
|
||
self.context_label.setText("当前商品项目不可用")
|
||
self.summary_label.setText("该商品项目已删除或不可访问")
|
||
self._set_notice("当前商品项目已删除或不可访问,无法继续读取历史生成记录。", "#cf222e")
|
||
self.refresh_button.setEnabled(False)
|
||
self.load_more_button.hide()
|
||
|
||
def _set_error(self, message):
|
||
self._clear_history_content()
|
||
self._set_notice(message, "#cf222e")
|
||
self.load_more_button.hide()
|
||
self.refresh_button.setEnabled(True)
|
||
|
||
def _set_notice(self, text, color="#57606a"):
|
||
self.notice_label.setText(str(text))
|
||
self.notice_label.setStyleSheet("color: %s; padding: 8px 0;" % color)
|
||
self.notice_label.show()
|
||
|
||
def _show_empty_state(self):
|
||
empty = QLabel("暂无历史生成记录,完成套图生成后会自动出现在这里")
|
||
empty.setObjectName("suiteHistoryEmptyLabel")
|
||
empty.setAlignment(Qt.AlignCenter)
|
||
empty.setStyleSheet("color: #6b7280; padding: 56px;")
|
||
self.history_layout.addWidget(empty)
|
||
|
||
def _add_round(self, round_info):
|
||
try:
|
||
jobs = image_studio.list_generation_round_current_jobs(
|
||
self.project_id,
|
||
round_info.generation_round_key,
|
||
path=self.db_path,
|
||
)
|
||
attempts = image_studio.list_generation_round_attempts(
|
||
self.project_id,
|
||
round_info.generation_round_key,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._set_error("历史轮次读取失败:%s" % _user_error(exc))
|
||
return
|
||
|
||
section = QWidget()
|
||
section.setObjectName("suiteHistoryRound")
|
||
section.setStyleSheet(
|
||
"QWidget#suiteHistoryRound { border-bottom: 1px solid #d8dee4; }"
|
||
)
|
||
layout = QVBoxLayout(section)
|
||
layout.setContentsMargins(2, 2, 2, 12)
|
||
layout.setSpacing(7)
|
||
|
||
header = QHBoxLayout()
|
||
header.setSpacing(8)
|
||
time_label = QLabel(
|
||
"旧版历史记录" if round_info.is_legacy else "生成于 %s" % _history_time_text(round_info.created_at)
|
||
)
|
||
time_label.setStyleSheet("font-weight: 600; color: #24292f;")
|
||
header.addWidget(time_label)
|
||
if round_info.is_current:
|
||
current = QLabel("当前")
|
||
current.setObjectName("suiteHistoryCurrentBadge")
|
||
current.setStyleSheet(
|
||
"color: #0969da; background: #ddf4ff; border: 1px solid #54aeff; "
|
||
"border-radius: 6px; padding: 1px 6px; font-weight: 600;"
|
||
)
|
||
header.addWidget(current)
|
||
elif round_info.is_legacy:
|
||
legacy = QLabel("旧版")
|
||
legacy.setStyleSheet(
|
||
"color: #57606a; background: #f6f8fa; border: 1px solid #d8dee4; "
|
||
"border-radius: 6px; padding: 1px 6px;"
|
||
)
|
||
header.addWidget(legacy)
|
||
source_label = QLabel("来源:%s" % _generation_source_summary(jobs))
|
||
source_label.setObjectName("suiteHistoryRoundSource")
|
||
source_label.setStyleSheet("color: #57606a;")
|
||
header.addWidget(source_label)
|
||
header.addStretch(1)
|
||
stats = self._round_stats_text(round_info)
|
||
stats_label = QLabel(stats)
|
||
stats_label.setObjectName("suiteHistoryRoundStats")
|
||
stats_label.setStyleSheet("color: #57606a;")
|
||
header.addWidget(stats_label)
|
||
layout.addLayout(header)
|
||
|
||
attempts_by_slot = {}
|
||
for attempt in attempts:
|
||
slot = getattr(attempt, "generation_slot_index", None)
|
||
if slot is not None:
|
||
attempts_by_slot[int(slot)] = attempts_by_slot.get(int(slot), 0) + 1
|
||
|
||
if not jobs:
|
||
message = QLabel("本轮没有可展示的图片,已保留生成状态记录。")
|
||
message.setStyleSheet("color: #6b7280; padding: 12px 0;")
|
||
layout.addWidget(message)
|
||
else:
|
||
grid = QGridLayout()
|
||
grid.setContentsMargins(0, 0, 0, 0)
|
||
grid.setHorizontalSpacing(8)
|
||
grid.setVerticalSpacing(8)
|
||
columns = 5
|
||
for index, job in enumerate(jobs):
|
||
asset = self._assets.get(int(job.output_asset_id or 0))
|
||
slot = getattr(job, "generation_slot_index", None)
|
||
retry_count = max(0, attempts_by_slot.get(int(slot), 1) - 1) if slot is not None else 0
|
||
card = SuiteHistoryImageCard(job, asset, retry_count)
|
||
card.selected.connect(self._select_card)
|
||
card.previewRequested.connect(self._preview_job)
|
||
card.menuRequested.connect(self._show_job_menu)
|
||
grid.addWidget(card, index // columns, index % columns)
|
||
if asset is not None and _asset_usable(asset):
|
||
self._available_image_count += 1
|
||
layout.addLayout(grid)
|
||
|
||
self.history_layout.addWidget(section)
|
||
self._round_count += 1
|
||
|
||
@staticmethod
|
||
def _round_stats_text(round_info):
|
||
parts = ["记录 %d" % int(round_info.job_count or 0)]
|
||
if round_info.slot_count:
|
||
parts.append("图片 %d" % int(round_info.slot_count))
|
||
parts.append("成功 %d" % int(round_info.succeeded_count or 0))
|
||
if round_info.failed_count:
|
||
parts.append("失败 %d" % int(round_info.failed_count))
|
||
if round_info.cancelled_count:
|
||
parts.append("停止 %d" % int(round_info.cancelled_count))
|
||
if round_info.active_count:
|
||
parts.append("进行中 %d" % int(round_info.active_count))
|
||
if round_info.retry_count:
|
||
parts.append("重试 %d" % int(round_info.retry_count))
|
||
return " · ".join(parts)
|
||
|
||
def _select_card(self, card):
|
||
if self._selected_card is card:
|
||
return
|
||
if self._selected_card is not None:
|
||
self._selected_card.set_selected(False)
|
||
self._selected_card = card
|
||
self._selected_card.set_selected(True)
|
||
|
||
def _preview_job(self, job, asset):
|
||
if asset is None or not _asset_usable(asset):
|
||
self._set_notice("这张图片的本地文件不可用,无法预览。", "#9a6700")
|
||
return
|
||
ProductSuitePreviewDialog(
|
||
asset.local_path,
|
||
"%s预览" % str(getattr(job, "job_type", "套图") or "套图"),
|
||
self,
|
||
).exec()
|
||
|
||
def _show_job_menu(self, job, asset, global_position):
|
||
menu = QMenu(self)
|
||
preview_action = menu.addAction("预览")
|
||
copy_action = menu.addAction("复制路径")
|
||
folder_action = menu.addAction("打开所在文件夹")
|
||
action = menu.exec(global_position)
|
||
if action is preview_action:
|
||
self._preview_job(job, asset)
|
||
elif action is copy_action:
|
||
if asset is None or not _asset_usable(asset):
|
||
self._set_notice("当前图片没有可复制的本地路径。", "#9a6700")
|
||
return
|
||
QApplication.clipboard().setText(asset.local_path)
|
||
self._set_notice("图片路径已复制。", "#1a7f37")
|
||
elif action is folder_action:
|
||
if asset is None or not _asset_usable(asset):
|
||
self._set_notice("当前图片没有可打开的本地文件夹。", "#9a6700")
|
||
return
|
||
try:
|
||
file_manager.open_in_file_manager(os.path.dirname(asset.local_path))
|
||
except Exception as exc:
|
||
self._set_notice("打开文件夹失败:%s" % _user_error(exc), "#cf222e")
|
||
|
||
def closeEvent(self, event):
|
||
self._clear_history_content()
|
||
super().closeEvent(event)
|
||
|
||
|
||
class ProductSuiteRoundPreviewDialog(ImagePreviewDialog):
|
||
"""Browse the usable output images that belong to one generation round."""
|
||
|
||
def __init__(self, entries, start_index=0, title="套图原图预览", parent=None):
|
||
self._entries = [
|
||
(str(path or ""), str(label or "生成图片"))
|
||
for path, label in list(entries or [])
|
||
if str(path or "")
|
||
]
|
||
if not self._entries:
|
||
self._entries = [("", "生成图片")]
|
||
self._entry_index = max(0, min(int(start_index or 0), len(self._entries) - 1))
|
||
self._title_prefix = str(title or "套图原图预览")
|
||
path, label = self._entries[self._entry_index]
|
||
super().__init__(path, self._entry_title(label), parent)
|
||
self.setObjectName("suiteHistoryRoundPreviewDialog")
|
||
|
||
navigation = QHBoxLayout()
|
||
navigation.setSpacing(6)
|
||
self.previous_button = QToolButton()
|
||
self.previous_button.setObjectName("suiteHistoryPreviewPreviousButton")
|
||
self.previous_button.setText("上一张")
|
||
self.previous_button.setToolTip("查看上一张生成图片")
|
||
self.previous_button.clicked.connect(self.show_previous)
|
||
navigation.addWidget(self.previous_button)
|
||
self.index_label = QLabel()
|
||
self.index_label.setObjectName("suiteHistoryPreviewIndexLabel")
|
||
self.index_label.setAlignment(Qt.AlignCenter)
|
||
self.index_label.setMinimumWidth(86)
|
||
navigation.addWidget(self.index_label)
|
||
self.next_button = QToolButton()
|
||
self.next_button.setObjectName("suiteHistoryPreviewNextButton")
|
||
self.next_button.setText("下一张")
|
||
self.next_button.setToolTip("查看下一张生成图片")
|
||
self.next_button.clicked.connect(self.show_next)
|
||
navigation.addWidget(self.next_button)
|
||
navigation.addStretch(1)
|
||
self.layout().insertLayout(1, navigation)
|
||
self._update_navigation()
|
||
|
||
def _entry_title(self, label):
|
||
return "%s · %s" % (self._title_prefix, str(label or "生成图片"))
|
||
|
||
def _update_navigation(self):
|
||
total = len(self._entries)
|
||
self.index_label.setText("%d / %d" % (self._entry_index + 1, total))
|
||
self.previous_button.setEnabled(total > 1 and self._entry_index > 0)
|
||
self.next_button.setEnabled(total > 1 and self._entry_index < total - 1)
|
||
|
||
def show_previous(self, checked=False):
|
||
if self._entry_index > 0:
|
||
self._show_entry(self._entry_index - 1)
|
||
|
||
def show_next(self, checked=False):
|
||
if self._entry_index < len(self._entries) - 1:
|
||
self._show_entry(self._entry_index + 1)
|
||
|
||
def _show_entry(self, index):
|
||
self._entry_index = max(0, min(int(index), len(self._entries) - 1))
|
||
path, label = self._entries[self._entry_index]
|
||
self._base_title = self._entry_title(label)
|
||
self._source = self._load_source(path)
|
||
self.fit_to_window = True
|
||
self._display_size = QSize()
|
||
self.setWindowTitle(self._window_title())
|
||
self._update_navigation()
|
||
self._render()
|
||
|
||
|
||
class SuiteGlobalHistoryThumbnail(QLabel):
|
||
"""Fixed-size thumbnail that opens its source image on double-click."""
|
||
|
||
previewRequested = Signal(int)
|
||
|
||
def __init__(self, job, asset, entry_index, parent=None):
|
||
super().__init__(parent)
|
||
self.entry_index = int(entry_index)
|
||
self._available = asset is not None and _asset_usable(asset)
|
||
self.setObjectName("suiteGlobalHistoryThumbnail")
|
||
self.setAlignment(Qt.AlignCenter)
|
||
self.setFixedSize(104, 78)
|
||
self.setToolTip(
|
||
"%s · %s · %s"
|
||
% (
|
||
str(getattr(job, "job_type", "套图") or "套图"),
|
||
_generation_source_label(job),
|
||
SuiteHistoryImageCard._status_text(getattr(job, "status", "")),
|
||
)
|
||
)
|
||
if self._available:
|
||
self.setPixmap(_image_pixmap(asset.local_path, QSize(104, 78)))
|
||
self.setCursor(Qt.PointingHandCursor)
|
||
else:
|
||
self.setPixmap(
|
||
_placeholder_pixmap(
|
||
"图片不可用",
|
||
QSize(104, 78),
|
||
"#fff8c5",
|
||
)
|
||
)
|
||
|
||
def mouseDoubleClickEvent(self, event):
|
||
if event.button() == Qt.LeftButton and self._available:
|
||
self.previewRequested.emit(self.entry_index)
|
||
super().mouseDoubleClickEvent(event)
|
||
|
||
|
||
class SuiteGlobalHistoryRoundRow(QFrame):
|
||
"""One fixed-height, cross-project history row for a generation round."""
|
||
|
||
previewRequested = Signal(object, int)
|
||
exportRequested = Signal(object)
|
||
|
||
MAX_THUMBNAILS = 5
|
||
|
||
def __init__(self, round_info, jobs, assets_by_job, parent=None):
|
||
super().__init__(parent)
|
||
self.round_info = round_info
|
||
self.jobs = list(jobs or [])
|
||
self.assets_by_job = dict(assets_by_job or {})
|
||
self._usable_entries = [
|
||
(job, self.assets_by_job.get(int(getattr(job, "id", 0) or 0)))
|
||
for job in self.jobs
|
||
if self.assets_by_job.get(int(getattr(job, "id", 0) or 0)) is not None
|
||
and _asset_usable(self.assets_by_job.get(int(getattr(job, "id", 0) or 0)))
|
||
]
|
||
self.setObjectName("suiteGlobalHistoryRoundRow")
|
||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||
self.setFixedHeight(118)
|
||
self.setStyleSheet(
|
||
"QFrame#suiteGlobalHistoryRoundRow {"
|
||
"border: 1px solid #d8dee4; border-radius: 6px; background: #ffffff;"
|
||
"}"
|
||
)
|
||
|
||
layout = QHBoxLayout(self)
|
||
layout.setContentsMargins(10, 8, 10, 8)
|
||
layout.setSpacing(10)
|
||
|
||
meta = QVBoxLayout()
|
||
meta.setSpacing(3)
|
||
meta.setContentsMargins(0, 0, 0, 0)
|
||
time_label = QLabel(
|
||
"旧版历史记录"
|
||
if bool(getattr(round_info, "is_legacy", False))
|
||
else "生成于 %s" % _history_time_text(getattr(round_info, "created_at", ""))
|
||
)
|
||
time_label.setObjectName("suiteGlobalHistoryTime")
|
||
time_label.setStyleSheet("font-weight: 600; color: #24292f;")
|
||
meta.addWidget(time_label)
|
||
account = str(
|
||
getattr(round_info, "account_name", "")
|
||
or getattr(round_info, "account_alias", "")
|
||
or "未命名店铺"
|
||
)
|
||
item_text = (
|
||
"临时草稿"
|
||
if str(getattr(round_info, "binding_state", ""))
|
||
== image_studio.PROJECT_BINDING_DRAFT
|
||
else "商品ID:%s" % str(getattr(round_info, "item_id", "") or "未填写")
|
||
)
|
||
meta.addWidget(QLabel("店铺:%s" % account))
|
||
meta.addWidget(QLabel(item_text))
|
||
source_label = QLabel("来源:%s" % _generation_source_summary(self.jobs))
|
||
source_label.setObjectName("suiteGlobalHistorySource")
|
||
source_label.setStyleSheet("color: #57606a;")
|
||
meta.addWidget(source_label)
|
||
layout.addLayout(meta, 0)
|
||
|
||
thumbnails = QHBoxLayout()
|
||
thumbnails.setSpacing(6)
|
||
thumbnails.setContentsMargins(0, 0, 0, 0)
|
||
shown_entries = self._usable_entries[: self.MAX_THUMBNAILS]
|
||
for entry_index, (job, asset) in enumerate(shown_entries):
|
||
thumbnail = SuiteGlobalHistoryThumbnail(job, asset, entry_index)
|
||
thumbnail.previewRequested.connect(self._emit_preview)
|
||
thumbnails.addWidget(thumbnail)
|
||
if not shown_entries:
|
||
placeholder = QLabel("本轮没有可预览图片")
|
||
placeholder.setObjectName("suiteGlobalHistoryNoImage")
|
||
placeholder.setAlignment(Qt.AlignCenter)
|
||
placeholder.setFixedSize(150, 78)
|
||
placeholder.setStyleSheet("color: #6b7280; background: #f6f8fa;")
|
||
thumbnails.addWidget(placeholder)
|
||
remaining = max(0, len(self._usable_entries) - len(shown_entries))
|
||
if remaining:
|
||
more = QLabel("+%d" % remaining)
|
||
more.setObjectName("suiteGlobalHistoryMoreImages")
|
||
more.setAlignment(Qt.AlignCenter)
|
||
more.setFixedSize(42, 78)
|
||
more.setToolTip("本轮还有%d张可预览生成图片" % remaining)
|
||
more.setStyleSheet("color: #57606a; background: #f6f8fa; border-radius: 4px;")
|
||
thumbnails.addWidget(more)
|
||
thumbnails.addStretch(1)
|
||
layout.addLayout(thumbnails, 1)
|
||
|
||
badges = QVBoxLayout()
|
||
badges.setSpacing(5)
|
||
badges.setContentsMargins(0, 0, 0, 0)
|
||
if bool(getattr(round_info, "is_current", False)):
|
||
current = QLabel("当前")
|
||
current.setObjectName("suiteGlobalHistoryCurrentBadge")
|
||
current.setAlignment(Qt.AlignCenter)
|
||
current.setStyleSheet(
|
||
"color: #0969da; background: #ddf4ff; border: 1px solid #54aeff; "
|
||
"border-radius: 5px; padding: 2px 6px; font-weight: 600;"
|
||
)
|
||
badges.addWidget(current)
|
||
elif bool(getattr(round_info, "is_legacy", False)):
|
||
legacy = QLabel("旧版")
|
||
legacy.setAlignment(Qt.AlignCenter)
|
||
legacy.setStyleSheet(
|
||
"color: #57606a; background: #f6f8fa; border: 1px solid #d8dee4; "
|
||
"border-radius: 5px; padding: 2px 6px;"
|
||
)
|
||
badges.addWidget(legacy)
|
||
stats = QLabel(self._stats_text(round_info))
|
||
stats.setObjectName("suiteGlobalHistoryStats")
|
||
stats.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
stats.setStyleSheet("color: #57606a;")
|
||
badges.addWidget(stats)
|
||
badges.addStretch(1)
|
||
self.export_button = QPushButton("导出本轮")
|
||
self.export_button.setObjectName("suiteGlobalHistoryExportButton")
|
||
self.export_button.setEnabled(bool(getattr(round_info, "succeeded_count", 0)))
|
||
self.export_button.setToolTip("复制本轮成功生成的图片到所选目录")
|
||
self.export_button.clicked.connect(lambda: self.exportRequested.emit(self))
|
||
badges.addWidget(self.export_button)
|
||
layout.addLayout(badges, 0)
|
||
|
||
@staticmethod
|
||
def _stats_text(round_info):
|
||
parts = ["成功 %d" % int(getattr(round_info, "succeeded_count", 0) or 0)]
|
||
failed = int(getattr(round_info, "failed_count", 0) or 0)
|
||
cancelled = int(getattr(round_info, "cancelled_count", 0) or 0)
|
||
active = int(getattr(round_info, "active_count", 0) or 0)
|
||
retry = int(getattr(round_info, "retry_count", 0) or 0)
|
||
if failed:
|
||
parts.append("失败 %d" % failed)
|
||
if cancelled:
|
||
parts.append("停止 %d" % cancelled)
|
||
if active:
|
||
parts.append("进行中 %d" % active)
|
||
if retry:
|
||
parts.append("重试 %d" % retry)
|
||
return " · ".join(parts)
|
||
|
||
def set_exporting(self, exporting):
|
||
self.export_button.setEnabled(
|
||
not bool(exporting) and bool(getattr(self.round_info, "succeeded_count", 0))
|
||
)
|
||
self.export_button.setText("正在导出" if exporting else "导出本轮")
|
||
|
||
def _emit_preview(self, entry_index):
|
||
self.previewRequested.emit(self, int(entry_index))
|
||
|
||
def mouseDoubleClickEvent(self, event):
|
||
if event.button() == Qt.LeftButton and self._usable_entries:
|
||
self._emit_preview(0)
|
||
super().mouseDoubleClickEvent(event)
|
||
|
||
|
||
class ProductSuiteGlobalHistoryDialog(QDialog):
|
||
"""Read-only global product-suite history with per-round preview and export."""
|
||
|
||
PAGE_SIZE = 30
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
current_project_id=None,
|
||
current_project_only=False,
|
||
db_path=None,
|
||
parent=None,
|
||
):
|
||
super().__init__(parent)
|
||
self.db_path = db_path
|
||
self.current_project_id = None
|
||
self._offset = 0
|
||
self._has_more = False
|
||
self._round_count = 0
|
||
self._available_image_count = 0
|
||
self._history_rows = []
|
||
self._asset_cache = {}
|
||
self._export_worker = None
|
||
self._export_thread = None
|
||
self._export_error_handled = False
|
||
|
||
self.setObjectName("suiteGlobalHistoryDialog")
|
||
self.setWindowTitle("套图历史生成记录")
|
||
self.setModal(False)
|
||
self.setAttribute(Qt.WA_DeleteOnClose, True)
|
||
self.setMinimumSize(940, 560)
|
||
self.resize(1160, 720)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(14, 14, 14, 14)
|
||
layout.setSpacing(10)
|
||
|
||
header = QHBoxLayout()
|
||
title_layout = QVBoxLayout()
|
||
title_layout.setSpacing(2)
|
||
self.context_label = QLabel("全部商品的套图生成历史")
|
||
self.context_label.setObjectName("suiteGlobalHistoryContextLabel")
|
||
self.context_label.setStyleSheet("font-weight: 600; color: #24292f;")
|
||
self.summary_label = QLabel()
|
||
self.summary_label.setObjectName("suiteGlobalHistorySummaryLabel")
|
||
self.summary_label.setStyleSheet("color: #57606a;")
|
||
title_layout.addWidget(self.context_label)
|
||
title_layout.addWidget(self.summary_label)
|
||
header.addLayout(title_layout, 1)
|
||
self.refresh_button = QPushButton("刷新")
|
||
self.refresh_button.setObjectName("suiteGlobalHistoryRefreshButton")
|
||
self.refresh_button.setToolTip("重新读取套图历史生成记录")
|
||
self.refresh_button.clicked.connect(self.refresh_history)
|
||
header.addWidget(self.refresh_button)
|
||
layout.addLayout(header)
|
||
|
||
filters = QHBoxLayout()
|
||
filters.setSpacing(8)
|
||
filters.addWidget(QLabel("店铺"))
|
||
self.account_filter_combo = QComboBox()
|
||
self.account_filter_combo.setObjectName("suiteGlobalHistoryAccountFilter")
|
||
self.account_filter_combo.setToolTip("选择要查看历史生成记录的店铺")
|
||
self.account_filter_combo.currentIndexChanged.connect(self.refresh_history)
|
||
filters.addWidget(self.account_filter_combo, 1)
|
||
filters.addWidget(QLabel("商品ID"))
|
||
self.item_filter_edit = QLineEdit()
|
||
self.item_filter_edit.setObjectName("suiteGlobalHistoryItemFilter")
|
||
self.item_filter_edit.setPlaceholderText("输入商品ID")
|
||
self.item_filter_edit.setClearButtonEnabled(True)
|
||
self.item_filter_edit.returnPressed.connect(self.refresh_history)
|
||
filters.addWidget(self.item_filter_edit, 1)
|
||
self.current_project_checkbox = QCheckBox("仅当前商品")
|
||
self.current_project_checkbox.setObjectName("suiteGlobalHistoryCurrentProjectFilter")
|
||
self.current_project_checkbox.toggled.connect(self.refresh_history)
|
||
filters.addWidget(self.current_project_checkbox)
|
||
self.filter_button = QPushButton("筛选")
|
||
self.filter_button.setObjectName("suiteGlobalHistoryFilterButton")
|
||
self.filter_button.clicked.connect(self.refresh_history)
|
||
filters.addWidget(self.filter_button)
|
||
layout.addLayout(filters)
|
||
|
||
self.notice_label = QLabel()
|
||
self.notice_label.setObjectName("suiteGlobalHistoryNoticeLabel")
|
||
self.notice_label.setWordWrap(True)
|
||
self.notice_label.hide()
|
||
layout.addWidget(self.notice_label)
|
||
|
||
self.scroll = QScrollArea()
|
||
self.scroll.setObjectName("suiteGlobalHistoryScrollArea")
|
||
self.scroll.setWidgetResizable(True)
|
||
self.history_content = QWidget()
|
||
self.history_content.setObjectName("suiteGlobalHistoryContent")
|
||
self.history_layout = QVBoxLayout(self.history_content)
|
||
self.history_layout.setContentsMargins(0, 0, 0, 0)
|
||
self.history_layout.setSpacing(7)
|
||
self.history_layout.setAlignment(Qt.AlignTop)
|
||
self.scroll.setWidget(self.history_content)
|
||
layout.addWidget(self.scroll, 1)
|
||
|
||
self.load_more_button = QPushButton("加载更多")
|
||
self.load_more_button.setObjectName("suiteGlobalHistoryLoadMoreButton")
|
||
self.load_more_button.clicked.connect(self.load_more)
|
||
self.load_more_button.hide()
|
||
layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter)
|
||
|
||
self.set_current_project(current_project_id, refresh=False)
|
||
self.set_current_project_filter(current_project_only, refresh=False)
|
||
self.refresh_history()
|
||
|
||
def set_current_project(self, project_id, *, refresh=True):
|
||
try:
|
||
normalized = int(project_id) if project_id is not None else None
|
||
except (TypeError, ValueError):
|
||
normalized = None
|
||
changed = normalized != self.current_project_id
|
||
self.current_project_id = normalized
|
||
previous = self.current_project_checkbox.blockSignals(True)
|
||
self.current_project_checkbox.setEnabled(normalized is not None)
|
||
self.current_project_checkbox.setToolTip(
|
||
"只显示当前商品项目的历史生成记录"
|
||
if normalized is not None
|
||
else "当前没有可筛选的商品项目"
|
||
)
|
||
if normalized is None:
|
||
self.current_project_checkbox.setChecked(False)
|
||
self.current_project_checkbox.blockSignals(previous)
|
||
if refresh and (changed or self.current_project_checkbox.isChecked()):
|
||
self.refresh_history()
|
||
|
||
def set_current_project_filter(self, enabled, *, refresh=True):
|
||
should_filter = bool(enabled) and self.current_project_id is not None
|
||
changed = self.current_project_checkbox.isChecked() != should_filter
|
||
previous = self.current_project_checkbox.blockSignals(True)
|
||
self.current_project_checkbox.setChecked(should_filter)
|
||
self.current_project_checkbox.blockSignals(previous)
|
||
if refresh and changed:
|
||
self.refresh_history()
|
||
|
||
def refresh_history(self, checked=False):
|
||
scroll_value = self.scroll.verticalScrollBar().value()
|
||
try:
|
||
self._refresh_account_filter_options()
|
||
except Exception as exc:
|
||
self._set_error("历史店铺读取失败:%s" % _user_error(exc))
|
||
return
|
||
self._clear_history_content()
|
||
self._offset = 0
|
||
self._has_more = False
|
||
self._round_count = 0
|
||
self._available_image_count = 0
|
||
self._asset_cache = {}
|
||
if not self._load_next_page():
|
||
return
|
||
QTimer.singleShot(
|
||
0,
|
||
lambda: self.scroll.verticalScrollBar().setValue(
|
||
min(scroll_value, self.scroll.verticalScrollBar().maximum())
|
||
),
|
||
)
|
||
|
||
def load_more(self, checked=False):
|
||
if self._has_more:
|
||
self._load_next_page()
|
||
|
||
def _load_next_page(self):
|
||
try:
|
||
project_id = (
|
||
self.current_project_id
|
||
if self.current_project_checkbox.isChecked()
|
||
else None
|
||
)
|
||
rounds = image_studio.list_global_generation_rounds(
|
||
account_alias=self.account_filter_combo.currentData(),
|
||
item_query=self.item_filter_edit.text(),
|
||
project_id=project_id,
|
||
limit=self.PAGE_SIZE,
|
||
offset=self._offset,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._set_error("历史记录读取失败:%s" % _user_error(exc))
|
||
return False
|
||
|
||
self.notice_label.hide()
|
||
if not rounds and self._offset == 0:
|
||
self._show_empty_state()
|
||
else:
|
||
for round_info in rounds:
|
||
self._add_round(round_info)
|
||
self._offset += len(rounds)
|
||
self._has_more = len(rounds) == self.PAGE_SIZE
|
||
self.load_more_button.setVisible(self._has_more)
|
||
self.load_more_button.setEnabled(self._has_more)
|
||
self._update_summary()
|
||
return True
|
||
|
||
def _refresh_account_filter_options(self):
|
||
selected_alias = self.account_filter_combo.currentData()
|
||
current_accounts = accounts.list_accounts(path=self.db_path)
|
||
history_accounts = image_studio.list_global_history_accounts(path=self.db_path)
|
||
|
||
active_by_alias = {}
|
||
for account in current_accounts:
|
||
alias = str(getattr(account, "alias", "") or "").strip()
|
||
if alias:
|
||
active_by_alias[alias] = account
|
||
|
||
previous = self.account_filter_combo.blockSignals(True)
|
||
try:
|
||
self.account_filter_combo.clear()
|
||
self.account_filter_combo.addItem("全部店铺", None)
|
||
for alias in sorted(
|
||
active_by_alias,
|
||
key=lambda value: (value.casefold(), value),
|
||
):
|
||
account = active_by_alias[alias]
|
||
account_name = str(getattr(account, "account_name", "") or "").strip()
|
||
self.account_filter_combo.addItem(
|
||
"%s(%s)" % (account_name or alias, alias),
|
||
alias,
|
||
)
|
||
for history_account in history_accounts:
|
||
alias = str(getattr(history_account, "account_alias", "") or "").strip()
|
||
if alias and alias not in active_by_alias:
|
||
self.account_filter_combo.addItem(
|
||
"历史店铺:%s(账号已删除)" % alias,
|
||
alias,
|
||
)
|
||
selected_index = self.account_filter_combo.findData(selected_alias)
|
||
self.account_filter_combo.setCurrentIndex(
|
||
selected_index if selected_index >= 0 else 0
|
||
)
|
||
finally:
|
||
self.account_filter_combo.blockSignals(previous)
|
||
|
||
def _add_round(self, round_info):
|
||
try:
|
||
jobs = image_studio.list_generation_round_current_jobs(
|
||
round_info.project_id,
|
||
round_info.generation_round_key,
|
||
path=self.db_path,
|
||
)
|
||
assets_by_job = {}
|
||
for job in jobs:
|
||
asset_id = getattr(job, "output_asset_id", None)
|
||
if not asset_id:
|
||
continue
|
||
asset_id = int(asset_id)
|
||
if asset_id not in self._asset_cache:
|
||
self._asset_cache[asset_id] = image_studio.get_asset(
|
||
asset_id,
|
||
path=self.db_path,
|
||
)
|
||
assets_by_job[int(job.id)] = self._asset_cache[asset_id]
|
||
except Exception as exc:
|
||
self._set_notice("部分历史记录无法读取:%s" % _user_error(exc), "#cf222e")
|
||
return
|
||
|
||
row = SuiteGlobalHistoryRoundRow(round_info, jobs, assets_by_job)
|
||
row.previewRequested.connect(self._preview_round)
|
||
row.exportRequested.connect(self._export_round)
|
||
self.history_layout.addWidget(row)
|
||
self._history_rows.append(row)
|
||
self._round_count += 1
|
||
self._available_image_count += len(row._usable_entries)
|
||
|
||
def _preview_round(self, row, start_index):
|
||
entries = [
|
||
(
|
||
asset.local_path,
|
||
str(getattr(job, "job_type", "套图") or "套图"),
|
||
)
|
||
for job, asset in row._usable_entries
|
||
]
|
||
if not entries:
|
||
self._set_notice("本轮没有可预览的本地生成图片。", "#9a6700")
|
||
return
|
||
title = "商品ID %s" % str(getattr(row.round_info, "item_id", "") or "临时草稿")
|
||
ProductSuiteRoundPreviewDialog(
|
||
entries,
|
||
start_index=start_index,
|
||
title=title,
|
||
parent=self,
|
||
).exec()
|
||
|
||
def _export_round(self, row):
|
||
if self._export_worker is not None:
|
||
self._set_notice("正在导出另一轮图片,请稍候。", "#9a6700")
|
||
return
|
||
parent_dir = QFileDialog.getExistingDirectory(self, "选择导出父目录")
|
||
if not parent_dir:
|
||
return
|
||
worker = ProductSuiteHistoryExportWorker(
|
||
row.round_info.project_id,
|
||
row.round_info.generation_round_key,
|
||
parent_dir,
|
||
db_path=self.db_path,
|
||
)
|
||
thread = run_worker(worker, thread_name="商品套图历史导出", start=False)
|
||
token = id(thread)
|
||
_PRODUCT_SUITE_THREAD_REFS[token] = (thread, worker)
|
||
thread.finished.connect(lambda token=token: _PRODUCT_SUITE_THREAD_REFS.pop(token, None))
|
||
worker.finished.connect(self._on_export_finished)
|
||
worker.cancelled.connect(self._on_export_cancelled)
|
||
worker.failed.connect(self._on_export_failed)
|
||
self._export_worker = worker
|
||
self._export_thread = thread
|
||
self._export_error_handled = False
|
||
self._set_export_controls(True)
|
||
thread.start()
|
||
|
||
def _on_export_finished(self, summary):
|
||
if dict(summary or {}).get("ok") is False:
|
||
if not self._export_error_handled:
|
||
self._on_export_failed(-1, dict(summary or {}).get("error") or "导出失败")
|
||
return
|
||
self._reset_export_state()
|
||
summary = dict(summary or {})
|
||
if summary.get("cancelled"):
|
||
self._set_notice("导出已停止,已完成的图片保留在导出目录中。", "#9a6700")
|
||
return
|
||
file_count = int(summary.get("file_count", 0) or 0)
|
||
skipped_count = int(summary.get("skipped_count", 0) or 0)
|
||
message = "本轮已导出%d张生成图片" % file_count
|
||
if skipped_count:
|
||
message += ",略过%d张不可用图片" % skipped_count
|
||
self._set_notice(message, "#1a7f37")
|
||
target_dir = str(summary.get("target_dir") or "")
|
||
if not target_dir:
|
||
return
|
||
box = QMessageBox(self)
|
||
box.setWindowTitle("导出完成")
|
||
box.setText(message)
|
||
open_button = box.addButton("打开目录", QMessageBox.AcceptRole)
|
||
box.addButton("确定", QMessageBox.RejectRole)
|
||
box.setDefaultButton(open_button)
|
||
box.exec()
|
||
if box.clickedButton() is open_button:
|
||
try:
|
||
file_manager.open_in_file_manager(target_dir)
|
||
except Exception as exc:
|
||
self._set_notice("打开导出目录失败:%s" % _user_error(exc), "#cf222e")
|
||
|
||
def _on_export_cancelled(self, summary):
|
||
self._reset_export_state()
|
||
self._set_notice("导出已停止,已完成的图片保留在导出目录中。", "#9a6700")
|
||
|
||
def _on_export_failed(self, _row, error):
|
||
if self._export_error_handled:
|
||
return
|
||
self._export_error_handled = True
|
||
self._reset_export_state()
|
||
self._set_notice("导出本轮失败:%s" % _user_error(error), "#cf222e")
|
||
|
||
def _reset_export_state(self):
|
||
self._export_worker = None
|
||
self._export_thread = None
|
||
self._set_export_controls(False)
|
||
|
||
def _set_export_controls(self, exporting):
|
||
for row in list(self._history_rows):
|
||
try:
|
||
row.set_exporting(exporting)
|
||
except RuntimeError:
|
||
continue
|
||
|
||
def _update_summary(self):
|
||
refreshed = time.strftime("%H:%M:%S")
|
||
self.summary_label.setText(
|
||
"已加载 %d 轮 · 可预览图片 %d 张 · 最近刷新 %s"
|
||
% (self._round_count, self._available_image_count, refreshed)
|
||
)
|
||
|
||
def _clear_history_content(self):
|
||
self._history_rows = []
|
||
while self.history_layout.count():
|
||
item = self.history_layout.takeAt(0)
|
||
widget = item.widget()
|
||
if widget is not None:
|
||
widget.deleteLater()
|
||
|
||
def _set_error(self, message):
|
||
self._clear_history_content()
|
||
self._set_notice(message, "#cf222e")
|
||
self.load_more_button.hide()
|
||
self.refresh_button.setEnabled(True)
|
||
|
||
def _set_notice(self, text, color="#57606a"):
|
||
self.notice_label.setText(str(text))
|
||
self.notice_label.setStyleSheet("color: %s; padding: 8px 0;" % color)
|
||
self.notice_label.show()
|
||
|
||
def _show_empty_state(self):
|
||
empty = QLabel("暂无套图历史生成记录,完成套图生成后会自动出现在这里")
|
||
empty.setObjectName("suiteGlobalHistoryEmptyLabel")
|
||
empty.setAlignment(Qt.AlignCenter)
|
||
empty.setStyleSheet("color: #6b7280; padding: 56px;")
|
||
self.history_layout.addWidget(empty)
|
||
|
||
def closeEvent(self, event):
|
||
if self._export_worker is not None:
|
||
self._export_worker.cancel()
|
||
self._clear_history_content()
|
||
super().closeEvent(event)
|
||
|
||
|
||
@dataclass
|
||
class SuiteTaskState:
|
||
key: int
|
||
serial: int
|
||
account_alias: str = ""
|
||
item_id: str = ""
|
||
project_id: int = None
|
||
project_binding_state: str = ""
|
||
prompt: str = ""
|
||
last_saved_prompt: str = ""
|
||
settings: dict = field(default_factory=product_suite.default_suite_settings)
|
||
current_job_ids: list = field(default_factory=list)
|
||
current_generation_round_key: str = ""
|
||
generation_job_ids: list = field(default_factory=list)
|
||
generation_mode: str = "batch"
|
||
generation_retry_job_id: int = None
|
||
generation_round_key: str = ""
|
||
generation_source: str = ""
|
||
worker: object = None
|
||
thread: object = None
|
||
generation_run_token: str = ""
|
||
generation_stop_requested: bool = False
|
||
generation_terminal_streak: int = 0
|
||
pull_worker: object = None
|
||
pull_thread: object = None
|
||
pull_run_token: str = ""
|
||
pull_stop_requested: bool = False
|
||
pull_cleanup_mode: str = "keep"
|
||
pull_before_asset_ids: set = field(default_factory=set)
|
||
pull_before_asset_states: list = field(default_factory=list)
|
||
pull_asset_ids: set = field(default_factory=set)
|
||
pull_download_asset_ids: set = field(default_factory=set)
|
||
pull_download_failed: int = 0
|
||
pull_started_at: float = None
|
||
import_worker: object = None
|
||
import_thread: object = None
|
||
import_created_draft: bool = False
|
||
ai_worker: object = None
|
||
ai_thread: object = None
|
||
ai_price_worker: object = None
|
||
ai_price_thread: object = None
|
||
ai_confirmation_open: bool = False
|
||
generation_price_worker: object = None
|
||
generation_price_thread: object = None
|
||
generation_confirmation_open: bool = False
|
||
resume_worker: object = None
|
||
resume_thread: object = None
|
||
download_queue: list = field(default_factory=list)
|
||
downloads: dict = field(default_factory=dict)
|
||
download_tokens: dict = field(default_factory=dict)
|
||
done: int = 0
|
||
failed: int = 0
|
||
total: int = 0
|
||
started_at: float = None
|
||
ai_started_at: float = None
|
||
ai_prompt_snapshot: str = ""
|
||
pending_ai_text: str = ""
|
||
undo_records: list = field(default_factory=list)
|
||
def generation_running(self):
|
||
return self.worker is not None
|
||
|
||
def pull_running(self):
|
||
return bool(self.pull_run_token)
|
||
|
||
|
||
class ProductSuiteTab(QWidget):
|
||
"""Native PySide6 product-suite UI backed by image_studio services."""
|
||
|
||
PLATFORM_OPTIONS = product_suite.PLATFORMS
|
||
COUNTRY_OPTIONS = product_suite.COUNTRIES
|
||
LANGUAGE_OPTIONS = product_suite.LANGUAGES
|
||
|
||
def __init__(
|
||
self,
|
||
parent=None,
|
||
db_path=None,
|
||
config=None,
|
||
config_path=None,
|
||
status_callback=None,
|
||
subscription_preflight_callback=None,
|
||
):
|
||
super().__init__(parent)
|
||
self.setObjectName("productSuiteTab")
|
||
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
||
self.db_path = db_path or appconfig.db_path(self.config)
|
||
self.config_path = config_path or self.config.get("config_path") or appconfig.CONFIG_PATH
|
||
self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config)
|
||
self.product_suite_prompt_path = appconfig.product_suite_prompt_path(self.config)
|
||
self.status_callback = status_callback
|
||
self.subscription_preflight_callback = subscription_preflight_callback
|
||
self.accounts = []
|
||
self._states = {}
|
||
self._retired_states = []
|
||
self._next_key = 1
|
||
self._next_serial = 1
|
||
self._displayed_state = None
|
||
self._prompt_save_timers = {}
|
||
self._generation_run_states = {}
|
||
self._pull_run_states = {}
|
||
self._original_list_context = None
|
||
self._loading = False
|
||
self._result_refresh_pending = False
|
||
self._prompt_template_init_error = ""
|
||
self._history_dialog = None
|
||
|
||
try:
|
||
prompts.ensure_default_product_suite_prompt(self.product_suite_prompt_path)
|
||
except prompts.PromptError as exc:
|
||
self._prompt_template_init_error = str(exc)
|
||
|
||
self._build_ui()
|
||
self._connect_signals()
|
||
self.refresh_accounts()
|
||
self._restore_draft_tasks()
|
||
if not self._states:
|
||
self.add_task(inherit=False)
|
||
self.elapsed_timer = QTimer(self)
|
||
self.elapsed_timer.setInterval(1000)
|
||
self.elapsed_timer.timeout.connect(self._refresh_elapsed)
|
||
self.elapsed_timer.start()
|
||
self.generation_watchdog_timer = QTimer(self)
|
||
self.generation_watchdog_timer.setInterval(750)
|
||
self.generation_watchdog_timer.timeout.connect(self._check_generation_watchdogs)
|
||
self.generation_watchdog_timer.start()
|
||
if self._prompt_template_init_error:
|
||
self._status(self._prompt_template_init_error, "danger")
|
||
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
self.refresh_gateway_state()
|
||
|
||
def _build_ui(self):
|
||
root = QVBoxLayout(self)
|
||
root.setContentsMargins(10, 8, 10, 8)
|
||
root.setSpacing(8)
|
||
root.addWidget(self._build_task_bar())
|
||
root.addWidget(self._build_context_bar())
|
||
|
||
self.main_splitter = QSplitter(Qt.Horizontal)
|
||
self.main_splitter.setObjectName("suiteMainSplitter")
|
||
self.main_splitter.addWidget(self._build_config_panel())
|
||
self.main_splitter.addWidget(self._build_results_panel())
|
||
self.main_splitter.setStretchFactor(0, 2)
|
||
self.main_splitter.setStretchFactor(1, 3)
|
||
self.main_splitter.setSizes([430, 700])
|
||
root.addWidget(self.main_splitter, 1)
|
||
|
||
def _build_task_bar(self):
|
||
frame = QFrame()
|
||
layout = QHBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
self.task_tabs = QTabBar()
|
||
self.task_tabs.setObjectName("suiteTaskTabs")
|
||
self.task_tabs.setTabsClosable(True)
|
||
self.task_tabs.setMovable(True)
|
||
self.task_tabs.setExpanding(False)
|
||
self.task_tabs.setFixedHeight(36)
|
||
self.task_tabs.setStyleSheet(
|
||
"""
|
||
QTabBar#suiteTaskTabs::tab {
|
||
min-width: 96px;
|
||
min-height: 26px;
|
||
max-height: 26px;
|
||
padding: 3px 10px;
|
||
margin-right: 4px;
|
||
border: 1px solid #c9d1d9;
|
||
border-bottom-color: #b8c0ca;
|
||
background: #f4f6f8;
|
||
color: #24292f;
|
||
}
|
||
QTabBar#suiteTaskTabs::tab:selected {
|
||
background: #ffffff;
|
||
border-color: #687785;
|
||
border-bottom-color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
QTabBar#suiteTaskTabs::tab:hover:!selected {
|
||
background: #eaf2ff;
|
||
}
|
||
"""
|
||
)
|
||
layout.addWidget(self.task_tabs, 1)
|
||
self.add_task_button = QToolButton()
|
||
self.add_task_button.setObjectName("suiteAddTaskButton")
|
||
self.add_task_button.setText("+")
|
||
self.add_task_button.setToolTip("新建套图任务")
|
||
self.add_task_button.setAccessibleName("新建套图任务")
|
||
self.add_task_button.setMinimumSize(32, 32)
|
||
layout.addWidget(self.add_task_button)
|
||
return frame
|
||
|
||
def _build_context_bar(self):
|
||
frame = QFrame()
|
||
frame.setObjectName("suiteContextBar")
|
||
layout = QHBoxLayout(frame)
|
||
self.context_bar_layout = layout
|
||
layout.setContentsMargins(10, 7, 10, 7)
|
||
layout.setSpacing(8)
|
||
self.history_button = QPushButton("历史生成")
|
||
self.history_button.setObjectName("suiteHistoryButton")
|
||
self.history_button.setToolTip("查看所有商品的历史生成记录")
|
||
layout.addWidget(self.history_button)
|
||
self.add_images_button = QPushButton("添加图片")
|
||
self.add_images_button.setObjectName("suiteAddImagesButton")
|
||
self.add_images_button.setAccessibleName("添加商品原图")
|
||
self.add_images_button.setToolTip("添加本地商品原图")
|
||
self.add_images_button.setStyleSheet(
|
||
"QPushButton#suiteAddImagesButton { "
|
||
"color: #0969da; border: 1px solid #0969da; "
|
||
"background-color: #eef4ff; font-weight: 600; "
|
||
"}"
|
||
"QPushButton#suiteAddImagesButton:hover { background-color: #dbeafe; }"
|
||
"QPushButton#suiteAddImagesButton:pressed { background-color: #c7ddff; }"
|
||
"QPushButton#suiteAddImagesButton:disabled { "
|
||
"color: #8c959f; border-color: #d8dee4; background-color: #f6f8fa; "
|
||
"}"
|
||
)
|
||
layout.addWidget(self.add_images_button)
|
||
self.item_id_hint_label = QLabel("请输入正确的商品ID")
|
||
self.item_id_hint_label.setObjectName("suiteItemIdHintLabel")
|
||
self.item_id_hint_label.setStyleSheet("color: #9a6700; font-weight: 600;")
|
||
self.item_id_hint_label.setToolTip("未绑定正式商品ID时,可添加本地图片和生成套图,但不能拉取蝦皮主图")
|
||
self.item_id_hint_label.setAccessibleName("商品ID提示")
|
||
self.item_id_hint_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
||
layout.addWidget(self.item_id_hint_label)
|
||
layout.addStretch(1)
|
||
layout.addWidget(QLabel("账号"))
|
||
self.account_combo = QComboBox()
|
||
self.account_combo.setObjectName("suiteAccountCombo")
|
||
self.account_combo.setMinimumWidth(120)
|
||
self.account_combo.setMaximumWidth(160)
|
||
self.account_combo.setMinimumContentsLength(10)
|
||
layout.addWidget(self.account_combo)
|
||
layout.addWidget(QLabel("商品ID"))
|
||
self.item_id_edit = QLineEdit()
|
||
self.item_id_edit.setObjectName("suiteItemIdEdit")
|
||
self.item_id_edit.setPlaceholderText("输入商品ID")
|
||
self.item_id_edit.setValidator(
|
||
QRegularExpressionValidator(QRegularExpression("^[0-9]*$"), self.item_id_edit)
|
||
)
|
||
item_width = max(
|
||
120,
|
||
min(140, self.item_id_edit.fontMetrics().horizontalAdvance("0" * 13) + 30),
|
||
)
|
||
self.item_id_edit.setMinimumWidth(item_width)
|
||
self.item_id_edit.setMaximumWidth(item_width)
|
||
layout.addWidget(self.item_id_edit)
|
||
self.pull_button = QPushButton("拉取蝦皮主图")
|
||
self.pull_button.setObjectName("suitePullButton")
|
||
layout.addWidget(self.pull_button)
|
||
return frame
|
||
|
||
def _build_config_panel(self):
|
||
panel = QWidget()
|
||
panel.setMinimumWidth(400)
|
||
layout = QVBoxLayout(panel)
|
||
layout.setContentsMargins(0, 0, 4, 0)
|
||
layout.setSpacing(7)
|
||
|
||
scroll = QScrollArea()
|
||
scroll.setObjectName("suiteConfigScroll")
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.NoFrame)
|
||
content = QWidget()
|
||
self.config_layout = QVBoxLayout(content)
|
||
self.config_layout.setContentsMargins(4, 2, 8, 2)
|
||
self.config_layout.setSpacing(9)
|
||
self.config_layout.addWidget(self._build_original_section())
|
||
self.config_layout.addWidget(self._build_settings_section())
|
||
self.config_layout.addWidget(self._build_prompt_section())
|
||
self.config_layout.addWidget(self._build_category_section())
|
||
self.config_layout.addStretch(1)
|
||
scroll.setWidget(content)
|
||
layout.addWidget(scroll, 1)
|
||
|
||
self.generate_button = QPushButton("生成套图(0)")
|
||
self.generate_button.setObjectName("suiteGenerateButton")
|
||
self.generate_button.setMinimumHeight(42)
|
||
self.generate_button.setStyleSheet(
|
||
"QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }"
|
||
"QPushButton:hover { background: #245fce; }"
|
||
)
|
||
layout.addWidget(self.generate_button)
|
||
self.resume_submitted_button = QPushButton("继续查询已提交图片")
|
||
self.resume_submitted_button.setObjectName("suiteResumeSubmittedButton")
|
||
self.resume_submitted_button.setToolTip("继续查询已提交到默认网关的图片,不会重新生成或再次扣点")
|
||
self.resume_submitted_button.setVisible(False)
|
||
layout.addWidget(self.resume_submitted_button)
|
||
self.generate_helper_label = QLabel("建议填写产品名称、核心卖点、目标人群、使用场景与禁用元素")
|
||
self.generate_helper_label.setWordWrap(True)
|
||
self.generate_helper_label.setStyleSheet("color: #6b7280;")
|
||
layout.addWidget(self.generate_helper_label)
|
||
return panel
|
||
|
||
def _build_original_section(self):
|
||
frame = QFrame()
|
||
layout = QVBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
title_row = QHBoxLayout()
|
||
title = QLabel("商品原图")
|
||
title.setStyleSheet("font-weight: 600;")
|
||
title_row.addWidget(title)
|
||
self.original_count_label = QLabel("0/16")
|
||
self.original_count_label.setStyleSheet("color: #6b7280;")
|
||
title_row.addWidget(self.original_count_label)
|
||
title_row.addStretch(1)
|
||
self.original_selected_label = QLabel("已选 0 张")
|
||
self.original_selected_label.setObjectName("suiteOriginalSelectedLabel")
|
||
self.original_selected_label.setStyleSheet("color: #6b7280;")
|
||
title_row.addWidget(self.original_selected_label)
|
||
self.select_all_originals_button = QToolButton()
|
||
self.select_all_originals_button.setObjectName("suiteSelectAllOriginalsButton")
|
||
self.select_all_originals_button.setText("全选")
|
||
self.select_all_originals_button.setToolTip("选择当前商品的全部原图")
|
||
self.select_all_originals_button.setAccessibleName("全选商品原图")
|
||
self.select_all_originals_button.setMinimumSize(48, 28)
|
||
title_row.addWidget(self.select_all_originals_button)
|
||
self.invert_originals_button = QToolButton()
|
||
self.invert_originals_button.setObjectName("suiteInvertOriginalsButton")
|
||
self.invert_originals_button.setText("反选")
|
||
self.invert_originals_button.setToolTip("反转当前商品原图的勾选状态")
|
||
self.invert_originals_button.setAccessibleName("反选商品原图")
|
||
self.invert_originals_button.setMinimumSize(48, 28)
|
||
title_row.addWidget(self.invert_originals_button)
|
||
layout.addLayout(title_row)
|
||
self.original_list = ProductOriginalList()
|
||
layout.addWidget(self.original_list)
|
||
return frame
|
||
|
||
def _build_settings_section(self):
|
||
frame = QFrame()
|
||
layout = QVBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
title = QLabel("生成设置")
|
||
title.setStyleSheet("font-weight: 600;")
|
||
layout.addWidget(title)
|
||
grid = QGridLayout()
|
||
self.settings_grid = grid
|
||
grid.setContentsMargins(0, 0, 0, 0)
|
||
grid.setHorizontalSpacing(6)
|
||
grid.setVerticalSpacing(3)
|
||
settings = (
|
||
(
|
||
"platform_label",
|
||
"platform_combo",
|
||
"平台",
|
||
"suitePlatformLabel",
|
||
"suitePlatformCombo",
|
||
self.PLATFORM_OPTIONS,
|
||
),
|
||
(
|
||
"country_label",
|
||
"country_combo",
|
||
"站点",
|
||
"suiteCountryLabel",
|
||
"suiteCountryCombo",
|
||
self.COUNTRY_OPTIONS,
|
||
),
|
||
(
|
||
"language_label",
|
||
"language_combo",
|
||
"语言",
|
||
"suiteLanguageLabel",
|
||
"suiteLanguageCombo",
|
||
self.LANGUAGE_OPTIONS,
|
||
),
|
||
(
|
||
"ratio_label",
|
||
"ratio_combo",
|
||
"比例",
|
||
"suiteRatioLabel",
|
||
"suiteRatioCombo",
|
||
product_suite.RATIOS,
|
||
),
|
||
)
|
||
for column, setting in enumerate(settings):
|
||
label_attr, combo_attr, text, label_name, combo_name, values = setting
|
||
label = QLabel(text)
|
||
label.setObjectName(label_name)
|
||
setattr(self, label_attr, label)
|
||
combo = self._value_combo(combo_name, text, values)
|
||
setattr(self, combo_attr, combo)
|
||
grid.addWidget(label, 0, column)
|
||
grid.addWidget(combo, 1, column)
|
||
grid.setColumnStretch(column, 1)
|
||
layout.addLayout(grid)
|
||
per_image_row = QHBoxLayout()
|
||
per_image_row.setContentsMargins(0, 0, 0, 0)
|
||
per_image_row.setSpacing(8)
|
||
self.per_image_checkbox = QCheckBox("每张上传图分别作为主图生成")
|
||
self.per_image_checkbox.setObjectName("suitePerImageCheckbox")
|
||
per_image_row.addWidget(self.per_image_checkbox)
|
||
self.per_image_helper_label = QLabel(
|
||
"多款式或多SKU图请勾选;同一商品多角度图不勾选,其余图会作为参考图一同提交。"
|
||
)
|
||
self.per_image_helper_label.setObjectName("suitePerImageHelperLabel")
|
||
self.per_image_helper_label.setWordWrap(True)
|
||
self.per_image_helper_label.setSizePolicy(
|
||
QSizePolicy.Expanding,
|
||
QSizePolicy.Preferred,
|
||
)
|
||
self.per_image_helper_label.setStyleSheet("color: #57606a; font-size: 12px;")
|
||
per_image_row.addWidget(self.per_image_helper_label, 1)
|
||
layout.addLayout(per_image_row)
|
||
return frame
|
||
|
||
def _value_combo(self, object_name, label, values):
|
||
combo = SuiteSettingsComboBox()
|
||
combo.setObjectName(object_name)
|
||
combo.setToolTip("%s设置" % label)
|
||
combo.setAccessibleName("%s设置" % label)
|
||
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||
for value in values:
|
||
combo.addItem(value, value)
|
||
combo.setItemData(combo.count() - 1, value, Qt.ToolTipRole)
|
||
return combo
|
||
|
||
def _build_prompt_section(self):
|
||
frame = QFrame()
|
||
layout = QVBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
title_row = QHBoxLayout()
|
||
self.prompt_title_layout = title_row
|
||
title = QLabel("商品卖点与要求")
|
||
self.prompt_title_label = title
|
||
title.setStyleSheet("font-weight: 600;")
|
||
title_row.addWidget(title)
|
||
self.ai_write_button = QPushButton("AI 帮写")
|
||
self.ai_write_button.setObjectName("suiteAiWriteButton")
|
||
title_row.addWidget(self.ai_write_button)
|
||
self.ai_cancel_button = QPushButton("取消")
|
||
self.ai_cancel_button.setObjectName("suiteAiCancelButton")
|
||
self.ai_cancel_button.hide()
|
||
title_row.addWidget(self.ai_cancel_button)
|
||
title_row.addStretch(1)
|
||
self.prompt_settings_button = QPushButton("提示词设置")
|
||
self.prompt_settings_button.setObjectName("suitePromptSettingsButton")
|
||
self.prompt_settings_button.setToolTip("编辑并预览套图最终提示词")
|
||
title_row.addWidget(self.prompt_settings_button)
|
||
layout.addLayout(title_row)
|
||
self.prompt_edit = AutoHeightPlainTextEdit()
|
||
self.prompt_edit.setObjectName("suitePromptEdit")
|
||
self.prompt_edit.setPlaceholderText("输入产品名称、卖点、目标人群、使用场景和禁用元素")
|
||
layout.addWidget(self.prompt_edit)
|
||
helper = QLabel("内容越具体,生成的商品套图越稳定。AI 帮写不会阻塞其他套图任务。")
|
||
helper.setWordWrap(True)
|
||
helper.setStyleSheet("color: #6b7280;")
|
||
layout.addWidget(helper)
|
||
return frame
|
||
|
||
def _build_category_section(self):
|
||
frame = QFrame()
|
||
layout = QVBoxLayout(frame)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
title_row = QHBoxLayout()
|
||
title = QLabel("套图结构配置")
|
||
title.setStyleSheet("font-weight: 600;")
|
||
title_row.addWidget(title)
|
||
title_row.addStretch(1)
|
||
self.category_total_label = QLabel("合计 0 张")
|
||
self.category_total_label.setStyleSheet("color: #2f6fed; font-weight: 600;")
|
||
title_row.addWidget(self.category_total_label)
|
||
layout.addLayout(title_row)
|
||
self.category_grid = QGridLayout()
|
||
self.category_grid.setContentsMargins(0, 0, 0, 0)
|
||
self.category_grid.setHorizontalSpacing(0)
|
||
self.category_grid.setVerticalSpacing(3)
|
||
self.category_grid.setColumnStretch(0, 1)
|
||
self.category_rows = {}
|
||
layout.addLayout(self.category_grid)
|
||
self.add_category_button = QPushButton("添加自定义分类")
|
||
self.add_category_button.setObjectName("suiteAddCategoryButton")
|
||
layout.addWidget(self.add_category_button)
|
||
self.custom_category_edit = QLineEdit()
|
||
self.custom_category_edit.setObjectName("suiteCustomCategoryEdit")
|
||
self.custom_category_edit.setPlaceholderText("输入分类名称后按回车")
|
||
self.custom_category_edit.hide()
|
||
layout.addWidget(self.custom_category_edit)
|
||
return frame
|
||
|
||
def _build_results_panel(self):
|
||
panel = QWidget()
|
||
layout = QVBoxLayout(panel)
|
||
layout.setContentsMargins(4, 0, 0, 0)
|
||
layout.setSpacing(7)
|
||
toolbar = QHBoxLayout()
|
||
self.results_toolbar_layout = toolbar
|
||
title = QLabel("生成结果")
|
||
title.setStyleSheet("font-weight: 600; font-size: 15px;")
|
||
toolbar.addWidget(title)
|
||
self.result_summary_label = QLabel("共 0 张 · 成功 0 张")
|
||
self.result_summary_label.setObjectName("suiteResultSummaryLabel")
|
||
self.result_summary_label.setStyleSheet(
|
||
"background: #eef4ff; color: #2459c4; padding: 3px 8px; border-radius: 4px;"
|
||
)
|
||
toolbar.addWidget(self.result_summary_label)
|
||
toolbar.addStretch(1)
|
||
self.undo_button = QPushButton("撤销删除")
|
||
self.undo_button.setObjectName("suiteUndoButton")
|
||
self.undo_button.hide()
|
||
toolbar.addWidget(self.undo_button)
|
||
self.more_button = QToolButton()
|
||
self.more_button.setText("⋯")
|
||
self.more_button.setToolTip("更多操作")
|
||
self.more_button.setAccessibleName("更多操作")
|
||
self.more_button.setMinimumSize(32, 32)
|
||
toolbar.addWidget(self.more_button)
|
||
layout.addLayout(toolbar)
|
||
|
||
self.result_scroll = QScrollArea()
|
||
self.result_scroll.setObjectName("suiteResultScroll")
|
||
self.result_scroll.setWidgetResizable(True)
|
||
self.result_scroll.setFrameShape(QFrame.StyledPanel)
|
||
self.result_content = QWidget()
|
||
self.result_grid = QGridLayout(self.result_content)
|
||
self.result_grid.setContentsMargins(12, 12, 12, 12)
|
||
self.result_grid.setSpacing(10)
|
||
self.result_grid.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||
self.result_scroll.setWidget(self.result_content)
|
||
layout.addWidget(self.result_scroll, 1)
|
||
|
||
bottom = QHBoxLayout()
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setObjectName("suiteProgressBar")
|
||
self.progress_bar.setRange(0, 1)
|
||
self.progress_bar.setValue(0)
|
||
self.progress_bar.setTextVisible(False)
|
||
bottom.addWidget(self.progress_bar, 1)
|
||
self.elapsed_label = QLabel("套图 0/0(0秒) · 失败 0")
|
||
self.elapsed_label.setObjectName("suiteElapsedLabel")
|
||
self.elapsed_label.setMinimumWidth(190)
|
||
bottom.addWidget(self.elapsed_label)
|
||
layout.addLayout(bottom)
|
||
return panel
|
||
|
||
def _connect_signals(self):
|
||
self.task_tabs.currentChanged.connect(self._on_task_changed)
|
||
self.task_tabs.tabCloseRequested.connect(self.close_task)
|
||
self.add_task_button.clicked.connect(lambda: self.add_task(inherit=True))
|
||
self.account_combo.currentIndexChanged.connect(self._on_account_changed)
|
||
self.item_id_edit.editingFinished.connect(self._on_item_finished)
|
||
self.pull_button.clicked.connect(self.pull_main_images)
|
||
self.add_images_button.clicked.connect(self.choose_images)
|
||
self.original_list.filesDropped.connect(self.import_files)
|
||
self.original_list.clipboardImage.connect(self.import_clipboard_image)
|
||
self.original_list.orderChanged.connect(self.reorder_originals)
|
||
self.original_list.deleteRequested.connect(self.delete_original)
|
||
self.original_list.deleteAssetsRequested.connect(self.delete_originals)
|
||
self.original_list.checkedAssetsChanged.connect(self._on_original_checks_changed)
|
||
self.original_list.itemClicked.connect(self._on_original_clicked)
|
||
self.original_list.itemDoubleClicked.connect(self._preview_original)
|
||
self.select_all_originals_button.clicked.connect(
|
||
lambda checked=False: self.original_list.select_all_assets()
|
||
)
|
||
self.invert_originals_button.clicked.connect(
|
||
lambda checked=False: self.original_list.invert_asset_checks()
|
||
)
|
||
for combo in (
|
||
self.platform_combo,
|
||
self.country_combo,
|
||
self.language_combo,
|
||
self.ratio_combo,
|
||
):
|
||
combo.currentIndexChanged.connect(self._on_settings_changed)
|
||
self.per_image_checkbox.toggled.connect(self._on_settings_changed)
|
||
self.prompt_edit.textChanged.connect(self._on_prompt_changed)
|
||
self.ai_write_button.clicked.connect(self.start_ai_write)
|
||
self.ai_cancel_button.clicked.connect(self.cancel_ai_write)
|
||
self.prompt_settings_button.clicked.connect(self.open_prompt_settings)
|
||
self.add_category_button.clicked.connect(self.add_custom_category)
|
||
self.custom_category_edit.returnPressed.connect(self._commit_custom_category)
|
||
self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit)
|
||
self.generate_button.clicked.connect(self.toggle_generation)
|
||
self.resume_submitted_button.clicked.connect(self.resume_submitted_jobs)
|
||
self.history_button.clicked.connect(self.open_history_dialog)
|
||
self.undo_button.clicked.connect(self.undo_delete)
|
||
self.more_button.clicked.connect(self._show_more_menu)
|
||
|
||
def _status(self, message, level=None):
|
||
_emit_status(self.status_callback, message, level=level)
|
||
|
||
def _message(self, title, message, icon=QMessageBox.Warning):
|
||
box = QMessageBox(self)
|
||
box.setIcon(icon)
|
||
box.setWindowTitle(str(title))
|
||
box.setText(str(message))
|
||
box.exec()
|
||
|
||
def _confirm(
|
||
self,
|
||
title,
|
||
message,
|
||
*,
|
||
destructive=False,
|
||
confirm_text="确认",
|
||
cancel_text="取消",
|
||
default_cancel=False,
|
||
):
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question)
|
||
box.setWindowTitle(str(title))
|
||
box.setText(str(message))
|
||
confirm_button = box.addButton(str(confirm_text), QMessageBox.AcceptRole)
|
||
cancel_button = box.addButton(str(cancel_text), QMessageBox.RejectRole)
|
||
if default_cancel:
|
||
box.setDefaultButton(cancel_button)
|
||
box.setEscapeButton(cancel_button)
|
||
elif not destructive:
|
||
box.setDefaultButton(confirm_button)
|
||
box.exec()
|
||
return box.clickedButton() is confirm_button
|
||
|
||
def _confirm_new_generation_history(self, state, summary):
|
||
item_id = str(state.item_id or "临时草稿")
|
||
account_text = self._account_context_label(state)
|
||
latest = str(summary.latest_succeeded_at or "").replace("T", " ")
|
||
lines = [
|
||
"%s" % account_text,
|
||
"商品ID:%s" % item_id,
|
||
"该商品已有成功套图:%d轮,%d张。"
|
||
% (
|
||
int(summary.successful_round_count),
|
||
int(summary.successful_image_count),
|
||
),
|
||
]
|
||
if latest:
|
||
lines.append("最近成功时间:%s" % latest)
|
||
lines.append("继续会创建新一轮生成,已有历史结果会保留。")
|
||
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Warning)
|
||
box.setWindowTitle("已有套图生成记录")
|
||
box.setText("\n".join(lines))
|
||
history_button = box.addButton("查看历史", QMessageBox.ActionRole)
|
||
continue_button = box.addButton("继续生成新一轮", QMessageBox.AcceptRole)
|
||
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||
box.setDefaultButton(cancel_button)
|
||
box.setEscapeButton(cancel_button)
|
||
box.exec()
|
||
clicked = box.clickedButton()
|
||
if clicked is history_button:
|
||
return "history"
|
||
if clicked is continue_button:
|
||
return "continue"
|
||
return "cancel"
|
||
|
||
def refresh_accounts(self):
|
||
selected = self.account_combo.currentData()
|
||
try:
|
||
self.accounts = accounts.list_accounts(path=self.db_path, config=self.config)
|
||
except Exception as exc:
|
||
self.accounts = []
|
||
self._status("商品套图账号读取失败:%s" % _user_error(exc), "danger")
|
||
self._loading = True
|
||
try:
|
||
self.account_combo.clear()
|
||
for account in self.accounts:
|
||
text = "%s(%s)" % (account.account_name, account.alias)
|
||
self.account_combo.addItem(text, account.alias)
|
||
self.account_combo.setItemData(
|
||
self.account_combo.count() - 1,
|
||
text,
|
||
Qt.ToolTipRole,
|
||
)
|
||
if not self.accounts:
|
||
self.account_combo.addItem("暂无账号,请先到账号管理添加", "")
|
||
index = self.account_combo.findData(selected)
|
||
if index >= 0:
|
||
self.account_combo.setCurrentIndex(index)
|
||
finally:
|
||
self._loading = False
|
||
self._update_account_tooltip()
|
||
|
||
def _append_task_state(self, state, title=None):
|
||
self._states[state.key] = state
|
||
index = self.task_tabs.addTab(title or "套图任务 %d" % state.serial)
|
||
self.task_tabs.setTabData(index, state.key)
|
||
if self.task_tabs.currentIndex() == index:
|
||
self._on_task_changed(index)
|
||
else:
|
||
self.task_tabs.setCurrentIndex(index)
|
||
return state
|
||
|
||
def _restore_draft_tasks(self):
|
||
try:
|
||
projects = image_studio.list_recoverable_draft_projects(path=self.db_path)
|
||
except Exception as exc:
|
||
self._status("临时草稿恢复失败:%s" % _user_error(exc), "danger")
|
||
return
|
||
for project in projects:
|
||
state = SuiteTaskState(
|
||
key=self._next_key,
|
||
serial=self._next_serial,
|
||
account_alias=project.account_alias,
|
||
project_id=int(project.id),
|
||
project_binding_state=project.binding_state,
|
||
prompt=str(project.draft_prompt or ""),
|
||
last_saved_prompt=str(project.draft_prompt or ""),
|
||
settings=product_suite.normalize_suite_settings(
|
||
image_studio.project_suite_settings(project)
|
||
),
|
||
)
|
||
self._next_key += 1
|
||
self._next_serial += 1
|
||
self._append_task_state(state, "临时草稿 %d" % state.serial)
|
||
if projects:
|
||
self._status("已恢复%d个临时草稿" % len(projects), "info")
|
||
|
||
def add_task(self, checked=False, inherit=True):
|
||
source = self._displayed_state if inherit else None
|
||
initial_settings = (
|
||
source.settings
|
||
if source is not None
|
||
else appconfig.product_suite_last_settings(self.config)
|
||
)
|
||
valid_aliases = {account.alias for account in self.accounts}
|
||
recent_alias = appconfig.product_suite_last_account_alias(self.config)
|
||
account_alias = source.account_alias if source is not None else recent_alias
|
||
if account_alias not in valid_aliases:
|
||
account_alias = self.accounts[0].alias if self.accounts else ""
|
||
if source is None and account_alias != recent_alias:
|
||
self._persist_last_account_alias(account_alias)
|
||
state = SuiteTaskState(
|
||
key=self._next_key,
|
||
serial=self._next_serial,
|
||
account_alias=account_alias,
|
||
prompt=(source.prompt if source is not None else ""),
|
||
settings=product_suite.normalize_suite_settings(initial_settings),
|
||
)
|
||
self._next_key += 1
|
||
self._next_serial += 1
|
||
return self._append_task_state(state)
|
||
|
||
def _set_task_title(self, state):
|
||
for index in range(self.task_tabs.count()):
|
||
if self.task_tabs.tabData(index) == state.key:
|
||
title = "临时草稿 %d" % state.serial if self._is_draft_state(state) else "套图任务 %d" % state.serial
|
||
self.task_tabs.setTabText(index, title)
|
||
return
|
||
|
||
def close_task(self, index):
|
||
key = self.task_tabs.tabData(index)
|
||
state = self._states.get(key)
|
||
if state is None:
|
||
return
|
||
if state is self._displayed_state:
|
||
self._save_controls_to_state(state)
|
||
self._flush_prompt_save(state)
|
||
project = self._state_project(state)
|
||
draft_action = None
|
||
if image_studio.is_draft_project(project) and image_studio.project_has_content(
|
||
project.id,
|
||
path=self.db_path,
|
||
):
|
||
draft_action = self._draft_close_action()
|
||
if draft_action == "cancel":
|
||
return
|
||
if state.generation_running():
|
||
if not self._confirm(
|
||
"关闭套图任务",
|
||
"任务生成中,关闭会取消该任务。确认关闭吗?",
|
||
destructive=True,
|
||
):
|
||
return
|
||
state.generation_stop_requested = True
|
||
state.worker.cancel()
|
||
for worker in (
|
||
state.ai_worker,
|
||
state.ai_price_worker,
|
||
state.generation_price_worker,
|
||
):
|
||
if worker is not None:
|
||
worker.cancel()
|
||
if state.pull_running():
|
||
state.pull_stop_requested = True
|
||
state.pull_cleanup_mode = "keep"
|
||
if state.pull_worker is not None:
|
||
state.pull_worker.cancel()
|
||
self._cancel_pull_downloads(state)
|
||
if image_studio.is_draft_project(project):
|
||
if draft_action == "delete":
|
||
try:
|
||
image_studio.soft_delete_project(
|
||
project.id,
|
||
reason="用户关闭临时草稿",
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._message("删除临时草稿失败", _user_error(exc))
|
||
return
|
||
self._status("临时草稿已删除", "success")
|
||
else:
|
||
try:
|
||
image_studio.discard_empty_draft_project(project.id, path=self.db_path)
|
||
except Exception as exc:
|
||
self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger")
|
||
self._release_prompt_save_timer(state)
|
||
if state.generation_run_token:
|
||
self._generation_run_states.pop(state.generation_run_token, None)
|
||
if state.pull_run_token:
|
||
self._pull_run_states.pop(state.pull_run_token, None)
|
||
self._retired_states.append(state)
|
||
self._states.pop(state.key, None)
|
||
self.task_tabs.removeTab(index)
|
||
if self.task_tabs.count() == 0:
|
||
self.add_task(inherit=False)
|
||
|
||
def _draft_close_action(self):
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Question)
|
||
box.setWindowTitle("关闭临时草稿")
|
||
box.setText("当前临时草稿包含图片或生成记录。请选择关闭后的处理方式。")
|
||
keep_button = box.addButton("保留草稿", QMessageBox.AcceptRole)
|
||
delete_button = box.addButton("删除草稿", QMessageBox.DestructiveRole)
|
||
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||
box.setDefaultButton(keep_button)
|
||
box.exec()
|
||
clicked = box.clickedButton()
|
||
if clicked is keep_button:
|
||
return "keep"
|
||
if clicked is delete_button:
|
||
return "delete"
|
||
if clicked is cancel_button:
|
||
return "cancel"
|
||
return "cancel"
|
||
|
||
def _state_for_index(self, index):
|
||
if index < 0:
|
||
return None
|
||
return self._states.get(self.task_tabs.tabData(index))
|
||
|
||
def _on_task_changed(self, index):
|
||
if self._loading:
|
||
return
|
||
if self._displayed_state is not None:
|
||
for worker in (
|
||
self._displayed_state.ai_price_worker,
|
||
self._displayed_state.generation_price_worker,
|
||
):
|
||
if worker is not None:
|
||
worker.cancel()
|
||
self._save_controls_to_state(self._displayed_state)
|
||
self._flush_prompt_save(self._displayed_state)
|
||
state = self._state_for_index(index)
|
||
self._displayed_state = state
|
||
if state is not None:
|
||
self._load_state(state)
|
||
self._offer_pending_ai_result(state)
|
||
|
||
def _save_controls_to_state(self, state):
|
||
if self._loading or state is None:
|
||
return
|
||
state.account_alias = str(self.account_combo.currentData() or "")
|
||
state.prompt = self.prompt_edit.toPlainText()
|
||
state.settings = self._settings_from_controls()
|
||
|
||
def _load_state(self, state):
|
||
self._sync_state_project_binding(state)
|
||
self._restore_current_generation_results(state)
|
||
self._loading = True
|
||
try:
|
||
self.custom_category_edit.hide()
|
||
self.add_category_button.show()
|
||
index = self.account_combo.findData(state.account_alias)
|
||
if index >= 0:
|
||
self.account_combo.setCurrentIndex(index)
|
||
self.item_id_edit.setText(state.item_id)
|
||
self.item_id_edit.setStyleSheet("")
|
||
self.prompt_edit.setPlainText(state.prompt)
|
||
self._set_combo_value(self.platform_combo, state.settings.get("platform"))
|
||
self._set_combo_value(self.country_combo, state.settings.get("country"))
|
||
self._set_combo_value(self.language_combo, state.settings.get("language"))
|
||
self._set_combo_value(self.ratio_combo, state.settings.get("ratio"))
|
||
self.per_image_checkbox.setChecked(bool(state.settings.get("per_image_primary")))
|
||
finally:
|
||
self._loading = False
|
||
self._refresh_originals(state)
|
||
self._rebuild_categories(state)
|
||
self._refresh_results(state)
|
||
self._apply_running_state(state)
|
||
self._refresh_elapsed()
|
||
|
||
def _set_combo_value(self, combo, value):
|
||
index = combo.findData(value)
|
||
if index >= 0:
|
||
combo.setCurrentIndex(index)
|
||
|
||
def _settings_from_controls(self):
|
||
current = self._displayed_state.settings if self._displayed_state else {}
|
||
settings = dict(current or {})
|
||
settings.update(
|
||
{
|
||
"platform": self.platform_combo.currentData(),
|
||
"country": self.country_combo.currentData(),
|
||
"language": self.language_combo.currentData(),
|
||
"ratio": self.ratio_combo.currentData(),
|
||
"per_image_primary": self.per_image_checkbox.isChecked(),
|
||
}
|
||
)
|
||
return product_suite.normalize_suite_settings(settings)
|
||
|
||
def _on_account_changed(self, index):
|
||
self._update_account_tooltip()
|
||
if self._loading or self._displayed_state is None:
|
||
return
|
||
state = self._displayed_state
|
||
alias = str(self.account_combo.currentData() or "")
|
||
if state.project_id is not None and alias != state.account_alias:
|
||
if self._is_draft_state(state):
|
||
self._message(
|
||
"不能切换账号",
|
||
"临时草稿已关联当前账号。请先绑定正式商品ID,或关闭草稿后再切换账号。",
|
||
)
|
||
self._loading = True
|
||
try:
|
||
self._set_combo_value(self.account_combo, state.account_alias)
|
||
finally:
|
||
self._loading = False
|
||
return
|
||
if not self._confirm(
|
||
"切换账号",
|
||
"切换账号后,当前任务会改为新的账号与商品上下文。确认继续吗?",
|
||
):
|
||
self._loading = True
|
||
try:
|
||
self._set_combo_value(self.account_combo, state.account_alias)
|
||
finally:
|
||
self._loading = False
|
||
return
|
||
self._clear_project_binding(state)
|
||
state.account_alias = alias
|
||
self._persist_last_account_alias(alias)
|
||
self._update_context_actions(state)
|
||
|
||
def _update_account_tooltip(self):
|
||
self.account_combo.setToolTip(self.account_combo.currentText())
|
||
|
||
def _on_item_finished(self):
|
||
self._commit_item_id(self._displayed_state)
|
||
|
||
def _restore_committed_item_id(self, state):
|
||
self._loading = True
|
||
try:
|
||
self.item_id_edit.setText(state.item_id)
|
||
finally:
|
||
self._loading = False
|
||
|
||
def _commit_item_id(self, state):
|
||
"""Validate and commit the visible product ID before an operation uses it."""
|
||
|
||
if self._loading or state is None:
|
||
return False
|
||
raw_item_id = self.item_id_edit.text()
|
||
item_id = image_studio.normalize_item_id_input(raw_item_id)
|
||
if item_id != raw_item_id:
|
||
self._loading = True
|
||
try:
|
||
self.item_id_edit.setText(item_id)
|
||
finally:
|
||
self._loading = False
|
||
if item_id and not image_studio.is_formal_item_id(item_id):
|
||
self.item_id_edit.setStyleSheet("border: 1px solid #9a6700;")
|
||
self._status("商品ID只能输入数字", "warning")
|
||
self._update_context_actions(state)
|
||
return False
|
||
self.item_id_edit.setStyleSheet("")
|
||
if self._is_draft_state(state):
|
||
if not item_id:
|
||
state.item_id = ""
|
||
self._update_context_actions(state)
|
||
return True
|
||
if not self._confirm(
|
||
"绑定正式商品",
|
||
"将当前临时草稿绑定到商品%s吗?\n已添加图片和生成记录会继续保留。" % item_id,
|
||
):
|
||
self._restore_committed_item_id(state)
|
||
self._update_context_actions(state)
|
||
return False
|
||
self._flush_prompt_save(state)
|
||
try:
|
||
project = image_studio.bind_draft_project(
|
||
state.project_id,
|
||
item_id,
|
||
path=self.db_path,
|
||
)
|
||
except image_studio.ImageStudioProjectConflictError as exc:
|
||
self._message("商品项目已存在", _user_error(exc))
|
||
self._restore_committed_item_id(state)
|
||
self._update_context_actions(state)
|
||
return False
|
||
except Exception as exc:
|
||
self._message("绑定正式商品失败", _user_error(exc))
|
||
self._restore_committed_item_id(state)
|
||
self._update_context_actions(state)
|
||
return False
|
||
state.item_id = project.item_id
|
||
state.project_binding_state = project.binding_state
|
||
self._set_task_title(state)
|
||
self._status("临时草稿已绑定商品%s" % project.item_id, "success")
|
||
self._update_context_actions(state)
|
||
return True
|
||
if state.project_id is not None and item_id != state.item_id:
|
||
if not self._confirm(
|
||
"切换商品",
|
||
"切换商品ID后,原图和生成结果会按新商品重新载入。确认继续吗?",
|
||
):
|
||
self._restore_committed_item_id(state)
|
||
return False
|
||
self._clear_project_binding(state)
|
||
state.item_id = item_id
|
||
if not item_id:
|
||
self._update_context_actions(state)
|
||
return True
|
||
if item_id and state.account_alias:
|
||
self._bind_project(state, load_existing=True)
|
||
self._update_context_actions(state)
|
||
return True
|
||
|
||
def _clear_project_binding(self, state):
|
||
self._flush_prompt_save(state)
|
||
state.project_id = None
|
||
state.project_binding_state = ""
|
||
state.last_saved_prompt = ""
|
||
state.current_job_ids = []
|
||
state.generation_job_ids = []
|
||
state.generation_mode = "batch"
|
||
state.generation_retry_job_id = None
|
||
state.pull_run_token = ""
|
||
state.pull_stop_requested = False
|
||
state.pull_before_asset_ids = set()
|
||
state.pull_before_asset_states = []
|
||
state.pull_asset_ids = set()
|
||
state.pull_download_asset_ids = set()
|
||
state.pull_download_failed = 0
|
||
state.done = state.failed = state.total = 0
|
||
state.started_at = None
|
||
if state is self._displayed_state:
|
||
self._refresh_originals(state)
|
||
self._refresh_results(state)
|
||
|
||
def _account_for_alias(self, alias):
|
||
return next((account for account in self.accounts if account.alias == alias), None)
|
||
|
||
def _account_context_label(self, state):
|
||
account = self._account_for_alias(getattr(state, "account_alias", ""))
|
||
alias = str(getattr(state, "account_alias", "") or "")
|
||
name = str(getattr(account, "account_name", "") or "").strip()
|
||
if name and name != alias:
|
||
return "%s(%s)" % (name, alias)
|
||
return name or alias or "未选择店铺"
|
||
|
||
def _state_project(self, state, *, include_deleted=False):
|
||
if state is None or state.project_id is None:
|
||
return None
|
||
try:
|
||
project = image_studio.get_project(
|
||
state.project_id,
|
||
path=self.db_path,
|
||
include_deleted=include_deleted,
|
||
)
|
||
except Exception as exc:
|
||
self._status("商品套图项目读取失败:%s" % _user_error(exc), "danger")
|
||
return None
|
||
if project is not None:
|
||
state.project_binding_state = project.binding_state
|
||
return project
|
||
|
||
def _sync_state_project_binding(self, state):
|
||
return self._state_project(state)
|
||
|
||
def _is_draft_state(self, state):
|
||
if state is None:
|
||
return False
|
||
if state.project_binding_state in image_studio.PROJECT_BINDING_STATES:
|
||
return state.project_binding_state == image_studio.PROJECT_BINDING_DRAFT
|
||
project = self._state_project(state)
|
||
return image_studio.is_draft_project(project)
|
||
|
||
def _has_account_context(self, state, *, show_message=True):
|
||
if state is None or not state.account_alias:
|
||
if show_message:
|
||
self._message("未选择账号", "请先在顶部选择账号。")
|
||
return False
|
||
if self._account_for_alias(state.account_alias) is None:
|
||
if show_message:
|
||
self._message("账号不可用", "所选账号不存在,请到账号管理刷新账号。")
|
||
return False
|
||
return True
|
||
|
||
def _valid_context(self, state, *, show_message=True):
|
||
if not self._has_account_context(state, show_message=show_message):
|
||
return False
|
||
if not image_studio.is_formal_item_id(state.item_id):
|
||
if show_message:
|
||
self._message("商品ID无效", "请输入正确的数字商品ID。")
|
||
return False
|
||
return True
|
||
|
||
def _ensure_project_for_local_work(self, state):
|
||
if state is None:
|
||
return None
|
||
if state.project_id is not None:
|
||
self._flush_prompt_save(state)
|
||
return self._state_project(state)
|
||
if state.item_id:
|
||
return self._bind_project(state)
|
||
return self._create_draft_project(state)
|
||
|
||
def _bind_project(self, state, *, load_existing=False):
|
||
if not self._valid_context(state):
|
||
return None
|
||
account = self._account_for_alias(state.account_alias)
|
||
if account is None:
|
||
self._message("账号不可用", "所选账号不存在,请到账号管理刷新账号。")
|
||
return None
|
||
previous_id = state.project_id
|
||
try:
|
||
project = image_studio.create_or_get_project(
|
||
account,
|
||
item_id=state.item_id,
|
||
draft_prompt=state.prompt,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._message("创建套图任务失败", _user_error(exc))
|
||
return None
|
||
state.project_id = int(project.id)
|
||
state.project_binding_state = project.binding_state
|
||
if previous_id != state.project_id:
|
||
self._restore_current_generation_results(state, force=True)
|
||
stored_prompt = str(project.draft_prompt or "")
|
||
if load_existing and previous_id != state.project_id:
|
||
state.prompt = stored_prompt
|
||
state.settings = product_suite.normalize_suite_settings(
|
||
image_studio.project_suite_settings(project)
|
||
)
|
||
if state is self._displayed_state:
|
||
self._load_state(state)
|
||
state.last_saved_prompt = stored_prompt
|
||
if state.prompt != stored_prompt:
|
||
self._flush_prompt_save(state)
|
||
return project
|
||
|
||
def _create_draft_project(self, state, *, announce=True):
|
||
if not self._has_account_context(state):
|
||
return None
|
||
account = self._account_for_alias(state.account_alias)
|
||
try:
|
||
project = image_studio.create_draft_project(
|
||
account,
|
||
draft_prompt=state.prompt,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._message("创建临时草稿失败", _user_error(exc))
|
||
return None
|
||
state.project_id = int(project.id)
|
||
state.project_binding_state = project.binding_state
|
||
state.item_id = ""
|
||
state.last_saved_prompt = str(project.draft_prompt or "")
|
||
self._persist_state(state)
|
||
self._set_task_title(state)
|
||
if state is self._displayed_state:
|
||
self._update_context_actions(state)
|
||
if announce:
|
||
self._status("已创建临时草稿,可继续添加本地图片", "info")
|
||
return project
|
||
|
||
def _prompt_save_timer(self, state):
|
||
timer = self._prompt_save_timers.get(state.key)
|
||
if timer is None:
|
||
timer = QTimer(self)
|
||
timer.setSingleShot(True)
|
||
timer.setInterval(PROMPT_AUTOSAVE_DELAY_MS)
|
||
timer.timeout.connect(
|
||
lambda key=state.key: self._on_prompt_autosave_timeout(key)
|
||
)
|
||
self._prompt_save_timers[state.key] = timer
|
||
return timer
|
||
|
||
def _schedule_prompt_save(self, state):
|
||
if state is None:
|
||
return
|
||
if state.project_id is None:
|
||
if (
|
||
not str(state.prompt or "").strip()
|
||
or not state.account_alias
|
||
or self._account_for_alias(state.account_alias) is None
|
||
):
|
||
self._cancel_prompt_save(state)
|
||
return
|
||
self._prompt_save_timer(state).start()
|
||
return
|
||
if state.prompt == state.last_saved_prompt:
|
||
self._cancel_prompt_save(state)
|
||
return
|
||
self._prompt_save_timer(state).start()
|
||
|
||
def _cancel_prompt_save(self, state):
|
||
timer = self._prompt_save_timers.get(state.key) if state is not None else None
|
||
if timer is not None:
|
||
timer.stop()
|
||
|
||
def _release_prompt_save_timer(self, state):
|
||
timer = (
|
||
self._prompt_save_timers.pop(state.key, None)
|
||
if state is not None
|
||
else None
|
||
)
|
||
if timer is not None:
|
||
timer.stop()
|
||
timer.deleteLater()
|
||
|
||
def _on_prompt_autosave_timeout(self, state_key):
|
||
state = self._states.get(state_key)
|
||
if state is not None:
|
||
self._persist_prompt(state)
|
||
|
||
def _persist_prompt(self, state):
|
||
if state is None:
|
||
return True
|
||
prompt = str(state.prompt or "")
|
||
if state.project_id is None:
|
||
if not prompt.strip():
|
||
return True
|
||
if (
|
||
not state.account_alias
|
||
or self._account_for_alias(state.account_alias) is None
|
||
):
|
||
return True
|
||
if self._create_draft_project(state, announce=False) is None:
|
||
return False
|
||
if prompt == state.last_saved_prompt:
|
||
return True
|
||
try:
|
||
project = image_studio.update_project_prompt(
|
||
state.project_id,
|
||
prompt,
|
||
path=self.db_path,
|
||
)
|
||
if project is None:
|
||
raise image_studio.ImageStudioError("商品套图项目不存在")
|
||
except Exception as exc:
|
||
self._status("商品卖点自动保存失败:%s" % _user_error(exc), "danger")
|
||
return False
|
||
state.last_saved_prompt = prompt
|
||
return True
|
||
|
||
def _flush_prompt_save(self, state):
|
||
self._cancel_prompt_save(state)
|
||
return self._persist_prompt(state)
|
||
|
||
def _persist_state(self, state):
|
||
if state.project_id is None:
|
||
return True
|
||
prompt_saved = self._flush_prompt_save(state)
|
||
try:
|
||
image_studio.update_project_suite_settings(
|
||
state.project_id,
|
||
state.settings,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._status("商品套图设置保存失败:%s" % _user_error(exc), "danger")
|
||
return False
|
||
return prompt_saved
|
||
|
||
def _on_settings_changed(self, value=None):
|
||
if self._loading or self._displayed_state is None:
|
||
return
|
||
state = self._displayed_state
|
||
state.settings = self._settings_from_controls()
|
||
self._persist_last_settings(state.settings)
|
||
self._persist_state(state)
|
||
self._refresh_totals(state)
|
||
|
||
def _persist_last_settings(self, settings):
|
||
last_settings = product_suite.last_suite_settings(settings)
|
||
try:
|
||
if os.path.exists(self.config_path):
|
||
saved = appconfig.update_config(
|
||
{"product_suite": {"last_settings": last_settings}},
|
||
path=self.config_path,
|
||
)
|
||
else:
|
||
base = dict(self.config)
|
||
suite = base.get("product_suite", {})
|
||
suite = dict(suite) if isinstance(suite, dict) else {}
|
||
suite["last_settings"] = last_settings
|
||
base["product_suite"] = suite
|
||
saved = appconfig.save_config(base, path=self.config_path)
|
||
except Exception as exc:
|
||
self._status("商品套图最近设置保存失败:%s" % _user_error(exc), "danger")
|
||
return
|
||
self.config.clear()
|
||
self.config.update(saved)
|
||
|
||
def _persist_last_account_alias(self, alias):
|
||
value = str(alias or "").strip()
|
||
if value == appconfig.product_suite_last_account_alias(self.config):
|
||
return
|
||
try:
|
||
if os.path.exists(self.config_path):
|
||
saved = appconfig.update_config(
|
||
{"product_suite": {"last_account_alias": value}},
|
||
path=self.config_path,
|
||
)
|
||
else:
|
||
base = dict(self.config)
|
||
suite = base.get("product_suite", {})
|
||
suite = dict(suite) if isinstance(suite, dict) else {}
|
||
suite["last_account_alias"] = value
|
||
base["product_suite"] = suite
|
||
saved = appconfig.save_config(base, path=self.config_path)
|
||
except Exception as exc:
|
||
self._status("商品套图最近店铺保存失败:%s" % _user_error(exc), "danger")
|
||
return
|
||
self.config.clear()
|
||
self.config.update(saved)
|
||
|
||
def _on_prompt_changed(self):
|
||
if self._loading or self._displayed_state is None:
|
||
return
|
||
state = self._displayed_state
|
||
state.prompt = self.prompt_edit.toPlainText()
|
||
self._schedule_prompt_save(state)
|
||
|
||
def open_prompt_settings(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is not None:
|
||
self._save_controls_to_state(state)
|
||
settings = state.settings
|
||
base_prompt = state.prompt
|
||
item_id = state.item_id or "未绑定商品"
|
||
source_image_count = len(
|
||
[asset for asset in self._original_assets(state) if _asset_usable(asset)]
|
||
)
|
||
else:
|
||
settings = product_suite.default_suite_settings()
|
||
base_prompt = ""
|
||
item_id = "未绑定商品"
|
||
source_image_count = 0
|
||
dialog = ProductSuitePromptDialog(
|
||
prompt_path=self.product_suite_prompt_path,
|
||
base_prompt=base_prompt,
|
||
settings=settings,
|
||
item_id=item_id,
|
||
source_image_count=source_image_count,
|
||
parent=self,
|
||
)
|
||
self.prompt_settings_dialog = dialog
|
||
dialog.exec()
|
||
|
||
def _update_context_actions(self, state):
|
||
is_draft = self._is_draft_state(state)
|
||
pull_running = bool(state and state.pull_running())
|
||
generation_running = bool(state and state.generation_running())
|
||
self.pull_button.setEnabled(
|
||
state is not None
|
||
and not generation_running
|
||
and (
|
||
pull_running
|
||
or is_draft
|
||
or self._valid_context(state, show_message=False)
|
||
)
|
||
)
|
||
self.pull_button.setToolTip(
|
||
"生成中不能拉取蝦皮主图"
|
||
if generation_running
|
||
else "正在停止拉取"
|
||
if pull_running and state.pull_stop_requested
|
||
else "停止拉取蝦皮主图"
|
||
if pull_running
|
||
else "需要先绑定正式商品ID"
|
||
if is_draft
|
||
else "拉取蝦皮主图"
|
||
)
|
||
self._refresh_item_id_hint(state)
|
||
self._refresh_add_images_action(state)
|
||
|
||
def _refresh_item_id_hint(self, state):
|
||
displayed_item_id = (
|
||
self.item_id_edit.text().strip()
|
||
if state is self._displayed_state
|
||
else str(state.item_id or "").strip()
|
||
)
|
||
item_id_invalid = bool(displayed_item_id and not image_studio.is_formal_item_id(displayed_item_id))
|
||
is_bound = bool(
|
||
state
|
||
and state.project_binding_state == image_studio.PROJECT_BINDING_BOUND
|
||
and image_studio.is_formal_item_id(state.item_id)
|
||
and not item_id_invalid
|
||
)
|
||
self.item_id_hint_label.setVisible(not is_bound)
|
||
if self._is_draft_state(state):
|
||
tooltip = "当前为临时草稿,可添加本地图片和生成套图;绑定正式商品ID后才能拉取蝦皮主图"
|
||
elif item_id_invalid:
|
||
tooltip = "商品ID只能输入数字;修正后可绑定或拉取蝦皮主图"
|
||
else:
|
||
tooltip = "未绑定正式商品ID时,可添加本地图片和生成套图,但不能拉取蝦皮主图"
|
||
self.item_id_hint_label.setToolTip(tooltip)
|
||
|
||
def _refresh_add_images_action(self, state):
|
||
asset_count = len(self._original_assets(state, include_missing=True)) if state else 0
|
||
full = asset_count >= image_studio_images.MAX_ORIGINAL_ASSETS
|
||
generation_running = bool(state and state.generation_running())
|
||
importing = bool(state and state.import_worker is not None)
|
||
self.add_images_button.setEnabled(
|
||
state is not None
|
||
and self._has_account_context(state, show_message=False)
|
||
and not full
|
||
and not generation_running
|
||
and not importing
|
||
)
|
||
if not self._has_account_context(state, show_message=False):
|
||
tooltip = "请先选择账号"
|
||
elif full:
|
||
tooltip = "已达到16张商品原图上限"
|
||
elif generation_running:
|
||
tooltip = "生成中不能添加商品原图"
|
||
elif importing:
|
||
tooltip = "正在添加商品原图"
|
||
else:
|
||
tooltip = "添加本地商品原图"
|
||
self.add_images_button.setToolTip(tooltip)
|
||
|
||
def _start_thread(self, worker, name):
|
||
thread = run_worker(worker, thread_name=name, start=False)
|
||
token = id(thread)
|
||
_PRODUCT_SUITE_THREAD_REFS[token] = (thread, worker)
|
||
thread.finished.connect(lambda token=token: _PRODUCT_SUITE_THREAD_REFS.pop(token, None))
|
||
run_token = str(getattr(worker, "run_token", "") or "")
|
||
if run_token:
|
||
thread.setProperty("productSuiteRunToken", run_token)
|
||
thread.finished.connect(self._on_generation_thread_finished_signal)
|
||
pull_run_token = str(getattr(worker, "pull_run_token", "") or "")
|
||
if pull_run_token:
|
||
thread.setProperty("productSuitePullRunToken", pull_run_token)
|
||
thread.finished.connect(self._on_pull_thread_finished_signal)
|
||
thread.start()
|
||
return thread
|
||
|
||
def _refresh_originals(self, state, *, preserve_checks=True):
|
||
context = (state.key, state.project_id) if state is not None else None
|
||
checked_ids = (
|
||
set(self.original_list.checked_asset_ids())
|
||
if preserve_checks and context == self._original_list_context
|
||
else set()
|
||
)
|
||
previous = self.original_list.blockSignals(True)
|
||
self.original_list.clear()
|
||
assets = self._original_assets(state, include_missing=False)
|
||
try:
|
||
for index, asset in enumerate(assets, 1):
|
||
label = "主图" if index == 1 else "参考%d" % (index - 1)
|
||
item = QListWidgetItem(label)
|
||
item.setFlags(
|
||
item.flags() | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled
|
||
)
|
||
if index == 1:
|
||
font = QFont(item.font())
|
||
font.setBold(True)
|
||
item.setFont(font)
|
||
item.setForeground(QColor("#1f5fbf"))
|
||
item.setData(Qt.UserRole, int(asset.id))
|
||
item.setData(
|
||
ORIGINAL_CHECK_STATE_ROLE,
|
||
Qt.Checked if int(asset.id) in checked_ids else Qt.Unchecked,
|
||
)
|
||
if _asset_usable(asset):
|
||
item.setIcon(QIcon(_image_pixmap(asset.local_path, QSize(82, 64))))
|
||
item.setToolTip(
|
||
"%s,勾选可批量删除;双击预览;%s"
|
||
% (
|
||
label,
|
||
"拖动到第一位即可设为主图"
|
||
if self.original_list.reordering_enabled()
|
||
else "正在拉取蝦皮主图,完成后可调整图片顺序",
|
||
)
|
||
)
|
||
else:
|
||
item.setIcon(QIcon(_placeholder_pixmap("待下载", QSize(82, 64))))
|
||
item.setToolTip(
|
||
"%s尚未下载;勾选可批量删除,单击缩略图后台拉取;%s"
|
||
% (
|
||
label,
|
||
"拖动到第一位即可设为主图"
|
||
if self.original_list.reordering_enabled()
|
||
else "正在拉取蝦皮主图,完成后可调整图片顺序",
|
||
)
|
||
)
|
||
self.original_list.addItem(item)
|
||
finally:
|
||
self.original_list.blockSignals(previous)
|
||
self._original_list_context = context
|
||
self.original_list.update_content_height()
|
||
self.original_list.viewport().update()
|
||
self.original_count_label.setText("%d/16" % len(assets))
|
||
self._refresh_original_selection_controls()
|
||
self._refresh_add_images_action(state)
|
||
self._refresh_totals(state)
|
||
|
||
def _on_original_checks_changed(self, asset_ids):
|
||
self._refresh_original_selection_controls()
|
||
|
||
def _refresh_original_selection_controls(self):
|
||
checked_count = len(self.original_list.checked_asset_ids())
|
||
asset_count = len(self.original_list.asset_ids())
|
||
self.original_selected_label.setText("已选 %d 张" % checked_count)
|
||
state = self._displayed_state
|
||
enabled = bool(asset_count) and state is not None and not state.generation_running()
|
||
self.select_all_originals_button.setEnabled(enabled)
|
||
self.invert_originals_button.setEnabled(enabled)
|
||
|
||
def _original_assets(self, state, *, include_missing=False):
|
||
if state is None or state.project_id is None:
|
||
return []
|
||
try:
|
||
return image_studio.list_assets(
|
||
state.project_id,
|
||
kind=image_studio.ASSET_KIND_ORIGINAL,
|
||
include_missing=include_missing,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._status("商品原图读取失败:%s" % _user_error(exc), "danger")
|
||
return []
|
||
|
||
def choose_images(self, checked=False):
|
||
paths, _ = QFileDialog.getOpenFileNames(
|
||
self,
|
||
"选择商品原图",
|
||
"",
|
||
"图片文件 (*.png *.jpg *.jpeg *.webp *.bmp)",
|
||
)
|
||
if paths:
|
||
self.import_files(paths)
|
||
|
||
def import_files(self, paths):
|
||
self._start_import(file_paths=list(paths or []))
|
||
|
||
def import_clipboard_image(self, image_bytes):
|
||
self._start_import(image_bytes=image_bytes)
|
||
|
||
def _start_import(self, *, file_paths=None, image_bytes=None):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
if not self._commit_item_id(state):
|
||
return
|
||
self._save_controls_to_state(state)
|
||
state.import_created_draft = False
|
||
created_project = state.project_id is None
|
||
project = self._ensure_project_for_local_work(state)
|
||
if project is None:
|
||
return
|
||
state.import_created_draft = created_project and image_studio.is_draft_project(project)
|
||
if state.import_worker is not None:
|
||
self._status("当前任务正在添加图片,请稍候", "warning")
|
||
return
|
||
current_count = len(self._original_assets(state, include_missing=True))
|
||
if current_count >= image_studio_images.MAX_ORIGINAL_ASSETS:
|
||
self._message("图片数量已满", "每个商品最多添加16张原图。")
|
||
return
|
||
worker = ProductSuiteImportImagesWorker(
|
||
state.project_id,
|
||
file_paths=file_paths,
|
||
image_bytes=image_bytes,
|
||
db_path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
state.import_worker = worker
|
||
if state is self._displayed_state:
|
||
self._refresh_add_images_action(state)
|
||
worker.finished.connect(lambda result, state=state: self._on_import_finished(state, result))
|
||
worker.cancelled.connect(lambda result, state=state: self._on_import_finished(state, result))
|
||
worker.failed.connect(lambda row, error, state=state: self._on_import_failed(state, error))
|
||
state.import_thread = self._start_thread(worker, "商品套图添加原图")
|
||
self._status("正在后台添加商品原图", "info")
|
||
|
||
def _discard_empty_import_draft(self, state):
|
||
if not state.import_created_draft:
|
||
return
|
||
state.import_created_draft = False
|
||
project_id = state.project_id
|
||
if project_id is None:
|
||
return
|
||
try:
|
||
project = image_studio.discard_empty_draft_project(project_id, path=self.db_path)
|
||
except Exception as exc:
|
||
self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger")
|
||
return
|
||
if project is not None and project.deleted_at is not None:
|
||
state.project_id = None
|
||
state.project_binding_state = ""
|
||
state.item_id = ""
|
||
self._set_task_title(state)
|
||
self._status("未添加有效图片,已丢弃空临时草稿", "warning")
|
||
|
||
def _on_import_failed(self, state, error):
|
||
state.import_worker = None
|
||
state.import_thread = None
|
||
self._discard_empty_import_draft(state)
|
||
self._status("添加商品原图失败:%s" % _user_error(error), "danger")
|
||
if state is self._displayed_state:
|
||
self._refresh_add_images_action(state)
|
||
|
||
def _on_import_finished(self, state, result):
|
||
state.import_worker = None
|
||
state.import_thread = None
|
||
self._discard_empty_import_draft(state)
|
||
if result.get("ok") is False:
|
||
self._message("添加商品原图失败", _user_error(result.get("error")))
|
||
else:
|
||
errors = result.get("errors") or []
|
||
count = len(result.get("assets") or [])
|
||
if errors:
|
||
self._message(
|
||
"部分图片未添加",
|
||
"已添加%d张,%d张失败。\n%s" % (
|
||
count,
|
||
len(errors),
|
||
_user_error(errors[0].get("error")),
|
||
),
|
||
)
|
||
else:
|
||
self._status("已添加%d张商品原图" % count, "success")
|
||
if state is self._displayed_state:
|
||
self._refresh_originals(state)
|
||
|
||
def delete_original(self, asset_id):
|
||
self.delete_originals([asset_id])
|
||
|
||
def delete_originals(self, asset_ids):
|
||
state = self._displayed_state
|
||
if state is None or state.project_id is None:
|
||
self._status("当前商品没有可删除的原图", "warning")
|
||
return
|
||
if state.generation_running():
|
||
self._status("生成中不能删除当前任务的商品原图", "warning")
|
||
return
|
||
if state.pull_running():
|
||
self._status("拉取蝦皮主图时不能删除当前任务的商品原图", "warning")
|
||
return
|
||
normalized_ids = []
|
||
seen = set()
|
||
for value in asset_ids or []:
|
||
asset_id = int(value)
|
||
if asset_id not in seen:
|
||
seen.add(asset_id)
|
||
normalized_ids.append(asset_id)
|
||
if not normalized_ids:
|
||
self._status("请先勾选要删除的商品原图", "warning")
|
||
return
|
||
downloading = set(state.download_queue) | set(state.downloads)
|
||
if downloading.intersection(normalized_ids):
|
||
self._message("暂不能删除商品原图", "选中的图片仍在后台下载,请等待下载结束后再删除。")
|
||
return
|
||
visible_ids = [int(asset.id) for asset in self._original_assets(state, include_missing=False)]
|
||
message = (
|
||
"确认从当前商品原图列表移除选中的%d张图片吗?\n\n"
|
||
"此操作不会删除蝦皮线上图片,也不会删除本地源文件。"
|
||
"以后重新拉取蝦皮主图时,线上仍存在的图片可能重新出现。"
|
||
% len(normalized_ids)
|
||
)
|
||
if visible_ids and visible_ids[0] in normalized_ids:
|
||
message += "\n\n选中内容包含当前主图,删除后下一张图片将成为主图。"
|
||
if not self._confirm("删除商品原图", message, destructive=True):
|
||
return
|
||
try:
|
||
image_studio.remove_original_assets_if_unused(
|
||
state.project_id,
|
||
normalized_ids,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._message("不能删除商品原图", _user_error(exc))
|
||
return
|
||
self._refresh_originals(state, preserve_checks=False)
|
||
self._status("已移除%d张商品原图" % len(normalized_ids), "success")
|
||
|
||
def reorder_originals(self, visible_ids):
|
||
state = self._displayed_state
|
||
if state is None or state.project_id is None:
|
||
return
|
||
if state.generation_running():
|
||
self._refresh_originals(state)
|
||
self._status("生成中不能调整商品原图顺序", "warning")
|
||
return
|
||
if state.pull_running():
|
||
self._refresh_originals(state)
|
||
self._status("正在拉取蝦皮主图,完成后可调整图片顺序", "warning")
|
||
return
|
||
all_assets = self._original_assets(state, include_missing=True)
|
||
hidden_ids = [int(asset.id) for asset in all_assets if int(asset.id) not in set(visible_ids)]
|
||
ordered_ids = list(visible_ids) + hidden_ids
|
||
before_ids = [int(asset.id) for asset in all_assets]
|
||
if ordered_ids == before_ids:
|
||
self._refresh_originals(state)
|
||
return
|
||
try:
|
||
image_studio.reorder_original_assets(
|
||
state.project_id,
|
||
ordered_ids,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._message("原图排序失败", _user_error(exc))
|
||
else:
|
||
self._status("已更新商品原图顺序,第一张图片为主图", "success")
|
||
self._refresh_originals(state)
|
||
|
||
def _asset_for_item(self, item):
|
||
state = self._displayed_state
|
||
if state is None or item is None:
|
||
return None
|
||
value = item.data(Qt.UserRole)
|
||
if value is None:
|
||
return None
|
||
asset_id = int(value)
|
||
return next(
|
||
(asset for asset in self._original_assets(state, include_missing=True) if int(asset.id) == asset_id),
|
||
None,
|
||
)
|
||
|
||
def _on_original_clicked(self, item):
|
||
asset = self._asset_for_item(item)
|
||
if asset is not None and not _asset_usable(asset) and asset.remote_url:
|
||
self._queue_original_downloads(self._displayed_state, [asset])
|
||
|
||
def _preview_original(self, item):
|
||
asset = self._asset_for_item(item)
|
||
if asset is None:
|
||
return
|
||
if not _asset_usable(asset):
|
||
self._queue_original_downloads(self._displayed_state, [asset])
|
||
self._status("原图正在后台下载,完成后可双击预览", "info")
|
||
return
|
||
ProductSuitePreviewDialog(asset.local_path, "商品原图预览", self).exec()
|
||
|
||
def pull_main_images(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
if state.pull_running():
|
||
self._request_stop_pull(state)
|
||
return
|
||
if not self._commit_item_id(state):
|
||
return
|
||
self._save_controls_to_state(state)
|
||
if self._is_draft_state(state):
|
||
self._message(
|
||
"无法拉取蝦皮主图",
|
||
"当前为临时项目,无法拉取蝦皮主图。\n请先输入正式商品ID后再试。",
|
||
)
|
||
return
|
||
if not self._valid_context(state):
|
||
return
|
||
existing = [asset for asset in self._original_assets(state, include_missing=False) if _asset_usable(asset)]
|
||
message = (
|
||
"店铺:%s\n"
|
||
"商品ID:%s\n"
|
||
"当前可用商品原图:%d张\n\n"
|
||
"将读取蝦皮主图并在后台下载,不会修改蝦皮线上商品。"
|
||
% (
|
||
self._account_context_label(state),
|
||
state.item_id,
|
||
len(existing),
|
||
)
|
||
)
|
||
if existing:
|
||
message += "\n继续拉取会刷新蝦皮原图列表,本地手动添加图片会保留。"
|
||
if not self._confirm(
|
||
"确认拉取蝦皮主图",
|
||
message,
|
||
confirm_text="确认拉取",
|
||
cancel_text="取消",
|
||
):
|
||
return
|
||
if state.project_id is None:
|
||
project = image_studio.get_project_by_account_item(
|
||
state.account_alias,
|
||
state.item_id,
|
||
path=self.db_path,
|
||
)
|
||
if project is not None:
|
||
state.project_id = int(project.id)
|
||
state.project_binding_state = project.binding_state
|
||
before_assets = (
|
||
image_studio.list_assets(
|
||
state.project_id,
|
||
kind=image_studio.ASSET_KIND_ORIGINAL,
|
||
path=self.db_path,
|
||
)
|
||
if state.project_id is not None
|
||
else []
|
||
)
|
||
pull_run_token = uuid.uuid4().hex
|
||
worker = ImageStudioPullImagesWorker(
|
||
state.account_alias,
|
||
state.item_id,
|
||
pull_run_token=pull_run_token,
|
||
db_path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
state.pull_worker = worker
|
||
state.pull_run_token = pull_run_token
|
||
state.pull_stop_requested = False
|
||
state.pull_cleanup_mode = "keep"
|
||
state.pull_before_asset_ids = {int(asset.id) for asset in before_assets}
|
||
state.pull_before_asset_states = [
|
||
{
|
||
"id": int(asset.id),
|
||
"status": asset.status,
|
||
"source_order": int(asset.source_order or 0),
|
||
}
|
||
for asset in before_assets
|
||
]
|
||
state.pull_asset_ids = set()
|
||
state.pull_download_asset_ids = set()
|
||
state.pull_download_failed = 0
|
||
state.pull_started_at = time.monotonic()
|
||
self._pull_run_states[pull_run_token] = state.key
|
||
self._log_pull_lifecycle(
|
||
state,
|
||
pull_run_token,
|
||
"started",
|
||
{
|
||
"before_count": len(state.pull_before_asset_ids),
|
||
},
|
||
)
|
||
worker.finished.connect(
|
||
lambda result, token=pull_run_token: self._on_pull_finished(
|
||
token,
|
||
result,
|
||
)
|
||
)
|
||
worker.cancelled.connect(
|
||
lambda result, token=pull_run_token: self._on_pull_finished(
|
||
token,
|
||
result,
|
||
)
|
||
)
|
||
worker.failed.connect(
|
||
lambda row, error, token=pull_run_token: self._on_pull_failed(
|
||
token,
|
||
error,
|
||
)
|
||
)
|
||
state.pull_thread = self._start_thread(worker, "商品套图拉取蝦皮主图")
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
self._status("开始拉取蝦皮主图,可继续操作其他套图任务", "info")
|
||
|
||
def _pull_state(self, pull_run_token):
|
||
token = str(pull_run_token or "")
|
||
state = self._states.get(self._pull_run_states.get(token))
|
||
if state is None or state.pull_run_token != token:
|
||
return None
|
||
return state
|
||
|
||
def _pull_stop_action(self):
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Question)
|
||
box.setWindowTitle("停止拉取蝦皮主图")
|
||
box.setText("请选择停止后如何处理本轮已经拉取的图片。")
|
||
keep_button = box.addButton("停止并保留", QMessageBox.AcceptRole)
|
||
clear_button = box.addButton(
|
||
"停止并清除本次新增",
|
||
QMessageBox.DestructiveRole,
|
||
)
|
||
continue_button = box.addButton("继续拉取", QMessageBox.RejectRole)
|
||
box.setDefaultButton(keep_button)
|
||
box.exec()
|
||
clicked = box.clickedButton()
|
||
if clicked is keep_button:
|
||
return "keep"
|
||
if clicked is clear_button:
|
||
return "clear_current"
|
||
if clicked is continue_button:
|
||
return "continue"
|
||
return "continue"
|
||
|
||
def _request_stop_pull(self, state):
|
||
if state.pull_stop_requested:
|
||
self._status("正在停止当前拉取任务", "warning")
|
||
return
|
||
action = self._pull_stop_action()
|
||
if action == "continue":
|
||
return
|
||
state.pull_stop_requested = True
|
||
state.pull_cleanup_mode = action
|
||
if state.pull_worker is not None:
|
||
state.pull_worker.cancel()
|
||
self._cancel_pull_downloads(state)
|
||
self._log_pull_lifecycle(
|
||
state,
|
||
state.pull_run_token,
|
||
"stop_requested",
|
||
{"cleanup_mode": action},
|
||
level="WARNING",
|
||
)
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
self._status("已请求停止拉取蝦皮主图", "warning")
|
||
self._maybe_finalize_pull(state, state.pull_run_token)
|
||
|
||
def _cancel_pull_downloads(self, state):
|
||
pull_ids = set(state.pull_download_asset_ids)
|
||
if not pull_ids:
|
||
return
|
||
state.download_queue = [
|
||
asset_id
|
||
for asset_id in state.download_queue
|
||
if int(asset_id) not in pull_ids
|
||
]
|
||
for asset_id in list(pull_ids):
|
||
running = state.downloads.get(int(asset_id))
|
||
if running is None:
|
||
state.pull_download_asset_ids.discard(int(asset_id))
|
||
state.download_tokens.pop(int(asset_id), None)
|
||
continue
|
||
worker, _ = running
|
||
worker.cancel()
|
||
|
||
def _on_pull_failed(self, pull_run_token, error):
|
||
state = self._pull_state(pull_run_token)
|
||
if state is None or state.pull_stop_requested:
|
||
return
|
||
self._status("拉取蝦皮主图失败:%s" % _user_error(error), "danger")
|
||
|
||
def _on_pull_finished(self, pull_run_token, result):
|
||
state = self._pull_state(pull_run_token)
|
||
if state is None:
|
||
return
|
||
state.pull_worker = None
|
||
state.pull_thread = None
|
||
result = dict(result or {})
|
||
if result.get("ok") is False and not state.pull_stop_requested:
|
||
self._finalize_pull(
|
||
state,
|
||
pull_run_token,
|
||
error=_user_error(result.get("error")),
|
||
)
|
||
return
|
||
project = result.get("project")
|
||
if project is not None:
|
||
state.project_id = int(project.id)
|
||
state.project_binding_state = project.binding_state
|
||
assets = [
|
||
asset
|
||
for asset in (result.get("assets") or [])
|
||
if asset.status != image_studio.ASSET_STATUS_MISSING
|
||
][: image_studio_images.MAX_ORIGINAL_ASSETS]
|
||
state.pull_asset_ids.update(
|
||
int(asset.id)
|
||
for asset in assets
|
||
if getattr(asset, "remote_url", None)
|
||
)
|
||
if result.get("cancelled") or state.pull_stop_requested:
|
||
self._cancel_pull_downloads(state)
|
||
else:
|
||
self._queue_original_downloads(
|
||
state,
|
||
assets,
|
||
pull_run_token=pull_run_token,
|
||
)
|
||
self._status("已读取%d张蝦皮主图,正在后台下载" % len(assets), "success")
|
||
if state is self._displayed_state:
|
||
self._refresh_originals(state)
|
||
self._apply_running_state(state)
|
||
self._maybe_finalize_pull(state, pull_run_token)
|
||
|
||
@Slot()
|
||
def _on_pull_thread_finished_signal(self):
|
||
sender = self.sender()
|
||
token = str(
|
||
sender.property("productSuitePullRunToken")
|
||
if sender is not None
|
||
else ""
|
||
)
|
||
QTimer.singleShot(0, lambda token=token: self._handle_pull_thread_finished(token))
|
||
|
||
def _handle_pull_thread_finished(self, pull_run_token):
|
||
state = self._pull_state(pull_run_token)
|
||
if state is None or state.pull_worker is None:
|
||
return
|
||
state.pull_worker = None
|
||
state.pull_thread = None
|
||
if state.pull_stop_requested:
|
||
self._maybe_finalize_pull(state, pull_run_token)
|
||
else:
|
||
self._finalize_pull(
|
||
state,
|
||
pull_run_token,
|
||
error="拉取线程已结束,请稍后重试",
|
||
)
|
||
|
||
def _maybe_finalize_pull(self, state, pull_run_token):
|
||
if self._pull_state(pull_run_token) is not state:
|
||
return False
|
||
if state.pull_worker is not None or state.pull_download_asset_ids:
|
||
return False
|
||
return self._finalize_pull(state, pull_run_token)
|
||
|
||
def _clear_current_pull_assets(self, state):
|
||
new_ids = sorted(
|
||
set(state.pull_asset_ids) - set(state.pull_before_asset_ids)
|
||
)
|
||
removable = []
|
||
retained = 0
|
||
for asset_id in new_ids:
|
||
asset = image_studio.get_asset(asset_id, path=self.db_path)
|
||
if (
|
||
asset is None
|
||
or int(asset.project_id) != int(state.project_id or 0)
|
||
or asset.kind != image_studio.ASSET_KIND_ORIGINAL
|
||
or not asset.remote_url
|
||
):
|
||
continue
|
||
if image_studio.asset_reference_counts(
|
||
asset.id,
|
||
path=self.db_path,
|
||
)["total"]:
|
||
retained += 1
|
||
else:
|
||
removable.append(asset.id)
|
||
removed = 0
|
||
if removable:
|
||
image_studio.remove_original_assets_if_unused(
|
||
state.project_id,
|
||
removable,
|
||
path=self.db_path,
|
||
)
|
||
removed = len(removable)
|
||
existing_ids = {
|
||
int(asset.id)
|
||
for asset in image_studio.list_assets(
|
||
state.project_id,
|
||
kind=image_studio.ASSET_KIND_ORIGINAL,
|
||
path=self.db_path,
|
||
)
|
||
}
|
||
snapshot = [
|
||
item
|
||
for item in state.pull_before_asset_states
|
||
if int(item["id"]) in existing_ids
|
||
]
|
||
if snapshot:
|
||
image_studio.restore_original_asset_snapshot(
|
||
state.project_id,
|
||
snapshot,
|
||
path=self.db_path,
|
||
)
|
||
return removed, retained
|
||
|
||
def _finalize_pull(self, state, pull_run_token, *, error=""):
|
||
if self._pull_state(pull_run_token) is not state:
|
||
return False
|
||
cancelled = state.pull_stop_requested
|
||
cleanup_mode = state.pull_cleanup_mode
|
||
pulled_count = len(state.pull_asset_ids)
|
||
new_count = len(set(state.pull_asset_ids) - set(state.pull_before_asset_ids))
|
||
removed = 0
|
||
retained = 0
|
||
cleanup_error = ""
|
||
if cancelled and cleanup_mode == "clear_current" and state.project_id is not None:
|
||
try:
|
||
removed, retained = self._clear_current_pull_assets(state)
|
||
except Exception as exc:
|
||
cleanup_error = _user_error(exc)
|
||
elapsed = int(
|
||
max(0, time.monotonic() - state.pull_started_at)
|
||
if state.pull_started_at
|
||
else 0
|
||
)
|
||
self._pull_run_states.pop(pull_run_token, None)
|
||
state.pull_worker = None
|
||
state.pull_thread = None
|
||
state.pull_run_token = ""
|
||
state.pull_stop_requested = False
|
||
state.pull_cleanup_mode = "keep"
|
||
state.pull_before_asset_ids = set()
|
||
state.pull_before_asset_states = []
|
||
state.pull_asset_ids = set()
|
||
state.pull_download_asset_ids = set()
|
||
failed = int(state.pull_download_failed or 0)
|
||
self._log_pull_lifecycle(
|
||
state,
|
||
pull_run_token,
|
||
"finalized",
|
||
{
|
||
"cancelled": cancelled,
|
||
"cleanup_mode": cleanup_mode,
|
||
"pulled_count": pulled_count,
|
||
"new_count": new_count,
|
||
"removed_count": removed,
|
||
"retained_count": retained,
|
||
"failed_count": failed,
|
||
"elapsed_seconds": elapsed,
|
||
"has_error": bool(error or cleanup_error),
|
||
},
|
||
level="WARNING" if cancelled or error or cleanup_error else "INFO",
|
||
)
|
||
state.pull_download_failed = 0
|
||
state.pull_started_at = None
|
||
if state is self._displayed_state:
|
||
self._refresh_originals(state)
|
||
self._apply_running_state(state)
|
||
if cleanup_error:
|
||
self._message(
|
||
"停止拉取后清理失败",
|
||
"拉取已停止,但本轮图片清理失败:%s" % cleanup_error,
|
||
)
|
||
self._status("拉取已停止,本轮图片清理失败", "danger")
|
||
return True
|
||
if error:
|
||
if state is self._displayed_state:
|
||
self._message("拉取蝦皮主图失败", error)
|
||
self._status("拉取蝦皮主图失败:%s" % error, "danger")
|
||
return True
|
||
if cancelled:
|
||
kept = max(0, new_count - removed)
|
||
if state is self._displayed_state:
|
||
self._message(
|
||
"拉取蝦皮主图已停止",
|
||
"本轮新增%d张:保留%d张,清理%d张,"
|
||
"因引用保留%d张,下载失败%d张;总用时%d秒。"
|
||
% (
|
||
new_count,
|
||
kept,
|
||
removed,
|
||
retained,
|
||
failed,
|
||
elapsed,
|
||
),
|
||
icon=QMessageBox.Information,
|
||
)
|
||
self._status(
|
||
"拉取已停止:保留%d张,清理%d张" % (kept, removed),
|
||
"warning",
|
||
)
|
||
return True
|
||
self._status(
|
||
"蝦皮主图拉取完成:本轮%d张,下载失败%d张,用时%d秒"
|
||
% (pulled_count, failed, elapsed),
|
||
"success" if not failed else "warning",
|
||
)
|
||
return True
|
||
|
||
def _log_pull_lifecycle(
|
||
self,
|
||
state,
|
||
pull_run_token,
|
||
event,
|
||
payload=None,
|
||
*,
|
||
level="INFO",
|
||
):
|
||
data = {
|
||
"pull_run_token": str(pull_run_token or "")[:8],
|
||
"project_id": getattr(state, "project_id", None),
|
||
"event": str(event or ""),
|
||
}
|
||
data.update(dict(payload or {}))
|
||
try:
|
||
diagnostics.write_diagnostic_log(
|
||
"商品套图拉取生命周期",
|
||
level=level,
|
||
step="product_suite_pull",
|
||
task_id=getattr(state, "project_id", None),
|
||
item_id=getattr(state, "item_id", None),
|
||
payload=data,
|
||
log_dir=appconfig.diagnostic_log_dir(self.config),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _queue_original_downloads(self, state, assets, *, pull_run_token=None):
|
||
if state is None:
|
||
return
|
||
queued = set(state.download_queue) | set(state.downloads)
|
||
for asset in assets or []:
|
||
if _asset_usable(asset) or not getattr(asset, "remote_url", None):
|
||
continue
|
||
asset_id = int(asset.id)
|
||
if asset_id not in queued:
|
||
state.download_queue.append(asset_id)
|
||
state.download_tokens[asset_id] = str(pull_run_token or "")
|
||
if pull_run_token:
|
||
state.pull_download_asset_ids.add(asset_id)
|
||
queued.add(asset_id)
|
||
self._start_queued_downloads(state)
|
||
|
||
def _start_queued_downloads(self, state):
|
||
while state.download_queue and len(state.downloads) < ORIGINAL_DOWNLOAD_CONCURRENCY:
|
||
asset_id = int(state.download_queue.pop(0))
|
||
worker = ImageStudioDownloadOriginalWorker(
|
||
asset_id,
|
||
db_path=self.db_path,
|
||
config=self.config,
|
||
max_retries=2,
|
||
)
|
||
download_token = str(state.download_tokens.get(asset_id, "") or "")
|
||
worker.finished.connect(
|
||
lambda result, state=state, asset_id=asset_id, token=download_token: self._on_download_finished(
|
||
state, asset_id, result, pull_run_token=token
|
||
)
|
||
)
|
||
worker.cancelled.connect(
|
||
lambda result, state=state, asset_id=asset_id, token=download_token: self._on_download_finished(
|
||
state, asset_id, result, pull_run_token=token
|
||
)
|
||
)
|
||
worker.failed.connect(
|
||
lambda row, error, state=state, asset_id=asset_id: self._status(
|
||
"商品原图 #%d 下载失败:%s" % (asset_id, _user_error(error)),
|
||
"danger",
|
||
)
|
||
)
|
||
thread = self._start_thread(worker, "商品套图下载原图")
|
||
state.downloads[asset_id] = (worker, thread)
|
||
|
||
def _on_download_finished(
|
||
self,
|
||
state,
|
||
asset_id,
|
||
result,
|
||
*,
|
||
pull_run_token="",
|
||
):
|
||
expected_token = str(state.download_tokens.get(int(asset_id), "") or "")
|
||
if expected_token != str(pull_run_token or ""):
|
||
return
|
||
state.downloads.pop(int(asset_id), None)
|
||
state.download_tokens.pop(int(asset_id), None)
|
||
if pull_run_token:
|
||
state.pull_download_asset_ids.discard(int(asset_id))
|
||
if result.get("ok") is False:
|
||
if pull_run_token:
|
||
state.pull_download_failed += 1
|
||
self._status(
|
||
"商品原图 #%d 下载失败:%s" % (asset_id, _user_error(result.get("error"))),
|
||
"danger",
|
||
)
|
||
elif not result.get("cancelled"):
|
||
self._status("商品原图 #%d 已下载" % asset_id, "success")
|
||
if state is self._displayed_state:
|
||
self._refresh_originals(state)
|
||
self._start_queued_downloads(state)
|
||
if pull_run_token:
|
||
self._maybe_finalize_pull(state, pull_run_token)
|
||
|
||
def _rebuild_categories(self, state):
|
||
while self.category_grid.count():
|
||
item = self.category_grid.takeAt(0)
|
||
widget = item.widget()
|
||
if widget is not None:
|
||
widget.deleteLater()
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
state.settings = settings
|
||
categories = settings["categories"]
|
||
ordered = product_suite.category_order(settings)
|
||
self.category_rows = {}
|
||
for index, name in enumerate(ordered):
|
||
custom = name not in product_suite.FIXED_CATEGORIES
|
||
row = SuiteCategoryRow(
|
||
name,
|
||
categories.get(name, 0),
|
||
helper=product_suite.category_helper(name),
|
||
custom=custom,
|
||
)
|
||
row.countChangeRequested.connect(self.change_category_count)
|
||
if custom:
|
||
row.renameRequested.connect(self.rename_custom_category)
|
||
row.deleteRequested.connect(self.delete_custom_category)
|
||
row.set_controls_enabled(not state.generation_running())
|
||
self.category_rows[name] = row
|
||
self.category_grid.addWidget(row, index, 0)
|
||
self._refresh_totals(state)
|
||
|
||
def change_category_count(self, name, delta):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
categories = dict(settings["categories"])
|
||
categories[name] = max(0, int(categories.get(name, 0)) + int(delta))
|
||
settings["categories"] = categories
|
||
state.settings = settings
|
||
self._persist_state(state)
|
||
row = self.category_rows.get(name) if state is self._displayed_state else None
|
||
if row is None:
|
||
self._rebuild_categories(state)
|
||
else:
|
||
row.set_count(categories[name])
|
||
self._refresh_totals(state)
|
||
|
||
def add_custom_category(self, checked=False):
|
||
if self._displayed_state is None:
|
||
return
|
||
self.add_category_button.hide()
|
||
self.custom_category_edit.clear()
|
||
self.custom_category_edit.show()
|
||
self.custom_category_edit.setFocus(Qt.OtherFocusReason)
|
||
|
||
def _commit_custom_category(self):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
name = self.custom_category_edit.text()
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
error = product_suite.suite_name_error(name, settings["categories"])
|
||
if error:
|
||
self._message("分类名称无效", error)
|
||
self.custom_category_edit.setFocus(Qt.OtherFocusReason)
|
||
return
|
||
categories = dict(settings["categories"])
|
||
categories[name] = 1
|
||
settings["categories"] = categories
|
||
settings["custom_category_order"] = list(settings["custom_category_order"]) + [name]
|
||
state.settings = product_suite.normalize_suite_settings(settings)
|
||
self._persist_state(state)
|
||
self.custom_category_edit.hide()
|
||
self.add_category_button.show()
|
||
self._rebuild_categories(state)
|
||
|
||
def _finish_custom_category_edit(self):
|
||
if not self.custom_category_edit.isHidden() and not self.custom_category_edit.text().strip():
|
||
self.custom_category_edit.hide()
|
||
self.add_category_button.show()
|
||
|
||
def rename_custom_category(self, old_name):
|
||
state = self._displayed_state
|
||
if state is None or old_name in product_suite.FIXED_CATEGORIES:
|
||
return
|
||
name, ok = QInputDialog.getText(
|
||
self,
|
||
"重命名自定义分类",
|
||
"分类名称",
|
||
text=old_name,
|
||
)
|
||
if not ok or name == old_name:
|
||
return
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
error = product_suite.suite_name_error(name, settings["categories"], old_name=old_name)
|
||
if error:
|
||
self._message("分类名称无效", error)
|
||
return
|
||
categories = dict(settings["categories"])
|
||
count = categories.pop(old_name, 1)
|
||
categories[name] = count
|
||
settings["categories"] = categories
|
||
settings["custom_category_order"] = [
|
||
name if value == old_name else value
|
||
for value in settings["custom_category_order"]
|
||
]
|
||
state.settings = product_suite.normalize_suite_settings(settings)
|
||
self._persist_state(state)
|
||
self._rebuild_categories(state)
|
||
|
||
def delete_custom_category(self, name):
|
||
state = self._displayed_state
|
||
if state is None or name in product_suite.FIXED_CATEGORIES:
|
||
return
|
||
settings = product_suite.normalize_suite_settings(state.settings)
|
||
categories = dict(settings["categories"])
|
||
categories.pop(name, None)
|
||
settings["categories"] = categories
|
||
settings["custom_category_order"] = [
|
||
value for value in settings["custom_category_order"] if value != name
|
||
]
|
||
state.settings = product_suite.normalize_suite_settings(settings)
|
||
self._persist_state(state)
|
||
self._rebuild_categories(state)
|
||
|
||
def _refresh_totals(self, state):
|
||
image_count = sum(1 for asset in self._original_assets(state) if _asset_usable(asset))
|
||
total = product_suite.suite_total_count(state.settings, image_count)
|
||
self.category_total_label.setText("合计 %d 张" % total)
|
||
if not state.generation_running():
|
||
self.generate_button.setText("生成套图(%d)" % total)
|
||
|
||
def _is_default_gateway(self):
|
||
try:
|
||
return appconfig.ai_backend(self.config) == "cmhub"
|
||
except Exception:
|
||
return False
|
||
|
||
def _is_direct_gateway(self):
|
||
try:
|
||
return appconfig.ai_backend(self.config) == "direct"
|
||
except Exception:
|
||
return False
|
||
|
||
def _direct_generation_config_error(self):
|
||
"""Return a user-safe direct image-model preflight error, if any."""
|
||
|
||
try:
|
||
ai_config = appconfig.ai_config(self.config)
|
||
model_name = str(ai_config.get("default_image_model") or "").strip()
|
||
if not model_name:
|
||
return "尚未选择图片模型"
|
||
model = appconfig.get_model(
|
||
model_name,
|
||
path=appconfig.ai_models_config_path(self.config),
|
||
)
|
||
except Exception:
|
||
return "当前图片模型不存在或无法读取"
|
||
if not model.get("enabled", True):
|
||
return "当前图片模型未启用"
|
||
return appconfig.image_model_config_error(model)
|
||
|
||
def _ensure_generation_gateway(self):
|
||
if self._is_default_gateway():
|
||
return True
|
||
if self._is_direct_gateway():
|
||
error = self._direct_generation_config_error()
|
||
if not error:
|
||
return True
|
||
self._message(
|
||
"自定义网关配置不完整",
|
||
"商品套图无法生成:%s。\n请到⑤设置补齐并保存图片模型配置。" % error,
|
||
)
|
||
return False
|
||
self._message("生成网关不可用", "请到⑤设置选择默认网关或配置自定义网关。")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _has_default_gateway_source(job):
|
||
return (
|
||
str(getattr(job, "generation_source", "") or "").strip().lower() == "cmhub"
|
||
and str(getattr(job, "provider", "") or "").strip().lower() == "cmhub"
|
||
)
|
||
|
||
@classmethod
|
||
def _is_default_gateway_job(cls, job):
|
||
return cls._has_default_gateway_source(job) and bool(
|
||
str(getattr(job, "task_id", "") or "").strip()
|
||
)
|
||
|
||
@staticmethod
|
||
def _is_direct_gateway_job(job):
|
||
return (
|
||
str(getattr(job, "generation_source", "") or "").strip().lower()
|
||
== image_studio.GENERATION_SOURCE_DIRECT
|
||
and str(getattr(job, "provider", "") or "").strip().lower()
|
||
== image_studio.PROVIDER_OPENAI_IMAGES_EDITS
|
||
)
|
||
|
||
def _retry_availability(self, job):
|
||
if self._has_default_gateway_source(job):
|
||
if self._is_default_gateway():
|
||
return True, ""
|
||
return False, "默认网关图片仅能在⑤设置切换为默认网关后重新生成"
|
||
if self._is_direct_gateway_job(job):
|
||
if not self._is_direct_gateway():
|
||
return False, "自定义网关图片仅能在⑤设置切换为自定义网关后重新生成"
|
||
error = self._direct_generation_config_error()
|
||
if error:
|
||
return False, "自定义网关配置不完整:%s" % error
|
||
return True, ""
|
||
return False, "当前图片的生成来源无法确认,不能重新生成"
|
||
|
||
def _resumable_default_gateway_jobs(self, state):
|
||
if state is None or state.project_id is None:
|
||
return []
|
||
try:
|
||
jobs = image_studio.list_resumable_jobs(
|
||
project_id=state.project_id,
|
||
include_failed_downloads=True,
|
||
path=self.db_path,
|
||
)
|
||
except Exception:
|
||
return []
|
||
return [job for job in jobs if self._is_default_gateway_job(job)]
|
||
|
||
def refresh_gateway_state(self):
|
||
state = self._displayed_state
|
||
if state is not None:
|
||
self._apply_running_state(state)
|
||
self._refresh_results(state)
|
||
|
||
def _update_resume_submitted_action(self, state):
|
||
jobs = self._resumable_default_gateway_jobs(state)
|
||
self.resume_submitted_button.setVisible(bool(jobs))
|
||
self.resume_submitted_button.setEnabled(
|
||
bool(jobs)
|
||
and state.resume_worker is None
|
||
and not state.generation_running()
|
||
and state.generation_price_worker is None
|
||
)
|
||
|
||
def resume_submitted_jobs(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None or state.project_id is None:
|
||
return
|
||
if state.resume_worker is not None:
|
||
self._status("正在继续查询已提交图片", "info")
|
||
return
|
||
jobs = self._resumable_default_gateway_jobs(state)
|
||
if not jobs:
|
||
self._status("当前没有可继续查询的默认网关图片", "info")
|
||
self._update_resume_submitted_action(state)
|
||
return
|
||
worker = ImageStudioResumeJobsWorker(
|
||
project_id=state.project_id,
|
||
aspect_ratio=state.settings.get("ratio", "1:1"),
|
||
db_path=self.db_path,
|
||
config=self.config,
|
||
cmhub_config_path=self.cmhub_config_path,
|
||
)
|
||
state.resume_worker = worker
|
||
worker.finished.connect(
|
||
lambda result, state=state: self._on_resume_submitted_finished(state, result)
|
||
)
|
||
worker.cancelled.connect(
|
||
lambda result, state=state: self._on_resume_submitted_finished(state, result)
|
||
)
|
||
state.resume_thread = self._start_thread(worker, "商品套图继续查询")
|
||
self._apply_running_state(state)
|
||
self._status("开始继续查询%d张已提交图片,不会重新生成或再次扣点" % len(jobs), "info")
|
||
|
||
def _on_resume_submitted_finished(self, state, result):
|
||
state.resume_worker = None
|
||
state.resume_thread = None
|
||
self._refresh_results(state)
|
||
self._update_resume_submitted_action(state)
|
||
if result.get("ok") is False:
|
||
error = str(result.get("error") or "")
|
||
if "cmhub_not_configured" in error or "缺少" in error:
|
||
self._message(
|
||
"继续查询失败",
|
||
"默认网关配置不可用,请恢复原默认网关配置后继续查询已提交图片。",
|
||
)
|
||
else:
|
||
self._message("继续查询失败", _user_error(error))
|
||
return
|
||
self._status("已完成已提交图片查询,请查看生成结果", "success")
|
||
|
||
def _require_default_gateway(self, action):
|
||
if self._is_default_gateway():
|
||
return True
|
||
self._message("当前不可用", "%s仅支持默认网关,请到⑤设置切换后再使用。" % action)
|
||
return False
|
||
|
||
def start_ai_write(self, checked=False):
|
||
if not self._require_default_gateway("商品套图AI帮写"):
|
||
return
|
||
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("开始 AI 帮写"):
|
||
return
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
if state.ai_confirmation_open:
|
||
self._status("正在等待AI帮写确认", "info")
|
||
return
|
||
if state.ai_worker is not None:
|
||
self._status("当前套图任务正在AI帮写", "info")
|
||
return
|
||
if state.ai_price_worker is not None:
|
||
self._status("正在读取图片理解预计扣点", "info")
|
||
return
|
||
selected_assets = self._ai_write_selected_assets(state)
|
||
if not selected_assets:
|
||
self._message("缺少可用商品原图", "请先添加商品原图,或等待已拉取的商品原图下载完成。")
|
||
return
|
||
self._save_controls_to_state(state)
|
||
selected_asset_ids = tuple(int(asset.id) for asset in selected_assets)
|
||
catalog_params = self._ai_write_catalog_params()
|
||
cached_models = (
|
||
cmhub_models.cached_model_catalog(catalog_params["base_url"], catalog_params["alias"])
|
||
if catalog_params is not None
|
||
else None
|
||
)
|
||
if cached_models is not None:
|
||
self._confirm_ai_write_request(
|
||
state,
|
||
selected_asset_ids,
|
||
self._ai_write_points_cost(cached_models, catalog_params["alias"]),
|
||
)
|
||
return
|
||
if catalog_params is None:
|
||
self._confirm_ai_write_request(state, selected_asset_ids, None)
|
||
return
|
||
self._start_ai_write_catalog_lookup(state, selected_asset_ids, catalog_params)
|
||
|
||
def _ai_write_selected_assets(self, state):
|
||
return [
|
||
asset
|
||
for asset in self._original_assets(state)
|
||
if _asset_usable(asset)
|
||
][: ai.CMHUB_VISION_MAX_IMAGES]
|
||
|
||
def _ai_write_catalog_params(self):
|
||
return self._cmhub_catalog_params("vision_alias")
|
||
|
||
def _cmhub_catalog_params(self, alias_key):
|
||
try:
|
||
cmhub_config = appconfig.cmhub_config(self.config)
|
||
base_url = appconfig.normalize_cmhub_base_url(cmhub_config.get("base_url"))
|
||
alias = str(cmhub_config.get(alias_key) or "").strip()
|
||
api_key = appconfig.get_cmhub_api_key(self.cmhub_config_path)
|
||
except Exception:
|
||
return None
|
||
if not base_url or not alias or not api_key:
|
||
return None
|
||
return {
|
||
"base_url": base_url,
|
||
"alias": alias,
|
||
"api_key": api_key,
|
||
"connect_timeout": cmhub_config.get("connect_timeout", 10),
|
||
"use_system_proxy": bool(appconfig.ai_config(self.config).get("use_system_proxy")),
|
||
}
|
||
|
||
@staticmethod
|
||
def _ai_write_points_cost(models, alias):
|
||
return cmhub_models.unambiguous_points_cost(
|
||
models,
|
||
alias,
|
||
"vision",
|
||
requires_image=True,
|
||
)
|
||
|
||
def _start_ai_write_catalog_lookup(self, state, selected_asset_ids, params):
|
||
worker = CMHubModelCatalogWorker(
|
||
params["base_url"],
|
||
params["api_key"],
|
||
connect_timeout=params["connect_timeout"],
|
||
use_system_proxy=params["use_system_proxy"],
|
||
)
|
||
state.ai_price_worker = worker
|
||
worker.finished.connect(
|
||
lambda result, state=state, asset_ids=selected_asset_ids, alias=params["alias"]:
|
||
self._on_ai_write_catalog_finished(state, asset_ids, alias, result)
|
||
)
|
||
worker.cancelled.connect(
|
||
lambda result, state=state: self._on_ai_write_catalog_cancelled(state, result)
|
||
)
|
||
state.ai_price_thread = self._start_thread(worker, "商品套图读取扣点")
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
self._status("正在读取图片理解预计扣点", "info")
|
||
|
||
def _on_ai_write_catalog_finished(self, state, selected_asset_ids, alias, result):
|
||
state.ai_price_worker = None
|
||
state.ai_price_thread = None
|
||
if not self._is_open_suite_state(state):
|
||
return
|
||
if result.get("ok") is False:
|
||
self._status("暂时无法取得图片理解预计扣点,实际以网关返回为准", "warning")
|
||
self._confirm_ai_write_request(state, selected_asset_ids, None)
|
||
elif not result.get("cancelled"):
|
||
self._confirm_ai_write_request(
|
||
state,
|
||
selected_asset_ids,
|
||
self._ai_write_points_cost(result.get("models") or [], alias),
|
||
)
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
|
||
def _on_ai_write_catalog_cancelled(self, state, result):
|
||
state.ai_price_worker = None
|
||
state.ai_price_thread = None
|
||
if self._is_open_suite_state(state):
|
||
self._status("已取消读取图片理解预计扣点", "warning")
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
|
||
def _is_open_suite_state(self, state):
|
||
return self._states.get(getattr(state, "key", None)) is state
|
||
|
||
def _confirm_ai_write_request(self, state, selected_asset_ids, points_cost):
|
||
if not self._is_open_suite_state(state) or state.ai_worker is not None:
|
||
return
|
||
current_asset_ids = tuple(int(asset.id) for asset in self._ai_write_selected_assets(state))
|
||
if current_asset_ids != tuple(selected_asset_ids):
|
||
self._status("商品原图已变化,请重新点击AI帮写", "warning")
|
||
return
|
||
message = self._ai_write_confirmation_message(len(current_asset_ids), points_cost)
|
||
state.ai_confirmation_open = True
|
||
accepted = False
|
||
try:
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Question)
|
||
box.setWindowTitle("开始AI帮写")
|
||
box.setText(message)
|
||
start_button = box.addButton("开始AI帮写", QMessageBox.AcceptRole)
|
||
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||
box.setDefaultButton(cancel_button)
|
||
box.setEscapeButton(cancel_button)
|
||
box.exec()
|
||
accepted = box.clickedButton() is start_button
|
||
finally:
|
||
state.ai_confirmation_open = False
|
||
if not accepted:
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
return
|
||
self._start_confirmed_ai_write(state, selected_asset_ids)
|
||
|
||
@staticmethod
|
||
def _ai_write_confirmation_message(image_count, points_cost):
|
||
lines = [
|
||
"本次会理解当前商品前%d张可用商品原图(最多%d张),并生成商品卖点与要求。"
|
||
% (int(image_count), ai.CMHUB_VISION_MAX_IMAGES),
|
||
]
|
||
price_text = cmhub_models.format_points_cost(points_cost)
|
||
if price_text:
|
||
lines.append("预计扣点:%s 点,实际以网关返回为准。" % price_text)
|
||
else:
|
||
lines.append("暂时无法取得预计扣点,实际以网关返回为准。")
|
||
lines.append("开始后可取消本地等待;请求已提交到网关时,仍可能产生扣点。")
|
||
return "\n".join(lines)
|
||
|
||
def _start_confirmed_ai_write(self, state, selected_asset_ids):
|
||
if not self._is_open_suite_state(state) or state.ai_worker is not None:
|
||
return
|
||
selected_assets = self._ai_write_selected_assets(state)
|
||
if tuple(int(asset.id) for asset in selected_assets) != tuple(selected_asset_ids):
|
||
self._status("商品原图已变化,请重新点击AI帮写", "warning")
|
||
return
|
||
context = (
|
||
"商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s"
|
||
% (
|
||
state.item_id or "未绑定商品",
|
||
state.settings["platform"],
|
||
state.settings["country"],
|
||
state.settings["language"],
|
||
state.prompt or "无",
|
||
)
|
||
)
|
||
instruction = (
|
||
"请为当前商品原图生成简洁、可执行的商品卖点与画面要求。"
|
||
"内容应包含核心卖点、目标人群、使用场景和禁用元素,不要虚构商品参数。"
|
||
)
|
||
state.ai_prompt_snapshot = state.prompt
|
||
state.ai_started_at = time.monotonic()
|
||
worker = ProductSuiteAiWriteWorker(
|
||
instruction,
|
||
context,
|
||
image_paths=[asset.local_path for asset in selected_assets],
|
||
config=self.config,
|
||
cmhub_config_path=self.cmhub_config_path,
|
||
)
|
||
state.ai_worker = worker
|
||
worker.finished.connect(lambda result, state=state: self._on_ai_write_finished(state, result))
|
||
worker.cancelled.connect(lambda result, state=state: self._on_ai_write_finished(state, result))
|
||
worker.failed.connect(lambda row, error, state=state: self._on_ai_write_failed(state, error))
|
||
state.ai_thread = self._start_thread(worker, "商品套图AI帮写")
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
message = "AI帮写已开始,可切换到其他套图任务"
|
||
if len([asset for asset in self._original_assets(state) if _asset_usable(asset)]) > ai.CMHUB_VISION_MAX_IMAGES:
|
||
message += ";已使用前%d张商品原图进行理解" % ai.CMHUB_VISION_MAX_IMAGES
|
||
self._status(message, "info")
|
||
|
||
def cancel_ai_write(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is not None and state.ai_price_worker is not None:
|
||
state.ai_price_worker.cancel()
|
||
self._status("已请求取消读取图片理解预计扣点", "warning")
|
||
elif state is not None and state.ai_worker is not None:
|
||
state.ai_worker.cancel()
|
||
self._status("已请求取消AI帮写", "warning")
|
||
|
||
def _on_ai_write_failed(self, state, error):
|
||
self._status("AI帮写失败:%s" % _user_error(error), "danger")
|
||
|
||
def _on_ai_write_finished(self, state, result):
|
||
state.ai_worker = None
|
||
state.ai_thread = None
|
||
state.ai_started_at = None
|
||
if result.get("ok") is False:
|
||
self._message("AI帮写失败", _user_error(result.get("error")))
|
||
elif not result.get("cancelled"):
|
||
text = str(result.get("text") or "").strip()
|
||
if text:
|
||
if state.prompt != state.ai_prompt_snapshot:
|
||
state.pending_ai_text = text
|
||
if state is self._displayed_state:
|
||
self._offer_pending_ai_result(state)
|
||
else:
|
||
self._status(
|
||
self._ai_write_success_message(result)
|
||
+ ",切回对应套图任务后确认是否采用",
|
||
"success",
|
||
)
|
||
else:
|
||
state.prompt = text
|
||
self._persist_state(state)
|
||
if state is self._displayed_state:
|
||
self._loading = True
|
||
try:
|
||
self.prompt_edit.setPlainText(text)
|
||
finally:
|
||
self._loading = False
|
||
self._status(self._ai_write_success_message(result), "success")
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
|
||
def _ai_write_success_message(self, result):
|
||
image_count = max(0, int(dict(result or {}).get("image_count", 0) or 0))
|
||
parts = ["AI帮写完成"]
|
||
if image_count:
|
||
parts.append("已理解%d张商品原图" % image_count)
|
||
metadata = dict(dict(result or {}).get("metadata") or {})
|
||
if metadata.get("points_cost") is not None:
|
||
parts.append("图片理解扣点%s" % metadata["points_cost"])
|
||
if metadata.get("points_balance") is not None:
|
||
parts.append("当前余额%s" % metadata["points_balance"])
|
||
return ":".join(parts[:2]) + ("," + ",".join(parts[2:]) if len(parts) > 2 else "")
|
||
|
||
def _offer_pending_ai_result(self, state):
|
||
if not state.pending_ai_text:
|
||
return
|
||
if self._confirm(
|
||
"采用AI帮写结果",
|
||
"AI帮写期间卖点内容已被修改。是否用AI帮写结果覆盖当前内容?",
|
||
):
|
||
state.prompt = state.pending_ai_text
|
||
self._loading = True
|
||
try:
|
||
self.prompt_edit.setPlainText(state.prompt)
|
||
finally:
|
||
self._loading = False
|
||
self._persist_state(state)
|
||
state.pending_ai_text = ""
|
||
|
||
def toggle_generation(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
if state.generation_price_worker is not None:
|
||
state.generation_price_worker.cancel()
|
||
self._status("已请求取消读取套图预计扣点", "warning")
|
||
return
|
||
if state.generation_confirmation_open:
|
||
self._status("正在等待套图生成确认", "info")
|
||
return
|
||
if state.generation_running():
|
||
retrying = state.generation_mode == "retry"
|
||
if state.generation_stop_requested:
|
||
self._status(
|
||
"正在停止当前图片重试"
|
||
if retrying
|
||
else "正在停止当前套图任务",
|
||
"warning",
|
||
)
|
||
return
|
||
if self._confirm(
|
||
"停止图片重试" if retrying else "停止生成套图",
|
||
(
|
||
"确认停止当前图片重试吗?已提交任务会在安全边界停止。"
|
||
if retrying
|
||
else "确认停止当前任务吗?正在请求的自定义网关图片无法立即撤销;如果图片已返回,程序会先保存,再结束后续任务。"
|
||
if state.generation_source == image_studio.GENERATION_SOURCE_DIRECT
|
||
else "确认取消当前任务吗?已提交任务会在安全边界停止。"
|
||
),
|
||
destructive=True,
|
||
):
|
||
state.generation_stop_requested = True
|
||
state.worker.cancel()
|
||
self._log_generation_lifecycle(
|
||
state,
|
||
state.generation_run_token,
|
||
"stop_requested",
|
||
)
|
||
self._apply_running_state(state)
|
||
self._status(
|
||
"已请求停止当前图片重试"
|
||
if retrying
|
||
else "正在停止,等待当前图片返回后结束"
|
||
if state.generation_source == image_studio.GENERATION_SOURCE_DIRECT
|
||
else "已请求停止当前套图任务",
|
||
"warning",
|
||
)
|
||
return
|
||
self.start_generation(state)
|
||
|
||
def start_generation(
|
||
self,
|
||
state,
|
||
specs=None,
|
||
*,
|
||
retry_job_id=None,
|
||
confirm_direct_retry=False,
|
||
):
|
||
if state is self._displayed_state:
|
||
if not self._commit_item_id(state):
|
||
return False
|
||
self._save_controls_to_state(state)
|
||
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("生成商品套图"):
|
||
return False
|
||
if not self._ensure_generation_gateway():
|
||
return False
|
||
if state.generation_running():
|
||
self._status("当前套图任务仍在生成", "warning")
|
||
return False
|
||
retry_job_id = int(retry_job_id) if retry_job_id is not None else None
|
||
retrying = retry_job_id is not None
|
||
confirm_batch = specs is None and not retrying
|
||
template_text = None
|
||
if specs is None:
|
||
try:
|
||
template_text = prompts.load_product_suite_prompt(
|
||
self.product_suite_prompt_path
|
||
)
|
||
except prompts.PromptError:
|
||
self._message(
|
||
"套图提示词模板无效",
|
||
"套图提示词模板无效,请在提示词设置中修复或恢复默认。",
|
||
)
|
||
return False
|
||
if self._ensure_project_for_local_work(state) is None:
|
||
return False
|
||
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
|
||
if not local_assets:
|
||
self._message("缺少商品原图", "请先添加图片,或等待蝦皮主图下载完成。")
|
||
return False
|
||
if specs is None and not state.prompt.strip():
|
||
self._message("商品卖点不能为空", "请填写商品卖点与要求,或使用AI帮写。")
|
||
return False
|
||
try:
|
||
specs = list(specs or product_suite.build_job_specs(
|
||
local_assets,
|
||
state.prompt,
|
||
state.settings,
|
||
state.item_id or "未绑定商品",
|
||
template_text=template_text,
|
||
))
|
||
except product_suite.ProductSuitePromptError:
|
||
message = "提示词渲染失败,请检查提示词设置中的变量格式。"
|
||
self._message("无法生成商品套图", message)
|
||
self._status(message, "danger")
|
||
return False
|
||
if not specs:
|
||
self._message("生成数量为0", "请至少把一个套图分类的数量设为1。")
|
||
return False
|
||
generation_round_key = ""
|
||
if retrying:
|
||
try:
|
||
original_job = image_studio.get_job(retry_job_id, path=self.db_path)
|
||
except Exception as exc:
|
||
self._message("读取重试图片失败", _user_error(exc))
|
||
return False
|
||
if original_job is None or int(original_job.project_id) != int(state.project_id):
|
||
self._message("重试图片无效", "该图片不属于当前商品套图任务。")
|
||
return False
|
||
for spec in specs:
|
||
spec["generation_round_key"] = original_job.generation_round_key
|
||
spec["generation_slot_index"] = original_job.generation_slot_index
|
||
generation_round_key = str(original_job.generation_round_key or "")
|
||
else:
|
||
if confirm_batch:
|
||
try:
|
||
history_summary = image_studio.get_successful_generation_history_summary(
|
||
state.project_id,
|
||
path=self.db_path,
|
||
)
|
||
except Exception:
|
||
self._message(
|
||
"读取套图历史失败",
|
||
"暂时无法确认该商品是否已有成功套图,请稍后重试。",
|
||
)
|
||
return False
|
||
if history_summary.successful_round_count:
|
||
decision = self._confirm_new_generation_history(
|
||
state,
|
||
history_summary,
|
||
)
|
||
if decision == "history":
|
||
self.open_history_dialog(
|
||
current_project_only=True,
|
||
current_project_id=state.project_id,
|
||
)
|
||
return False
|
||
if decision != "continue":
|
||
return False
|
||
generation_round_key = uuid.uuid4().hex
|
||
for slot_index, spec in enumerate(specs):
|
||
spec["generation_round_key"] = generation_round_key
|
||
spec["generation_slot_index"] = slot_index
|
||
if confirm_batch:
|
||
return self._start_generation_price_confirmation(
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
generation_round_key,
|
||
template_text,
|
||
)
|
||
if confirm_direct_retry:
|
||
if not self._confirm_direct_retry(state, local_assets, specs):
|
||
return False
|
||
return self._start_generation_worker(
|
||
state,
|
||
specs,
|
||
generation_round_key,
|
||
retrying=retrying,
|
||
retry_job_id=retry_job_id,
|
||
)
|
||
|
||
def _confirm_direct_retry(self, state, local_assets, specs):
|
||
message = "\n".join(
|
||
[
|
||
"这张图片上次通过自定义网关生成但未完成。",
|
||
"服务商可能对上次未确认请求已计费,本次重新生成可能再次收费。",
|
||
"",
|
||
self._generation_confirmation_message(
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
estimate=None,
|
||
),
|
||
]
|
||
)
|
||
return self._confirm(
|
||
"确认重新生成商品套图",
|
||
message,
|
||
destructive=True,
|
||
confirm_text="确认重新生成",
|
||
cancel_text="取消",
|
||
default_cancel=True,
|
||
)
|
||
|
||
def _start_generation_worker(
|
||
self,
|
||
state,
|
||
specs,
|
||
generation_round_key,
|
||
*,
|
||
retrying,
|
||
retry_job_id,
|
||
):
|
||
self._persist_state(state)
|
||
source = image_studio_generation.generation_source_for_config(self.config)
|
||
run_token = uuid.uuid4().hex
|
||
worker = ProductSuiteGenerateWorker(
|
||
state.project_id,
|
||
specs,
|
||
run_token=run_token,
|
||
generation_round_key=generation_round_key or None,
|
||
aspect_ratio=state.settings["ratio"],
|
||
db_path=self.db_path,
|
||
config=self.config,
|
||
cmhub_config_path=self.cmhub_config_path,
|
||
)
|
||
state.worker = worker
|
||
state.generation_run_token = run_token
|
||
state.generation_stop_requested = False
|
||
state.generation_terminal_streak = 0
|
||
state.generation_job_ids = []
|
||
state.generation_mode = "retry" if retrying else "batch"
|
||
state.generation_source = source["generation_source"]
|
||
state.generation_retry_job_id = retry_job_id
|
||
state.generation_round_key = generation_round_key
|
||
state.done = 0
|
||
state.failed = 0
|
||
state.total = len(specs)
|
||
state.started_at = time.monotonic()
|
||
if not retrying:
|
||
state.current_job_ids = []
|
||
self._generation_run_states[run_token] = state.key
|
||
worker.progress.connect(self._on_generation_progress_signal)
|
||
worker.finished.connect(self._on_generation_finished_signal)
|
||
worker.cancelled.connect(self._on_generation_finished_signal)
|
||
worker.failed.connect(self._on_generation_failed_signal)
|
||
state.thread = self._start_thread(worker, "商品套图生成")
|
||
self._log_generation_lifecycle(
|
||
state,
|
||
run_token,
|
||
"started",
|
||
{
|
||
"total": len(specs),
|
||
"job_ids": 0,
|
||
"mode": state.generation_mode,
|
||
},
|
||
)
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
self._refresh_results(state)
|
||
self._status(
|
||
"图片重试已开始;可切换到其他任务"
|
||
if retrying
|
||
else "商品套图生成已开始,共%d张;可切换到其他任务" % len(specs),
|
||
"info",
|
||
)
|
||
return True
|
||
|
||
def _start_generation_price_confirmation(
|
||
self,
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
generation_round_key,
|
||
template_text,
|
||
):
|
||
if state.generation_price_worker is not None or state.generation_confirmation_open:
|
||
self._status("正在读取套图预计扣点", "info")
|
||
return False
|
||
snapshot = self._generation_plan_snapshot(state, local_assets, specs, template_text)
|
||
if self._is_direct_gateway():
|
||
return self._confirm_generation_price_request(
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
generation_round_key,
|
||
snapshot,
|
||
None,
|
||
)
|
||
params = self._cmhub_catalog_params("image_alias")
|
||
cached_models = (
|
||
cmhub_models.cached_model_catalog(params["base_url"], params["alias"])
|
||
if params is not None
|
||
else None
|
||
)
|
||
if cached_models is not None:
|
||
return self._confirm_generation_price_request(
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
generation_round_key,
|
||
snapshot,
|
||
self._generation_points_estimate(cached_models, params["alias"], specs),
|
||
)
|
||
if params is None:
|
||
return self._confirm_generation_price_request(
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
generation_round_key,
|
||
snapshot,
|
||
None,
|
||
)
|
||
worker = CMHubModelCatalogWorker(
|
||
params["base_url"],
|
||
params["api_key"],
|
||
connect_timeout=params["connect_timeout"],
|
||
use_system_proxy=params["use_system_proxy"],
|
||
)
|
||
state.generation_price_worker = worker
|
||
worker.finished.connect(
|
||
lambda result, state=state, assets=list(local_assets), specs=list(specs), key=generation_round_key, plan=snapshot, alias=params["alias"]:
|
||
self._on_generation_catalog_finished(
|
||
state,
|
||
assets,
|
||
specs,
|
||
key,
|
||
plan,
|
||
alias,
|
||
result,
|
||
)
|
||
)
|
||
worker.cancelled.connect(
|
||
lambda result, state=state: self._on_generation_catalog_cancelled(state, result)
|
||
)
|
||
state.generation_price_thread = self._start_thread(worker, "商品套图读取生图扣点")
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
self._status("正在读取套图预计扣点", "info")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _generation_points_estimate(models, alias, specs):
|
||
unit_cost = cmhub_models.unambiguous_points_cost(
|
||
models,
|
||
alias,
|
||
"image",
|
||
requires_image=True,
|
||
)
|
||
if unit_cost is None:
|
||
return None
|
||
return (unit_cost, unit_cost * len(specs))
|
||
|
||
@staticmethod
|
||
def _generation_specs_signature(specs):
|
||
return tuple(
|
||
(
|
||
int(spec.get("source_asset_id") or 0),
|
||
tuple(int(asset_id) for asset_id in (spec.get("reference_asset_ids") or [])),
|
||
str(spec.get("category") or spec.get("job_type") or ""),
|
||
int(spec.get("category_index") or 0),
|
||
int(spec.get("source_index") or 0),
|
||
str(spec.get("prompt") or ""),
|
||
)
|
||
for spec in specs
|
||
)
|
||
|
||
def _generation_plan_snapshot(self, state, local_assets, specs, template_text):
|
||
return {
|
||
"asset_ids": tuple(int(asset.id) for asset in local_assets),
|
||
"settings": product_suite.normalize_suite_settings(state.settings),
|
||
"prompt": str(state.prompt or ""),
|
||
"item_id": str(state.item_id or ""),
|
||
"template_text": str(template_text or ""),
|
||
"specs": self._generation_specs_signature(specs),
|
||
"generation_backend": appconfig.ai_backend(self.config),
|
||
}
|
||
|
||
def _generation_plan_is_current(self, state, snapshot):
|
||
if not self._is_open_suite_state(state) or state.generation_running():
|
||
return False
|
||
try:
|
||
template_text = prompts.load_product_suite_prompt(self.product_suite_prompt_path)
|
||
except prompts.PromptError:
|
||
return False
|
||
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
|
||
try:
|
||
current_specs = product_suite.build_job_specs(
|
||
local_assets,
|
||
state.prompt,
|
||
state.settings,
|
||
state.item_id or "未绑定商品",
|
||
template_text=template_text,
|
||
)
|
||
except product_suite.ProductSuitePromptError:
|
||
return False
|
||
return snapshot == self._generation_plan_snapshot(
|
||
state,
|
||
local_assets,
|
||
current_specs,
|
||
template_text,
|
||
)
|
||
|
||
def _on_generation_catalog_finished(
|
||
self,
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
generation_round_key,
|
||
snapshot,
|
||
alias,
|
||
result,
|
||
):
|
||
state.generation_price_worker = None
|
||
state.generation_price_thread = None
|
||
if state is not self._displayed_state or not self._is_open_suite_state(state):
|
||
return
|
||
estimate = None
|
||
if result.get("ok") is False:
|
||
self._status("暂时无法取得套图预计扣点,实际以网关返回为准", "warning")
|
||
elif result.get("cancelled"):
|
||
self._apply_running_state(state)
|
||
return
|
||
else:
|
||
estimate = self._generation_points_estimate(result.get("models") or [], alias, specs)
|
||
self._confirm_generation_price_request(
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
generation_round_key,
|
||
snapshot,
|
||
estimate,
|
||
)
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
|
||
def _on_generation_catalog_cancelled(self, state, result):
|
||
state.generation_price_worker = None
|
||
state.generation_price_thread = None
|
||
if state is self._displayed_state and self._is_open_suite_state(state):
|
||
self._status("已取消读取套图预计扣点", "warning")
|
||
self._apply_running_state(state)
|
||
|
||
def _confirm_generation_price_request(
|
||
self,
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
generation_round_key,
|
||
snapshot,
|
||
estimate,
|
||
):
|
||
if state is not self._displayed_state or not self._is_open_suite_state(state):
|
||
return False
|
||
if not self._generation_plan_is_current(state, snapshot):
|
||
self._status("商品原图或生成设置已变化,请重新点击生成套图", "warning")
|
||
return False
|
||
state.generation_confirmation_open = True
|
||
try:
|
||
confirmed = self._confirm(
|
||
"确认生成商品套图",
|
||
self._generation_confirmation_message(
|
||
state,
|
||
local_assets,
|
||
specs,
|
||
estimate=estimate,
|
||
),
|
||
confirm_text="确认生成",
|
||
cancel_text="返回修改",
|
||
default_cancel=True,
|
||
)
|
||
finally:
|
||
state.generation_confirmation_open = False
|
||
if not confirmed:
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
return False
|
||
if not self._generation_plan_is_current(state, snapshot):
|
||
self._status("商品原图或生成设置已变化,请重新点击生成套图", "warning")
|
||
return False
|
||
return self._start_generation_worker(
|
||
state,
|
||
specs,
|
||
generation_round_key,
|
||
retrying=False,
|
||
retry_job_id=None,
|
||
)
|
||
|
||
def _generation_confirmation_message(self, state, local_assets, specs, *, estimate=None):
|
||
counts = {}
|
||
for spec in specs:
|
||
category = str(
|
||
spec.get("category")
|
||
or spec.get("job_type")
|
||
or "未分类"
|
||
)
|
||
counts[category] = counts.get(category, 0) + 1
|
||
ordered = list(product_suite.category_order(state.settings))
|
||
ordered.extend(name for name in counts if name not in ordered)
|
||
category_lines = [
|
||
"%s:%d张" % (name, counts[name])
|
||
for name in ordered
|
||
if counts.get(name, 0) > 0
|
||
]
|
||
per_image_primary = bool(state.settings.get("per_image_primary"))
|
||
reference_count = 0
|
||
if not per_image_primary:
|
||
reference_count = max(
|
||
[len(spec.get("reference_asset_ids") or []) for spec in specs] or [0]
|
||
)
|
||
ignored_reference_count = max(
|
||
0,
|
||
len(local_assets) - 1 - reference_count,
|
||
)
|
||
output_spec = image_studio_generation.requested_output_spec(
|
||
state.settings.get("ratio") or "1:1"
|
||
)
|
||
direct_gateway = self._is_direct_gateway()
|
||
lines = [
|
||
"店铺:%s" % self._account_context_label(state),
|
||
"商品ID:%s" % (state.item_id or "未绑定商品"),
|
||
"可用商品原图:%d张" % len(local_assets),
|
||
"",
|
||
"逐图主图:%s" % ("已开启" if per_image_primary else "未开启"),
|
||
(
|
||
"说明:白底图只使用第一张原图;场景图、卖点图和自定义分类会按每张原图分别生成。"
|
||
if per_image_primary
|
||
else (
|
||
"说明:所有分类都使用第1张可用原图作为主图,另有%d张参考图会随每次请求一同提交。"
|
||
% reference_count
|
||
if reference_count
|
||
else "说明:所有分类都只使用第一张可用原图生成。"
|
||
)
|
||
),
|
||
"",
|
||
]
|
||
if not per_image_primary and ignored_reference_count:
|
||
lines.extend(
|
||
[
|
||
"参考图仅取前7张,其余%d张不参与本轮生成。" % ignored_reference_count,
|
||
"",
|
||
]
|
||
)
|
||
lines.extend(category_lines)
|
||
lines.extend(
|
||
[
|
||
"图片比例:%s" % (state.settings.get("ratio") or "1:1"),
|
||
"本次生成总数:%d张" % len(specs),
|
||
"商品卖点:已填写",
|
||
"",
|
||
]
|
||
)
|
||
lines.extend(
|
||
[
|
||
"输出尺寸:%s%s"
|
||
% (
|
||
output_spec["requested_output_size"],
|
||
"(接近比例生成)" if output_spec["approximate_ratio"] else "",
|
||
),
|
||
"",
|
||
]
|
||
)
|
||
if direct_gateway:
|
||
lines.extend(
|
||
[
|
||
"生成来源:自定义网关",
|
||
"自定义网关不计点数,实际费用以服务商为准。",
|
||
"首图作为主要商品参考;参考图效果取决于模型,商品主体一致性可能弱于默认网关。",
|
||
]
|
||
)
|
||
elif estimate is None:
|
||
lines.append("本次会消耗 cmhub 点数,暂时无法取得预计扣点,实际以网关返回为准。")
|
||
else:
|
||
unit_cost, total_cost = estimate
|
||
lines.extend(
|
||
[
|
||
"预计单张扣点:%s 点" % cmhub_models.format_points_cost(unit_cost),
|
||
"预计总扣点:%s 点" % cmhub_models.format_points_cost(total_cost),
|
||
"实际扣点以 cmhub 返回为准。",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def _generation_signal_token(self, payload=None):
|
||
token = str((payload or {}).get("run_token") or "")
|
||
sender = self.sender()
|
||
return token or str(getattr(sender, "run_token", "") or "")
|
||
|
||
def _generation_state(self, run_token):
|
||
token = str(run_token or "")
|
||
state = self._states.get(self._generation_run_states.get(token))
|
||
if state is None or state.generation_run_token != token:
|
||
return None
|
||
return state
|
||
|
||
@Slot(dict)
|
||
def _on_generation_progress_signal(self, payload):
|
||
token = self._generation_signal_token(payload)
|
||
state = self._generation_state(token)
|
||
if state is None:
|
||
return
|
||
self._on_generation_progress(state, payload)
|
||
|
||
def _on_generation_progress(self, state, payload):
|
||
if "total" in payload:
|
||
state.total = max(0, int(payload.get("total") or 0))
|
||
state.done = int(payload.get("done", state.done) or 0)
|
||
state.failed = int(payload.get("failed", state.failed) or 0)
|
||
job_ids = payload.get("job_ids")
|
||
if job_ids is not None:
|
||
self._set_generation_job_ids(state, job_ids)
|
||
state.generation_terminal_streak = 0
|
||
if state is self._displayed_state:
|
||
self._refresh_results(state)
|
||
self._refresh_elapsed()
|
||
|
||
@Slot(int, str)
|
||
def _on_generation_failed_signal(self, row, error):
|
||
token = self._generation_signal_token()
|
||
state = self._generation_state(token)
|
||
if state is None:
|
||
return
|
||
self._log_generation_lifecycle(
|
||
state,
|
||
token,
|
||
"worker_failed",
|
||
{"has_error": True},
|
||
level="ERROR",
|
||
)
|
||
self._status(
|
||
(
|
||
"图片重试失败:%s"
|
||
if state.generation_mode == "retry"
|
||
else "商品套图生成失败:%s"
|
||
)
|
||
% _user_error(error),
|
||
"danger",
|
||
)
|
||
|
||
@Slot(dict)
|
||
def _on_generation_finished_signal(self, result):
|
||
token = self._generation_signal_token(result)
|
||
state = self._generation_state(token)
|
||
if state is None:
|
||
return
|
||
if result.get("job_ids") is not None:
|
||
self._set_generation_job_ids(state, result.get("job_ids"))
|
||
self._log_generation_lifecycle(
|
||
state,
|
||
token,
|
||
"worker_finished",
|
||
{
|
||
"ok": result.get("ok", True),
|
||
"success": result.get("success", 0),
|
||
"failed": result.get("failed", 0),
|
||
"cancelled": result.get(
|
||
"cancelled_count",
|
||
result.get("cancelled", 0),
|
||
),
|
||
},
|
||
)
|
||
if result.get("ok") is False:
|
||
self._reconcile_generation_jobs(state, "生成线程异常结束")
|
||
if (
|
||
state.generation_stop_requested
|
||
and not self._generation_job_ids(state)
|
||
and result.get("cancelled") is True
|
||
):
|
||
result = dict(result)
|
||
result["total"] = state.total
|
||
result["cancelled_count"] = state.total
|
||
self._finalize_generation(state, token, result, source="worker")
|
||
|
||
@Slot()
|
||
def _on_generation_thread_finished_signal(self):
|
||
sender = self.sender()
|
||
token = str(
|
||
sender.property("productSuiteRunToken")
|
||
if sender is not None
|
||
else ""
|
||
)
|
||
self._handle_generation_thread_finished(token)
|
||
|
||
def _handle_generation_thread_finished(self, run_token):
|
||
state = self._generation_state(run_token)
|
||
if state is None:
|
||
return
|
||
snapshot = self._generation_job_snapshot(state)
|
||
had_active = bool(snapshot["active"])
|
||
self._log_generation_lifecycle(
|
||
state,
|
||
run_token,
|
||
"thread_finished_fallback",
|
||
snapshot,
|
||
level="WARNING" if snapshot["active"] else "INFO",
|
||
)
|
||
if had_active:
|
||
self._reconcile_generation_jobs(state, "生成线程已结束")
|
||
snapshot = self._generation_job_snapshot(state)
|
||
result = self._generation_result_from_snapshot(snapshot)
|
||
if not snapshot["job_ids"] and state.generation_stop_requested:
|
||
result.update(
|
||
{
|
||
"total": state.total,
|
||
"cancelled_count": state.total,
|
||
}
|
||
)
|
||
elif had_active:
|
||
result.update(
|
||
{
|
||
"ok": False,
|
||
"error": "生成线程已结束,部分任务可稍后继续查询",
|
||
}
|
||
)
|
||
self._finalize_generation(
|
||
state,
|
||
run_token,
|
||
result,
|
||
source="thread_finished",
|
||
)
|
||
|
||
def _check_generation_watchdogs(self):
|
||
for state in list(self._states.values()):
|
||
token = state.generation_run_token
|
||
if not token or not state.generation_running():
|
||
continue
|
||
snapshot = self._generation_job_snapshot(state)
|
||
if snapshot["all_terminal"]:
|
||
state.generation_terminal_streak += 1
|
||
else:
|
||
state.generation_terminal_streak = 0
|
||
if state.generation_terminal_streak < 2:
|
||
continue
|
||
self._log_generation_lifecycle(
|
||
state,
|
||
token,
|
||
"terminal_watchdog_finalize",
|
||
snapshot,
|
||
level="WARNING",
|
||
)
|
||
self._finalize_generation(
|
||
state,
|
||
token,
|
||
self._generation_result_from_snapshot(snapshot),
|
||
source="terminal_watchdog",
|
||
)
|
||
|
||
def _generation_job_ids(self, state):
|
||
job_ids = list(state.generation_job_ids)
|
||
if not job_ids and state.worker is not None:
|
||
worker_job_ids = getattr(state.worker, "job_ids", [])
|
||
if not isinstance(worker_job_ids, (list, tuple, set)):
|
||
worker_job_ids = []
|
||
job_ids = [
|
||
int(job_id)
|
||
for job_id in list(worker_job_ids or [])
|
||
]
|
||
if job_ids:
|
||
self._set_generation_job_ids(state, job_ids)
|
||
return job_ids
|
||
|
||
def _set_generation_job_ids(self, state, job_ids):
|
||
normalized = []
|
||
for job_id in job_ids or []:
|
||
value = int(job_id)
|
||
if value not in normalized:
|
||
normalized.append(value)
|
||
state.generation_job_ids = normalized
|
||
if state.generation_mode != "retry":
|
||
state.current_job_ids = list(normalized)
|
||
return
|
||
retry_job_id = state.generation_retry_job_id
|
||
current = [int(job_id) for job_id in state.current_job_ids]
|
||
if retry_job_id in current:
|
||
index = current.index(retry_job_id)
|
||
current[index:index + 1] = normalized
|
||
else:
|
||
for job_id in normalized:
|
||
if job_id not in current:
|
||
current.append(job_id)
|
||
state.current_job_ids = current
|
||
|
||
def _restore_current_generation_results(self, state, *, force=False, allow_running=False):
|
||
if state.project_id is None or (
|
||
state.generation_running() and not allow_running
|
||
):
|
||
return False
|
||
if state.current_job_ids and not force:
|
||
return False
|
||
try:
|
||
round_key = image_studio.get_current_generation_round(
|
||
state.project_id,
|
||
path=self.db_path,
|
||
)
|
||
jobs = image_studio.list_generation_round_current_jobs(
|
||
state.project_id,
|
||
round_key,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._status("当前生成结果恢复失败:%s" % _user_error(exc), "danger")
|
||
return False
|
||
state.current_generation_round_key = str(round_key or "")
|
||
state.current_job_ids = [int(job.id) for job in jobs]
|
||
return True
|
||
|
||
def _generation_job_snapshot(self, state):
|
||
job_ids = self._generation_job_ids(state)
|
||
counts = {
|
||
"success": 0,
|
||
"failed": 0,
|
||
"cancelled": 0,
|
||
"active": 0,
|
||
"job_ids": len(job_ids),
|
||
"all_terminal": False,
|
||
}
|
||
if not job_ids:
|
||
counts["active"] = max(0, int(state.total or 0))
|
||
return counts
|
||
for job_id in job_ids:
|
||
try:
|
||
job = image_studio.get_job(job_id, path=self.db_path)
|
||
except Exception:
|
||
job = None
|
||
status = str(getattr(job, "status", "") or "")
|
||
if status == "succeeded":
|
||
counts["success"] += 1
|
||
elif status in {"failed", "expired"}:
|
||
counts["failed"] += 1
|
||
elif status == "cancelled":
|
||
counts["cancelled"] += 1
|
||
else:
|
||
counts["active"] += 1
|
||
counts["all_terminal"] = (
|
||
len(job_ids) == int(state.total or 0)
|
||
and counts["active"] == 0
|
||
)
|
||
return counts
|
||
|
||
def _generation_result_from_snapshot(self, snapshot):
|
||
return {
|
||
"total": int(snapshot.get("job_ids", 0) or 0),
|
||
"success": int(snapshot.get("success", 0) or 0),
|
||
"failed": int(snapshot.get("failed", 0) or 0),
|
||
"cancelled_count": int(snapshot.get("cancelled", 0) or 0),
|
||
}
|
||
|
||
def _reconcile_generation_jobs(self, state, reason):
|
||
for job_id in self._generation_job_ids(state):
|
||
try:
|
||
job = image_studio.get_job(job_id, path=self.db_path)
|
||
if job is None or job.status in {
|
||
"succeeded",
|
||
"failed",
|
||
"expired",
|
||
"cancelled",
|
||
}:
|
||
continue
|
||
recovery = (
|
||
image_studio.JOB_RECOVERY_RESUME
|
||
if job.task_id
|
||
else image_studio.JOB_RECOVERY_REGENERATE
|
||
)
|
||
image_studio.update_job_status(
|
||
job.id,
|
||
"cancelled",
|
||
error="%s,任务可稍后继续处理" % reason,
|
||
recovery_action=recovery,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._status(
|
||
"商品套图任务状态收尾失败:%s" % _user_error(exc),
|
||
"danger",
|
||
)
|
||
|
||
def _finalize_generation(self, state, run_token, result, *, source):
|
||
if self._generation_state(run_token) is not state:
|
||
return False
|
||
retrying = state.generation_mode == "retry"
|
||
snapshot = self._generation_job_snapshot(state)
|
||
if snapshot["job_ids"] and (
|
||
snapshot["all_terminal"]
|
||
or source in {"thread_finished", "terminal_watchdog"}
|
||
):
|
||
result = dict(result or {})
|
||
result.update(self._generation_result_from_snapshot(snapshot))
|
||
result = dict(result or {})
|
||
stop_requested = state.generation_stop_requested
|
||
success = int(result.get("success", 0) or 0)
|
||
failed = int(result.get("failed", 0) or 0)
|
||
cancelled = int(
|
||
result.get("cancelled_count", result.get("cancelled", 0)) or 0
|
||
)
|
||
total = int(result.get("total", state.total) or state.total)
|
||
active = max(0, total - success - failed - cancelled)
|
||
elapsed = (
|
||
int(max(0, time.monotonic() - state.started_at))
|
||
if state.started_at
|
||
else 0
|
||
)
|
||
promoted = False
|
||
if not retrying and state.generation_round_key and success:
|
||
try:
|
||
promoted = image_studio.promote_generation_round_if_success(
|
||
state.project_id,
|
||
state.generation_round_key,
|
||
path=self.db_path,
|
||
)
|
||
except Exception as exc:
|
||
self._status("当前生成轮次保存失败:%s" % _user_error(exc), "danger")
|
||
self._restore_current_generation_results(
|
||
state,
|
||
force=True,
|
||
allow_running=True,
|
||
)
|
||
elif not retrying:
|
||
self._restore_current_generation_results(
|
||
state,
|
||
force=True,
|
||
allow_running=True,
|
||
)
|
||
elif state.generation_round_key:
|
||
self._restore_current_generation_results(
|
||
state,
|
||
force=True,
|
||
allow_running=True,
|
||
)
|
||
self._generation_run_states.pop(run_token, None)
|
||
state.generation_run_token = ""
|
||
state.generation_stop_requested = False
|
||
state.generation_terminal_streak = 0
|
||
state.generation_job_ids = []
|
||
state.generation_retry_job_id = None
|
||
state.generation_round_key = ""
|
||
state.generation_source = ""
|
||
state.worker = None
|
||
state.thread = None
|
||
state.done = success + failed + cancelled
|
||
state.failed = failed
|
||
state.total = total
|
||
state.started_at = None
|
||
if state is self._displayed_state:
|
||
self._apply_running_state(state)
|
||
self._refresh_results(state)
|
||
self._refresh_elapsed()
|
||
self._log_generation_lifecycle(
|
||
state,
|
||
run_token,
|
||
"finalized",
|
||
{
|
||
"source": source,
|
||
"total": total,
|
||
"success": success,
|
||
"failed": failed,
|
||
"cancelled": cancelled,
|
||
"active": active,
|
||
"elapsed_seconds": elapsed,
|
||
"mode": "retry" if retrying else "batch",
|
||
"current_round_promoted": promoted,
|
||
},
|
||
level="WARNING" if active or result.get("ok") is False else "INFO",
|
||
)
|
||
if state.key not in self._states:
|
||
return True
|
||
if active or result.get("ok") is False:
|
||
message = str(
|
||
result.get("error")
|
||
or "生成线程已结束,部分任务可稍后继续查询"
|
||
)
|
||
if state is self._displayed_state:
|
||
self._message(
|
||
"图片重试未完整结束"
|
||
if retrying
|
||
else "商品套图生成未完整结束",
|
||
_user_error(message),
|
||
)
|
||
else:
|
||
self._status(
|
||
(
|
||
"套图任务%d图片重试未完整结束"
|
||
if retrying
|
||
else "套图任务%d生成未完整结束"
|
||
)
|
||
% state.serial,
|
||
"danger",
|
||
)
|
||
return True
|
||
if stop_requested or cancelled:
|
||
if state is self._displayed_state:
|
||
if retrying:
|
||
self._message(
|
||
"图片重试已停止",
|
||
"本次重试:成功%d张,失败%d张,停止%d张;"
|
||
"已提交任务可稍后继续查询;总用时%d秒。"
|
||
% (success, failed, cancelled, elapsed),
|
||
icon=QMessageBox.Information,
|
||
)
|
||
else:
|
||
self._message(
|
||
"商品套图生成已停止",
|
||
"本轮共%d张:成功%d张,失败%d张,停止%d张;"
|
||
"已提交任务可稍后继续查询;总用时%d秒。"
|
||
% (total, success, failed, cancelled, elapsed),
|
||
icon=QMessageBox.Information,
|
||
)
|
||
self._status(
|
||
(
|
||
"图片重试已停止:成功%d张,失败%d张,停止%d张"
|
||
if retrying
|
||
else "商品套图生成已停止:成功%d张,失败%d张,停止%d张"
|
||
)
|
||
% (success, failed, cancelled),
|
||
"warning",
|
||
)
|
||
return True
|
||
if state is self._displayed_state:
|
||
if retrying:
|
||
self._message(
|
||
"图片重试成功" if success and not failed else "图片重试失败",
|
||
"本次重试:成功%d张,失败%d张,停止%d张;总用时%d秒。"
|
||
% (success, failed, cancelled, elapsed),
|
||
icon=QMessageBox.Information,
|
||
)
|
||
else:
|
||
self._message(
|
||
"商品套图生成完成",
|
||
"本轮共%d张:成功%d张,失败%d张,停止%d张;总用时%d秒。"
|
||
% (total, success, failed, cancelled, elapsed),
|
||
icon=QMessageBox.Information,
|
||
)
|
||
if retrying:
|
||
self._status(
|
||
"图片重试成功" if success and not failed else "图片重试失败,请查看失败卡片",
|
||
"success" if success and not failed else "danger",
|
||
)
|
||
else:
|
||
self._status(
|
||
"商品套图生成完成:成功%d张,失败%d张" % (success, failed),
|
||
"success",
|
||
)
|
||
return True
|
||
|
||
def _log_generation_lifecycle(
|
||
self,
|
||
state,
|
||
run_token,
|
||
event,
|
||
payload=None,
|
||
*,
|
||
level="INFO",
|
||
):
|
||
data = {
|
||
"run_token": str(run_token or "")[:8],
|
||
"project_id": getattr(state, "project_id", None),
|
||
"event": str(event or ""),
|
||
}
|
||
data.update(dict(payload or {}))
|
||
try:
|
||
diagnostics.write_diagnostic_log(
|
||
"商品套图生成生命周期",
|
||
level=level,
|
||
step="product_suite_generation",
|
||
task_id=getattr(state, "project_id", None),
|
||
item_id=getattr(state, "item_id", None),
|
||
payload=data,
|
||
log_dir=appconfig.diagnostic_log_dir(self.config),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_running_state(self, state):
|
||
generation_running = state.generation_running()
|
||
generation_price_pending = state.generation_price_worker is not None
|
||
pull_running = state.pull_running()
|
||
default_gateway = self._is_default_gateway()
|
||
supported_gateway = default_gateway or self._is_direct_gateway()
|
||
self.pull_button.setText(
|
||
"正在停止..."
|
||
if pull_running and state.pull_stop_requested
|
||
else "停止拉取蝦皮"
|
||
if pull_running
|
||
else "拉取蝦皮主图"
|
||
)
|
||
self.account_combo.setEnabled(not generation_running and not pull_running)
|
||
self.item_id_edit.setEnabled(not generation_running and not pull_running)
|
||
self._refresh_add_images_action(state)
|
||
self.original_list.setEnabled(not generation_running)
|
||
self.original_list.set_reordering_enabled(not generation_running and not pull_running)
|
||
self._refresh_original_selection_controls()
|
||
for widget in (
|
||
self.platform_combo,
|
||
self.country_combo,
|
||
self.language_combo,
|
||
self.ratio_combo,
|
||
self.per_image_checkbox,
|
||
self.prompt_edit,
|
||
self.prompt_settings_button,
|
||
self.add_category_button,
|
||
self.custom_category_edit,
|
||
):
|
||
widget.setEnabled(not generation_running)
|
||
for row in self.category_rows.values():
|
||
row.set_controls_enabled(not generation_running)
|
||
self.generate_button.setEnabled(
|
||
generation_running or generation_price_pending or supported_gateway
|
||
)
|
||
if generation_running:
|
||
self.generate_button.setText(
|
||
"正在停止..."
|
||
if state.generation_stop_requested
|
||
else "停止重试"
|
||
if state.generation_mode == "retry"
|
||
else "停止生成"
|
||
)
|
||
self.generate_button.setStyleSheet(
|
||
"QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }"
|
||
"QPushButton:hover { background: #a40e26; }"
|
||
)
|
||
elif generation_price_pending:
|
||
self.generate_button.setText("取消读取扣点")
|
||
self.generate_button.setStyleSheet(
|
||
"QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }"
|
||
"QPushButton:hover { background: #a40e26; }"
|
||
)
|
||
else:
|
||
self.generate_button.setStyleSheet(
|
||
"QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }"
|
||
"QPushButton:hover { background: #245fce; }"
|
||
)
|
||
self._refresh_totals(state)
|
||
ai_running = state.ai_worker is not None or state.ai_price_worker is not None
|
||
self.ai_write_button.setEnabled(
|
||
default_gateway and not ai_running and not generation_running
|
||
)
|
||
if not default_gateway and not ai_running:
|
||
self.ai_write_button.setToolTip("商品套图AI帮写仅支持默认网关")
|
||
self.generate_button.setToolTip("自定义网关生成不计点数,实际费用以服务商为准")
|
||
else:
|
||
self.ai_write_button.setToolTip("")
|
||
self.generate_button.setToolTip("")
|
||
self.ai_cancel_button.setVisible(ai_running)
|
||
self._update_resume_submitted_action(state)
|
||
self._update_context_actions(state)
|
||
|
||
def _refresh_elapsed(self):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
elapsed = int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0
|
||
self.elapsed_label.setText(
|
||
"%s %d/%d(%d秒) · 失败 %d" % (
|
||
"重试" if state.generation_mode == "retry" else "套图",
|
||
state.done,
|
||
state.total,
|
||
elapsed,
|
||
state.failed,
|
||
)
|
||
)
|
||
self.progress_bar.setRange(0, max(1, state.total))
|
||
self.progress_bar.setValue(min(state.done, max(1, state.total)))
|
||
if state.ai_price_worker is not None:
|
||
self.ai_write_button.setText("读取扣点中")
|
||
elif state.ai_worker is not None and state.ai_started_at is not None:
|
||
ai_elapsed = int(max(0, time.monotonic() - state.ai_started_at))
|
||
self.ai_write_button.setText("AI 帮写中(%d秒)" % ai_elapsed)
|
||
else:
|
||
self.ai_write_button.setText("AI 帮写")
|
||
|
||
def _jobs_for_state(self, state):
|
||
if state.project_id is None:
|
||
return []
|
||
try:
|
||
jobs = image_studio.list_jobs(state.project_id, path=self.db_path)
|
||
except Exception as exc:
|
||
self._status("生成结果读取失败:%s" % _user_error(exc), "danger")
|
||
return []
|
||
by_id = {int(job.id): job for job in jobs}
|
||
return [
|
||
by_id[int(job_id)]
|
||
for job_id in state.current_job_ids
|
||
if int(job_id) in by_id
|
||
]
|
||
|
||
def _refresh_results(self, state):
|
||
while self.result_grid.count():
|
||
item = self.result_grid.takeAt(0)
|
||
widget = item.widget()
|
||
if widget is not None:
|
||
widget.deleteLater()
|
||
jobs = self._jobs_for_state(state)
|
||
assets = {}
|
||
if state.project_id is not None:
|
||
try:
|
||
assets = {
|
||
int(asset.id): asset
|
||
for asset in image_studio.list_assets(state.project_id, path=self.db_path)
|
||
}
|
||
except Exception:
|
||
assets = {}
|
||
success = 0
|
||
if not jobs:
|
||
empty = QLabel("还没有生成结果,点击左侧“生成套图”开始")
|
||
empty.setAlignment(Qt.AlignCenter)
|
||
empty.setStyleSheet("color: #6b7280; padding: 48px;")
|
||
self.result_grid.addWidget(empty, 0, 0, 1, 3)
|
||
else:
|
||
columns = max(2, min(4, max(1, self.result_scroll.viewport().width() // 200)))
|
||
for index, job in enumerate(jobs):
|
||
asset = assets.get(int(job.output_asset_id or 0))
|
||
if job.status == "succeeded" and asset is not None and _asset_usable(asset):
|
||
success += 1
|
||
card = SuiteResultCard(job, asset)
|
||
card.previewRequested.connect(self.preview_job)
|
||
card.retryRequested.connect(self.retry_job)
|
||
card.menuRequested.connect(self._show_job_menu)
|
||
card.deleteRequested.connect(self.delete_job_asset)
|
||
retry_enabled, retry_tooltip = self._retry_availability(job)
|
||
card.set_retry_enabled(retry_enabled, retry_tooltip)
|
||
self.result_grid.addWidget(card, index // columns, index % columns)
|
||
self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success))
|
||
self.undo_button.setVisible(bool(state.undo_records))
|
||
|
||
def open_history_dialog(
|
||
self,
|
||
checked=False,
|
||
*,
|
||
current_project_only=False,
|
||
current_project_id=None,
|
||
):
|
||
state = self._displayed_state
|
||
if current_project_id is None:
|
||
current_project_id = state.project_id if state is not None else None
|
||
|
||
dialog = self._history_dialog
|
||
if dialog is not None:
|
||
try:
|
||
dialog.set_current_project(current_project_id, refresh=False)
|
||
dialog.set_current_project_filter(
|
||
current_project_only,
|
||
refresh=False,
|
||
)
|
||
dialog.refresh_history()
|
||
dialog.show()
|
||
dialog.raise_()
|
||
dialog.activateWindow()
|
||
return
|
||
except RuntimeError:
|
||
pass
|
||
self._history_dialog = None
|
||
|
||
dialog = ProductSuiteGlobalHistoryDialog(
|
||
current_project_id=current_project_id,
|
||
current_project_only=current_project_only,
|
||
db_path=self.db_path,
|
||
parent=self,
|
||
)
|
||
dialog.destroyed.connect(
|
||
lambda _object=None, current=dialog: self._clear_history_dialog(current)
|
||
)
|
||
self._history_dialog = dialog
|
||
dialog.show()
|
||
dialog.raise_()
|
||
dialog.activateWindow()
|
||
|
||
def _clear_history_dialog(self, dialog):
|
||
if self._history_dialog is dialog:
|
||
self._history_dialog = None
|
||
|
||
def _close_history_dialog_for_project(self, project_id=None):
|
||
if project_id is not None:
|
||
return
|
||
dialog = self._history_dialog
|
||
if dialog is None:
|
||
return
|
||
try:
|
||
dialog.close()
|
||
self._history_dialog = None
|
||
except RuntimeError:
|
||
self._history_dialog = None
|
||
|
||
def preview_job(self, job):
|
||
asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None
|
||
if asset is None or not _asset_usable(asset):
|
||
self._status("这张生成图片尚不可预览", "warning")
|
||
return
|
||
ProductSuitePreviewDialog(asset.local_path, "%s预览" % job.job_type, self).exec()
|
||
|
||
def retry_job(self, job):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
if state.generation_running():
|
||
self._message("当前任务正在生成", "请等待当前生成结束或停止后再重试单张图片。")
|
||
return
|
||
if str(getattr(job, "status", "") or "") not in {
|
||
"failed",
|
||
"expired",
|
||
"cancelled",
|
||
}:
|
||
self._status("当前图片无需重试", "warning")
|
||
return
|
||
retry_enabled, retry_tooltip = self._retry_availability(job)
|
||
if not retry_enabled:
|
||
self._message("当前不可重新生成", retry_tooltip)
|
||
return
|
||
try:
|
||
reference_asset_ids = image_studio.job_reference_asset_ids(job)
|
||
except Exception as exc:
|
||
self._message("读取重试图片失败", _user_error(exc))
|
||
return
|
||
spec = {
|
||
"source_asset_id": job.source_asset_id,
|
||
"reference_asset_ids": reference_asset_ids,
|
||
"job_type": job.job_type,
|
||
"prompt": job.prompt,
|
||
}
|
||
self.start_generation(
|
||
state,
|
||
specs=[spec],
|
||
retry_job_id=job.id,
|
||
confirm_direct_retry=self._is_direct_gateway_job(job),
|
||
)
|
||
|
||
def _show_job_menu(self, job, global_position):
|
||
menu = QMenu(self)
|
||
preview_action = menu.addAction("预览")
|
||
copy_action = menu.addAction("复制路径")
|
||
folder_action = menu.addAction("打开文件夹")
|
||
retry_action = menu.addAction("重新生成")
|
||
retry_enabled, retry_tooltip = self._retry_availability(job)
|
||
retry_action.setEnabled(retry_enabled)
|
||
retry_action.setToolTip(retry_tooltip)
|
||
delete_action = menu.addAction("删除")
|
||
action = menu.exec(global_position)
|
||
asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None
|
||
if action is preview_action:
|
||
self.preview_job(job)
|
||
elif action is copy_action:
|
||
if asset is None or not _asset_usable(asset):
|
||
self._status("当前图片没有可复制的本地路径", "warning")
|
||
else:
|
||
QApplication.clipboard().setText(asset.local_path)
|
||
self._status("生成图片路径已复制", "success")
|
||
elif action is folder_action:
|
||
if asset is not None and _asset_usable(asset):
|
||
try:
|
||
file_manager.open_in_file_manager(os.path.dirname(asset.local_path))
|
||
except Exception as exc:
|
||
self._message("打开文件夹失败", _user_error(exc))
|
||
else:
|
||
self.open_project_folder()
|
||
elif action is retry_action:
|
||
self.retry_job(job)
|
||
elif action is delete_action:
|
||
self.delete_job_asset(job)
|
||
|
||
def delete_job_asset(self, job):
|
||
state = self._displayed_state
|
||
if state is None or not job.output_asset_id:
|
||
return
|
||
if not self._confirm("删除生成图片", "确认把这张图片移到当前商品的废纸篓吗?"):
|
||
return
|
||
try:
|
||
record = image_studio_images.trash_generated_asset(
|
||
job.output_asset_id,
|
||
path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
except Exception as exc:
|
||
self._message("删除生成图片失败", _user_error(exc))
|
||
return
|
||
state.undo_records.append(record)
|
||
self._refresh_results(state)
|
||
self._status("生成图片已删除,可点击“撤销删除”恢复", "success")
|
||
|
||
def undo_delete(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None or not state.undo_records:
|
||
return
|
||
record = state.undo_records.pop()
|
||
try:
|
||
image_studio_images.restore_trashed_asset(
|
||
record,
|
||
path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
except Exception as exc:
|
||
state.undo_records.append(record)
|
||
self._message("撤销删除失败", _user_error(exc))
|
||
return
|
||
self._refresh_results(state)
|
||
self._status("生成图片已恢复", "success")
|
||
|
||
def _show_more_menu(self):
|
||
menu = QMenu(self)
|
||
delete_all_action = menu.addAction("删除当前视图全部图片")
|
||
if menu.exec(self.more_button.mapToGlobal(self.more_button.rect().bottomLeft())) is delete_all_action:
|
||
self.delete_all_visible_results()
|
||
|
||
def delete_all_visible_results(self):
|
||
state = self._displayed_state
|
||
if state is None:
|
||
return
|
||
jobs = [job for job in self._jobs_for_state(state) if job.output_asset_id]
|
||
if not jobs:
|
||
self._status("当前视图没有可删除的生成图片", "warning")
|
||
return
|
||
if not self._confirm(
|
||
"删除全部生成图片",
|
||
"确认把当前视图中的%d张生成图片移到废纸篓吗?" % len(jobs),
|
||
destructive=True,
|
||
):
|
||
return
|
||
failures = 0
|
||
for job in jobs:
|
||
try:
|
||
state.undo_records.append(
|
||
image_studio_images.trash_generated_asset(
|
||
job.output_asset_id,
|
||
path=self.db_path,
|
||
config=self.config,
|
||
)
|
||
)
|
||
except Exception:
|
||
failures += 1
|
||
self._refresh_results(state)
|
||
if failures:
|
||
self._message("部分图片未删除", "有%d张图片无法删除,请查看当前结果。" % failures)
|
||
else:
|
||
self._status("当前视图的生成图片已移到废纸篓", "success")
|
||
|
||
def open_project_folder(self, checked=False):
|
||
state = self._displayed_state
|
||
if state is None or state.project_id is None:
|
||
self._message("未创建套图任务", "请先选择账号并添加本地图片,或输入商品ID后拉取蝦皮主图。")
|
||
return
|
||
project = image_studio.get_project(state.project_id, path=self.db_path)
|
||
if project is None:
|
||
self._message("商品任务不存在", "当前商品套图任务已经不存在。")
|
||
return
|
||
target = image_studio.default_project_image_dirs(project, config=self.config)["generated"]
|
||
os.makedirs(target, exist_ok=True)
|
||
try:
|
||
file_manager.open_in_file_manager(target)
|
||
except Exception as exc:
|
||
self._message("打开文件夹失败", _user_error(exc))
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
if not self._result_refresh_pending:
|
||
self._result_refresh_pending = True
|
||
QTimer.singleShot(80, self._refresh_after_resize)
|
||
|
||
def _refresh_after_resize(self):
|
||
self._result_refresh_pending = False
|
||
if self._displayed_state is not None:
|
||
self._refresh_results(self._displayed_state)
|
||
|
||
def closeEvent(self, event):
|
||
self._close_history_dialog_for_project()
|
||
if self._displayed_state is not None:
|
||
self._save_controls_to_state(self._displayed_state)
|
||
for state in list(self._states.values()):
|
||
self._flush_prompt_save(state)
|
||
for state in list(self._states.values()) + list(self._retired_states):
|
||
for worker in (
|
||
state.worker,
|
||
state.pull_worker,
|
||
state.import_worker,
|
||
state.ai_worker,
|
||
state.ai_price_worker,
|
||
state.generation_price_worker,
|
||
):
|
||
if worker is not None and hasattr(worker, "cancel"):
|
||
worker.cancel()
|
||
for worker, thread in list(state.downloads.values()):
|
||
worker.cancel()
|
||
for state in list(self._states.values()) + list(self._retired_states):
|
||
self._release_prompt_save_timer(state)
|
||
self._generation_run_states.clear()
|
||
self._pull_run_states.clear()
|
||
super().closeEvent(event)
|