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