"""Tab 6: project-bound product suite generation workspace.""" from __future__ import annotations import os import re import time import uuid from dataclasses import dataclass, field from PySide6.QtCore import ( QByteArray, QBuffer, QIODevice, QRect, QSize, Qt, QTimer, Signal, Slot, ) from PySide6.QtGui import QColor, QIcon, QImage, QImageReader, QKeySequence, QPainter, QPixmap from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, QDialog, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListView, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QSplitter, QStyle, QStyledItemDelegate, QStyleOptionButton, QTabBar, QToolButton, QVBoxLayout, QWidget, ) from ... import ( accounts, appconfig, diagnostics, image_studio, image_studio_images, product_suite, prompts, ) from .. import file_manager from ..image_preview import ImagePreviewDialog from ..product_suite_prompt_dialog import ProductSuitePromptDialog from ..widgets import _emit_status, run_worker from ..workers import ( ImageStudioDownloadOriginalWorker, ImageStudioPullImagesWorker, ProductSuiteAiWriteWorker, ProductSuiteGenerateWorker, ProductSuiteImportImagesWorker, ) ORIGINAL_DOWNLOAD_CONCURRENCY = 2 ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1 PROMPT_AUTOSAVE_DELAY_MS = 500 _PRODUCT_SUITE_THREAD_REFS = {} _URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE) class AutoHeightPlainTextEdit(QPlainTextEdit): """A plain-text editor that delegates scrolling to its containing page.""" def __init__(self, parent=None, minimum_height=96): super().__init__(parent) self._minimum_content_height = max(1, int(minimum_height)) self._height_update_pending = False self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setFixedHeight(self._minimum_content_height) self.document().contentsChanged.connect(self.schedule_height_update) self.document().documentLayout().documentSizeChanged.connect( self.schedule_height_update ) self.schedule_height_update() def schedule_height_update(self, *args): if self._height_update_pending: return self._height_update_pending = True QTimer.singleShot(0, self.refresh_height) def refresh_height(self): self._height_update_pending = False text_height = 0.0 block = self.document().firstBlock() while block.isValid(): layout = block.layout() line_count = max(1, layout.lineCount()) for index in range(line_count): line = layout.lineAt(index) text_height += ( line.height() if line.isValid() else self.fontMetrics().lineSpacing() ) block = block.next() margins = self.contentsMargins() target = int( text_height + (self.document().documentMargin() * 2) + margins.top() + margins.bottom() + (self.frameWidth() * 2) + 6 ) target = max(self._minimum_content_height, target) if self.height() != target: self.setFixedHeight(target) self.updateGeometry() def resizeEvent(self, event): super().resizeEvent(event) self.schedule_height_update() def showEvent(self, event): super().showEvent(event) self.schedule_height_update() def _asset_usable(asset): path = str(getattr(asset, "local_path", "") or "") return ( getattr(asset, "status", "") != image_studio.ASSET_STATUS_MISSING and bool(path) and os.path.isfile(path) ) def _placeholder_pixmap(text, size, color="#f3f4f6"): pixmap = QPixmap(size) pixmap.fill(QColor(color)) painter = QPainter(pixmap) painter.setPen(QColor("#6b7280")) painter.drawText(pixmap.rect(), Qt.AlignCenter | Qt.TextWordWrap, str(text or "")) painter.end() return pixmap def _image_pixmap(path, size, fallback="暂无图片"): image = QImage() if path and os.path.isfile(path): reader = QImageReader(str(path)) reader.setAutoTransform(True) source_size = reader.size() if source_size.isValid(): source_size.scale(size, Qt.KeepAspectRatio) reader.setScaledSize(source_size) image = reader.read() if image.isNull(): return _placeholder_pixmap(fallback, size) scaled = QPixmap.fromImage(image).scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) canvas = QPixmap(size) canvas.fill(QColor("#f7f8fa")) painter = QPainter(canvas) painter.drawPixmap( max(0, (size.width() - scaled.width()) // 2), max(0, (size.height() - scaled.height()) // 2), scaled, ) painter.end() return canvas def _user_error(error): text = diagnostics.redact_log_text(str(error or "未知错误")) text = _URL_RE.sub("[链接已隐藏]", text).replace("\r", " ").replace("\n", " ").strip() return text if len(text) <= 90 else text[:87] + "..." class ProductSuitePreviewDialog(ImagePreviewDialog): """Current product-suite wrapper for the shared full-image preview.""" class ProductOriginalDelegate(QStyledItemDelegate): CHECK_HIT_SIZE = 26 @classmethod def checkbox_hit_rect(cls, item_rect): return QRect( item_rect.left() + 2, item_rect.top() + 2, cls.CHECK_HIT_SIZE, cls.CHECK_HIT_SIZE, ) def paint(self, painter, option, index): super().paint(painter, option, index) if index.data(Qt.UserRole) is None: return checked = index.data(ORIGINAL_CHECK_STATE_ROLE) == Qt.Checked style = QApplication.style() width = style.pixelMetric(QStyle.PM_IndicatorWidth) height = style.pixelMetric(QStyle.PM_IndicatorHeight) hit_rect = self.checkbox_hit_rect(option.rect) checkbox = QStyleOptionButton() checkbox.rect = QRect( hit_rect.center().x() - width // 2, hit_rect.center().y() - height // 2, width, height, ) checkbox.state = QStyle.State_Enabled if option.state & QStyle.State_Enabled else QStyle.State_None checkbox.state |= QStyle.State_On if checked else QStyle.State_Off style.drawControl(QStyle.CE_CheckBox, checkbox, painter) class ProductOriginalList(QListWidget): MAX_VISIBLE_ASSETS = 16 EMPTY_STATE_TEXT = "暂无商品原图" filesDropped = Signal(list) clipboardImage = Signal(bytes) orderChanged = Signal(list) deleteRequested = Signal(int) deleteAssetsRequested = Signal(list) checkedAssetsChanged = Signal(list) def __init__(self, parent=None): super().__init__(parent) self.setObjectName("suiteOriginalList") self.setViewMode(QListView.IconMode) self.setResizeMode(QListView.Adjust) self.setMovement(QListView.Snap) self.setWrapping(True) self.setIconSize(QSize(82, 64)) self.setGridSize(QSize(112, 98)) self.setSpacing(4) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setAcceptDrops(True) self.setDragEnabled(True) self.setDropIndicatorShown(True) self.setDragDropMode(QListWidget.InternalMove) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._show_menu) self.itemChanged.connect(lambda item: self.checkedAssetsChanged.emit(self.checked_asset_ids())) self.setItemDelegate(ProductOriginalDelegate(self)) self.setMouseTracking(True) self.itemEntered.connect(self._show_hover_remove) self._hovered_asset_id = None self._hover_remove_button = QToolButton(self.viewport()) self._hover_remove_button.setText("×") self._hover_remove_button.setToolTip("删除这张商品原图") self._hover_remove_button.setAccessibleName("删除商品原图") self._hover_remove_button.setFixedSize(28, 28) self._hover_remove_button.clicked.connect(self._remove_hovered) self._hover_remove_button.hide() self.setToolTip("可拖入或粘贴图片;已有缩略图可拖动排序") def paintEvent(self, event): super().paintEvent(event) if self.count() != 0: return painter = QPainter(self.viewport()) painter.setPen(QColor("#6b7280")) painter.drawText( self.viewport().rect().adjusted(12, 8, -12, -8), Qt.AlignCenter, self.EMPTY_STATE_TEXT, ) painter.end() def dragEnterEvent(self, event): if event.mimeData().hasUrls(): event.acceptProposedAction() return super().dragEnterEvent(event) def dragMoveEvent(self, event): if event.mimeData().hasUrls(): event.acceptProposedAction() return super().dragMoveEvent(event) def dropEvent(self, event): if event.mimeData().hasUrls(): paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()] self.filesDropped.emit(paths) event.acceptProposedAction() return super().dropEvent(event) self.orderChanged.emit(self.asset_ids()) def resizeEvent(self, event): super().resizeEvent(event) self.update_content_height() def mousePressEvent(self, event): position = event.position().toPoint() item = self.itemAt(position) if ( event.button() == Qt.LeftButton and item is not None and item.data(Qt.UserRole) is not None and ProductOriginalDelegate.checkbox_hit_rect(self.visualItemRect(item)).contains(position) ): self._set_item_checked(item, not self._item_checked(item)) event.accept() return super().mousePressEvent(event) def mouseDoubleClickEvent(self, event): position = event.position().toPoint() item = self.itemAt(position) if ( item is not None and item.data(Qt.UserRole) is not None and ProductOriginalDelegate.checkbox_hit_rect(self.visualItemRect(item)).contains(position) ): event.accept() return super().mouseDoubleClickEvent(event) def keyPressEvent(self, event): if event.matches(QKeySequence.Paste): image = QApplication.clipboard().image() if not image.isNull(): payload = QByteArray() buffer = QBuffer(payload) buffer.open(QIODevice.WriteOnly) image.save(buffer, "PNG") buffer.close() self.clipboardImage.emit(bytes(payload)) return if event.key() in {Qt.Key_Delete, Qt.Key_Backspace}: checked_ids = self.checked_asset_ids() if checked_ids: self.deleteAssetsRequested.emit(checked_ids) return item = self.currentItem() if item is not None and item.data(Qt.UserRole) is not None: self.deleteRequested.emit(int(item.data(Qt.UserRole))) return if event.key() == Qt.Key_Space: item = self.currentItem() if item is not None and item.data(Qt.UserRole) is not None: self._set_item_checked(item, not self._item_checked(item)) return super().keyPressEvent(event) def asset_ids(self): return [ int(self.item(row).data(Qt.UserRole)) for row in range(self.count()) if self.item(row).data(Qt.UserRole) is not None ] def checked_asset_ids(self): return [ int(self.item(row).data(Qt.UserRole)) for row in range(self.count()) if self.item(row).data(Qt.UserRole) is not None and self._item_checked(self.item(row)) ] def set_checked_asset_ids(self, asset_ids): checked_ids = {int(asset_id) for asset_id in asset_ids or []} previous = self.blockSignals(True) try: for row in range(self.count()): item = self.item(row) asset_id = item.data(Qt.UserRole) if asset_id is not None: item.setData( ORIGINAL_CHECK_STATE_ROLE, Qt.Checked if int(asset_id) in checked_ids else Qt.Unchecked, ) finally: self.blockSignals(previous) self.viewport().update() self.checkedAssetsChanged.emit(self.checked_asset_ids()) def select_all_assets(self): self.set_checked_asset_ids(self.asset_ids()) def invert_asset_checks(self): checked = set(self.checked_asset_ids()) self.set_checked_asset_ids([asset_id for asset_id in self.asset_ids() if asset_id not in checked]) def clear_checks(self): self.set_checked_asset_ids([]) def content_column_count(self): step = max(1, self.gridSize().width() + self.spacing()) available = max(1, self.viewport().width() - self.spacing()) return max(1, (available + self.spacing()) // step) def content_row_count(self): count = min(self.MAX_VISIBLE_ASSETS, self.count()) if count == 0: return 1 columns = self.content_column_count() return (count + columns - 1) // columns def update_content_height(self): rows = self.content_row_count() target = ( rows * self.gridSize().height() + (rows + 1) * self.spacing() + 2 * self.frameWidth() ) if target != self.height(): self.setFixedHeight(target) def _show_menu(self, position): item = self.itemAt(position) if item is None or item.data(Qt.UserRole) is None: return menu = QMenu(self) actions = [] for label, asset_ids in self.context_delete_options(int(item.data(Qt.UserRole))): actions.append((menu.addAction(label), asset_ids)) selected = menu.exec(self.viewport().mapToGlobal(position)) for action, asset_ids in actions: if selected is action: if len(asset_ids) == 1 and asset_ids[0] not in self.checked_asset_ids(): self.deleteRequested.emit(asset_ids[0]) else: self.deleteAssetsRequested.emit(asset_ids) return def context_delete_options(self, clicked_asset_id): clicked_asset_id = int(clicked_asset_id) checked_ids = self.checked_asset_ids() if clicked_asset_id in checked_ids: if len(checked_ids) > 1: return [("删除选中的%d张图片…" % len(checked_ids), checked_ids)] return [("删除选中图片…", checked_ids)] options = [("删除这张图片…", [clicked_asset_id])] if checked_ids: label = ( "删除选中的%d张图片…" % len(checked_ids) if len(checked_ids) > 1 else "删除选中图片…" ) options.append((label, checked_ids)) return options @staticmethod def _item_checked(item): return item.data(ORIGINAL_CHECK_STATE_ROLE) == Qt.Checked @staticmethod def _set_item_checked(item, checked): item.setData(ORIGINAL_CHECK_STATE_ROLE, Qt.Checked if checked else Qt.Unchecked) def _show_hover_remove(self, item): value = item.data(Qt.UserRole) if value is None: self._hovered_asset_id = None self._hover_remove_button.hide() return self._hovered_asset_id = int(value) rect = self.visualItemRect(item) self._hover_remove_button.move(max(0, rect.right() - 27), rect.top() + 1) self._hover_remove_button.show() self._hover_remove_button.raise_() def _remove_hovered(self): if self._hovered_asset_id is not None: self.deleteRequested.emit(int(self._hovered_asset_id)) def leaveEvent(self, event): self._hovered_asset_id = None self._hover_remove_button.hide() super().leaveEvent(event) class SuiteCategoryRow(QFrame): countChangeRequested = Signal(str, int) renameRequested = Signal(str) deleteRequested = Signal(str) def __init__(self, name, count, *, helper="", custom=False, parent=None): super().__init__(parent) self.category_name = str(name) self._controls_enabled = True self.setObjectName("suiteCategoryRow") self.setProperty("categoryName", self.category_name) self.setStyleSheet( "QFrame#suiteCategoryRow { border: none; border-bottom: 1px solid #eaeef2; }" ) layout = QGridLayout(self) layout.setContentsMargins(6, 5, 4, 6) layout.setHorizontalSpacing(8) layout.setVerticalSpacing(2) layout.setColumnStretch(0, 1) self.name_label = QLabel(self.category_name) self.name_label.setObjectName("suiteCategoryNameLabel") self.name_label.setStyleSheet("font-weight: 600; color: #24292f;") layout.addWidget(self.name_label, 0, 0) controls_widget = QWidget() controls = QHBoxLayout(controls_widget) controls.setContentsMargins(0, 0, 0, 0) controls.setSpacing(4) self.minus_button = QToolButton() self.minus_button.setObjectName("suiteCategoryMinusButton") self.minus_button.setText("−") self.minus_button.setToolTip("减少%s数量" % self.category_name) self.minus_button.setAccessibleName("减少%s数量" % self.category_name) self.minus_button.setMinimumSize(28, 28) self.minus_button.clicked.connect( lambda checked=False: self.countChangeRequested.emit(self.category_name, -1) ) controls.addWidget(self.minus_button) self.count_label = QLabel() self.count_label.setObjectName("suiteCategoryCountLabel") self.count_label.setAlignment(Qt.AlignCenter) self.count_label.setMinimumWidth(28) controls.addWidget(self.count_label) self.plus_button = QToolButton() self.plus_button.setObjectName("suiteCategoryPlusButton") self.plus_button.setText("+") self.plus_button.setToolTip("增加%s数量" % self.category_name) self.plus_button.setAccessibleName("增加%s数量" % self.category_name) self.plus_button.setMinimumSize(28, 28) self.plus_button.clicked.connect( lambda checked=False: self.countChangeRequested.emit(self.category_name, 1) ) controls.addWidget(self.plus_button) self.rename_button = None self.delete_button = None if custom: self.rename_button = QToolButton() self.rename_button.setObjectName("suiteCategoryRenameButton") self.rename_button.setText("改名") self.rename_button.setToolTip("重命名%s分类" % self.category_name) self.rename_button.setAccessibleName("重命名%s分类" % self.category_name) self.rename_button.setMinimumSize(48, 28) self.rename_button.clicked.connect( lambda checked=False: self.renameRequested.emit(self.category_name) ) controls.addWidget(self.rename_button) self.delete_button = QToolButton() self.delete_button.setObjectName("suiteCategoryDeleteButton") self.delete_button.setText("×") self.delete_button.setToolTip("删除%s分类" % self.category_name) self.delete_button.setAccessibleName("删除%s分类" % self.category_name) self.delete_button.setMinimumSize(28, 28) self.delete_button.clicked.connect( lambda checked=False: self.deleteRequested.emit(self.category_name) ) controls.addWidget(self.delete_button) layout.addWidget(controls_widget, 0, 1, 2 if helper else 1, 1, Qt.AlignTop) self.helper_label = None if helper: self.helper_label = QLabel(str(helper)) self.helper_label.setObjectName("suiteCategoryHelperLabel") self.helper_label.setStyleSheet("color: #6b7280;") self.helper_label.setWordWrap(True) layout.addWidget(self.helper_label, 1, 0) self.set_count(count) def set_count(self, count): normalized = max(0, int(count or 0)) self.count_label.setText(str(normalized)) self.minus_button.setEnabled(self._controls_enabled and normalized > 0) def count(self): return int(self.count_label.text() or 0) def set_controls_enabled(self, enabled): self._controls_enabled = bool(enabled) self.minus_button.setEnabled(self._controls_enabled and self.count() > 0) self.plus_button.setEnabled(self._controls_enabled) if self.rename_button is not None: self.rename_button.setEnabled(self._controls_enabled) if self.delete_button is not None: self.delete_button.setEnabled(self._controls_enabled) class SuiteResultCard(QFrame): previewRequested = Signal(object) retryRequested = Signal(object) menuRequested = Signal(object, object) deleteRequested = Signal(object) def __init__(self, job, asset=None, parent=None): super().__init__(parent) self.job = job self.asset = asset self.setObjectName("suiteResultCard") self.setFrameShape(QFrame.StyledPanel) self.setFixedSize(186, 224) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect( lambda point: self.menuRequested.emit(self.job, self.mapToGlobal(point)) ) layout = QVBoxLayout(self) layout.setContentsMargins(7, 7, 7, 7) layout.setSpacing(5) title_row = QHBoxLayout() title = QLabel(str(getattr(job, "job_type", "套图"))) title.setStyleSheet("font-weight: 600; color: #24292f;") title_row.addWidget(title, 1) if asset is not None and _asset_usable(asset): delete_button = QToolButton() delete_button.setText("×") delete_button.setToolTip("删除这张生成图片") delete_button.setAccessibleName("删除生成图片") delete_button.setMinimumSize(28, 28) delete_button.clicked.connect(lambda: self.deleteRequested.emit(self.job)) title_row.addWidget(delete_button) layout.addLayout(title_row) image_label = QLabel() image_label.setAlignment(Qt.AlignCenter) image_label.setFixedSize(170, 150) status = str(getattr(job, "status", "pending") or "pending") if asset is not None and _asset_usable(asset): image_label.setPixmap(_image_pixmap(asset.local_path, QSize(170, 150))) else: label = { "pending": "等待提交", "submitted": "排队中", "running": "生成中", "failed": "生成失败", "expired": "任务过期", "cancelled": "已停止", "succeeded": "图片已删除", }.get(status, "处理中") color = "#ffebe9" if status in {"failed", "expired"} else "#f3f4f6" image_label.setPixmap(_placeholder_pixmap(label, QSize(170, 150), color)) image_label.mousePressEvent = lambda event: ( self.previewRequested.emit(self.job) if event.button() == Qt.LeftButton else None ) layout.addWidget(image_label) footer = QHBoxLayout() status_text = { "pending": "等待提交", "submitted": "已提交", "running": "生成中", "succeeded": "生成成功" if asset is not None and _asset_usable(asset) else "已删除", "failed": "失败:%s" % _user_error(getattr(job, "error", "")), "expired": "已过期", "cancelled": "已停止", }.get(status, status) status_label = QLabel(status_text) status_label.setWordWrap(False) status_label.setToolTip(status_text) footer.addWidget(status_label, 1) if status in {"failed", "expired", "cancelled"}: retry_button = QPushButton("重试") retry_button.setMinimumWidth(52) retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job)) footer.addWidget(retry_button) layout.addLayout(footer) class SuiteHistoryImageCard(QFrame): """Read-only image card used by the product-suite history dialog.""" selected = Signal(object) previewRequested = Signal(object, object) menuRequested = Signal(object, object, object) def __init__(self, job, asset=None, retry_count=0, parent=None): super().__init__(parent) self.job = job self.asset = asset self.retry_count = max(0, int(retry_count or 0)) self._selected = False self.setObjectName("suiteHistoryImageCard") self.setFixedSize(166, 190) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect( lambda point: self.menuRequested.emit( self.job, self.asset, self.mapToGlobal(point), ) ) layout = QVBoxLayout(self) layout.setContentsMargins(6, 6, 6, 6) layout.setSpacing(4) title = QLabel(str(getattr(job, "job_type", "套图") or "套图")) title.setStyleSheet("font-weight: 600; color: #24292f;") title.setAttribute(Qt.WA_TransparentForMouseEvents, True) layout.addWidget(title) image = QLabel() image.setObjectName("suiteHistoryImage") image.setAlignment(Qt.AlignCenter) image.setFixedSize(154, 118) image.setAttribute(Qt.WA_TransparentForMouseEvents, True) status = str(getattr(job, "status", "pending") or "pending") if asset is not None and _asset_usable(asset): image.setPixmap(_image_pixmap(asset.local_path, QSize(154, 118))) else: image.setPixmap( _placeholder_pixmap( "图片文件不可用" if status == "succeeded" else self._status_text(status), QSize(154, 118), "#fff8c5" if status == "succeeded" else "#f3f4f6", ) ) layout.addWidget(image) footer_text = self._status_text(status) if self.retry_count: footer_text += " · 重试%d次" % self.retry_count footer = QLabel(footer_text) footer.setObjectName("suiteHistoryImageStatus") footer.setWordWrap(True) footer.setMaximumHeight(34) footer.setAttribute(Qt.WA_TransparentForMouseEvents, True) layout.addWidget(footer) tooltip = [ "类型:%s" % str(getattr(job, "job_type", "套图") or "套图"), "状态:%s" % self._status_text(status), "生成时间:%s" % _history_time_text(getattr(job, "created_at", "")), ] if self.retry_count: tooltip.append("本槽位已重试%d次" % self.retry_count) if asset is None or not _asset_usable(asset): tooltip.append("本地图片文件不可用") self.setToolTip("\n".join(tooltip)) self._apply_selected_style() @staticmethod def _status_text(status): return { "pending": "等待提交", "submitted": "已提交", "running": "生成中", "succeeded": "生成成功", "failed": "生成失败", "expired": "任务过期", "cancelled": "已停止", }.get(str(status or ""), "处理中") def set_selected(self, selected): self._selected = bool(selected) self._apply_selected_style() def _apply_selected_style(self): border = "#0969da" if self._selected else "#d8dee4" background = "#eef6ff" if self._selected else "#ffffff" self.setStyleSheet( "QFrame#suiteHistoryImageCard {" "border: %dpx solid %s; border-radius: 6px; background: %s;" "}" % (2 if self._selected else 1, border, background) ) def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.selected.emit(self) super().mousePressEvent(event) def mouseDoubleClickEvent(self, event): if event.button() == Qt.LeftButton: self.previewRequested.emit(self.job, self.asset) super().mouseDoubleClickEvent(event) def _history_time_text(value): text = str(value or "").strip().replace("T", " ") if not text: return "时间未知" return text[:19] class ProductSuiteHistoryDialog(QDialog): """Read-only, project-scoped generation history grouped by persisted rounds.""" PAGE_SIZE = 20 def __init__(self, project_id, *, db_path=None, parent=None): super().__init__(parent) self.project_id = int(project_id) self.db_path = db_path self._offset = 0 self._has_more = False self._assets = {} self._selected_card = None self._round_count = 0 self._available_image_count = 0 self._project_unavailable = False self.setObjectName("suiteHistoryDialog") self.setWindowTitle("历史生成记录") self.setModal(False) self.setAttribute(Qt.WA_DeleteOnClose, True) self.setMinimumSize(740, 520) self.resize(940, 680) layout = QVBoxLayout(self) layout.setContentsMargins(14, 14, 14, 14) layout.setSpacing(10) header = QHBoxLayout() header.setSpacing(10) header_text = QVBoxLayout() header_text.setSpacing(2) self.context_label = QLabel() self.context_label.setObjectName("suiteHistoryContextLabel") self.context_label.setStyleSheet("font-weight: 600; color: #24292f;") self.summary_label = QLabel() self.summary_label.setObjectName("suiteHistorySummaryLabel") self.summary_label.setStyleSheet("color: #57606a;") header_text.addWidget(self.context_label) header_text.addWidget(self.summary_label) header.addLayout(header_text, 1) self.refresh_button = QPushButton("刷新") self.refresh_button.setObjectName("suiteHistoryRefreshButton") self.refresh_button.setToolTip("重新读取当前商品的生成历史") self.refresh_button.clicked.connect(self.refresh_history) header.addWidget(self.refresh_button) layout.addLayout(header) self.notice_label = QLabel() self.notice_label.setObjectName("suiteHistoryNoticeLabel") self.notice_label.setWordWrap(True) self.notice_label.hide() layout.addWidget(self.notice_label) self.scroll = QScrollArea() self.scroll.setObjectName("suiteHistoryScrollArea") self.scroll.setWidgetResizable(True) self.history_content = QWidget() self.history_content.setObjectName("suiteHistoryContent") self.history_layout = QVBoxLayout(self.history_content) self.history_layout.setContentsMargins(0, 0, 0, 0) self.history_layout.setSpacing(12) self.history_layout.setAlignment(Qt.AlignTop) self.scroll.setWidget(self.history_content) layout.addWidget(self.scroll, 1) self.load_more_button = QPushButton("加载更多") self.load_more_button.setObjectName("suiteHistoryLoadMoreButton") self.load_more_button.clicked.connect(self.load_more) self.load_more_button.hide() layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter) self.refresh_history() def refresh_history(self, checked=False): scroll_value = self.scroll.verticalScrollBar().value() self._clear_history_content() self._offset = 0 self._has_more = False self._assets = {} self._round_count = 0 self._available_image_count = 0 if not self._load_next_page(): return QTimer.singleShot( 0, lambda: self.scroll.verticalScrollBar().setValue( min(scroll_value, self.scroll.verticalScrollBar().maximum()) ), ) def load_more(self, checked=False): if self._has_more: self._load_next_page() def _load_next_page(self): try: project = image_studio.get_project(self.project_id, path=self.db_path) if project is None: self._set_project_unavailable() return False self._project_unavailable = False self._set_context(project) if not self._assets: self._assets = { int(asset.id): asset for asset in image_studio.list_assets( self.project_id, path=self.db_path, ) } rounds = image_studio.list_generation_rounds( self.project_id, limit=self.PAGE_SIZE, offset=self._offset, path=self.db_path, ) except Exception as exc: self._set_error("历史记录读取失败:%s" % _user_error(exc)) return False self.notice_label.hide() if not rounds and self._offset == 0: self._show_empty_state() else: for round_info in rounds: self._add_round(round_info) self._offset += len(rounds) self._has_more = len(rounds) == self.PAGE_SIZE self.load_more_button.setVisible(self._has_more) self.load_more_button.setEnabled(self._has_more) self._update_summary() return True def _set_context(self, project): account_name = str(project.account_name or project.account_alias or "未命名账号") item_text = "临时草稿" if image_studio.is_draft_project(project) else "商品ID:%s" % project.item_id self.context_label.setText("店铺:%s · %s" % (account_name, item_text)) def _update_summary(self): refreshed = time.strftime("%H:%M:%S") self.summary_label.setText( "已加载 %d 轮 · 可用图片 %d 张 · 最近刷新 %s" % (self._round_count, self._available_image_count, refreshed) ) def _clear_history_content(self): self._selected_card = None while self.history_layout.count(): item = self.history_layout.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() def _set_project_unavailable(self): self._project_unavailable = True self._clear_history_content() self.context_label.setText("当前商品项目不可用") self.summary_label.setText("该商品项目已删除或不可访问") self._set_notice("当前商品项目已删除或不可访问,无法继续读取历史生成记录。", "#cf222e") self.refresh_button.setEnabled(False) self.load_more_button.hide() def _set_error(self, message): self._clear_history_content() self._set_notice(message, "#cf222e") self.load_more_button.hide() self.refresh_button.setEnabled(True) def _set_notice(self, text, color="#57606a"): self.notice_label.setText(str(text)) self.notice_label.setStyleSheet("color: %s; padding: 8px 0;" % color) self.notice_label.show() def _show_empty_state(self): empty = QLabel("暂无历史生成记录,完成套图生成后会自动出现在这里") empty.setObjectName("suiteHistoryEmptyLabel") empty.setAlignment(Qt.AlignCenter) empty.setStyleSheet("color: #6b7280; padding: 56px;") self.history_layout.addWidget(empty) def _add_round(self, round_info): try: jobs = image_studio.list_generation_round_current_jobs( self.project_id, round_info.generation_round_key, path=self.db_path, ) attempts = image_studio.list_generation_round_attempts( self.project_id, round_info.generation_round_key, path=self.db_path, ) except Exception as exc: self._set_error("历史轮次读取失败:%s" % _user_error(exc)) return section = QWidget() section.setObjectName("suiteHistoryRound") section.setStyleSheet( "QWidget#suiteHistoryRound { border-bottom: 1px solid #d8dee4; }" ) layout = QVBoxLayout(section) layout.setContentsMargins(2, 2, 2, 12) layout.setSpacing(7) header = QHBoxLayout() header.setSpacing(8) time_label = QLabel( "旧版历史记录" if round_info.is_legacy else "生成于 %s" % _history_time_text(round_info.created_at) ) time_label.setStyleSheet("font-weight: 600; color: #24292f;") header.addWidget(time_label) if round_info.is_current: current = QLabel("当前") current.setObjectName("suiteHistoryCurrentBadge") current.setStyleSheet( "color: #0969da; background: #ddf4ff; border: 1px solid #54aeff; " "border-radius: 6px; padding: 1px 6px; font-weight: 600;" ) header.addWidget(current) elif round_info.is_legacy: legacy = QLabel("旧版") legacy.setStyleSheet( "color: #57606a; background: #f6f8fa; border: 1px solid #d8dee4; " "border-radius: 6px; padding: 1px 6px;" ) header.addWidget(legacy) header.addStretch(1) stats = self._round_stats_text(round_info) stats_label = QLabel(stats) stats_label.setObjectName("suiteHistoryRoundStats") stats_label.setStyleSheet("color: #57606a;") header.addWidget(stats_label) layout.addLayout(header) attempts_by_slot = {} for attempt in attempts: slot = getattr(attempt, "generation_slot_index", None) if slot is not None: attempts_by_slot[int(slot)] = attempts_by_slot.get(int(slot), 0) + 1 if not jobs: message = QLabel("本轮没有可展示的图片,已保留生成状态记录。") message.setStyleSheet("color: #6b7280; padding: 12px 0;") layout.addWidget(message) else: grid = QGridLayout() grid.setContentsMargins(0, 0, 0, 0) grid.setHorizontalSpacing(8) grid.setVerticalSpacing(8) columns = 5 for index, job in enumerate(jobs): asset = self._assets.get(int(job.output_asset_id or 0)) slot = getattr(job, "generation_slot_index", None) retry_count = max(0, attempts_by_slot.get(int(slot), 1) - 1) if slot is not None else 0 card = SuiteHistoryImageCard(job, asset, retry_count) card.selected.connect(self._select_card) card.previewRequested.connect(self._preview_job) card.menuRequested.connect(self._show_job_menu) grid.addWidget(card, index // columns, index % columns) if asset is not None and _asset_usable(asset): self._available_image_count += 1 layout.addLayout(grid) self.history_layout.addWidget(section) self._round_count += 1 @staticmethod def _round_stats_text(round_info): parts = ["记录 %d" % int(round_info.job_count or 0)] if round_info.slot_count: parts.append("图片 %d" % int(round_info.slot_count)) parts.append("成功 %d" % int(round_info.succeeded_count or 0)) if round_info.failed_count: parts.append("失败 %d" % int(round_info.failed_count)) if round_info.cancelled_count: parts.append("停止 %d" % int(round_info.cancelled_count)) if round_info.active_count: parts.append("进行中 %d" % int(round_info.active_count)) if round_info.retry_count: parts.append("重试 %d" % int(round_info.retry_count)) return " · ".join(parts) def _select_card(self, card): if self._selected_card is card: return if self._selected_card is not None: self._selected_card.set_selected(False) self._selected_card = card self._selected_card.set_selected(True) def _preview_job(self, job, asset): if asset is None or not _asset_usable(asset): self._set_notice("这张图片的本地文件不可用,无法预览。", "#9a6700") return ProductSuitePreviewDialog( asset.local_path, "%s预览" % str(getattr(job, "job_type", "套图") or "套图"), self, ).exec() def _show_job_menu(self, job, asset, global_position): menu = QMenu(self) preview_action = menu.addAction("预览") copy_action = menu.addAction("复制路径") folder_action = menu.addAction("打开所在文件夹") action = menu.exec(global_position) if action is preview_action: self._preview_job(job, asset) elif action is copy_action: if asset is None or not _asset_usable(asset): self._set_notice("当前图片没有可复制的本地路径。", "#9a6700") return QApplication.clipboard().setText(asset.local_path) self._set_notice("图片路径已复制。", "#1a7f37") elif action is folder_action: if asset is None or not _asset_usable(asset): self._set_notice("当前图片没有可打开的本地文件夹。", "#9a6700") return try: file_manager.open_in_file_manager(os.path.dirname(asset.local_path)) except Exception as exc: self._set_notice("打开文件夹失败:%s" % _user_error(exc), "#cf222e") def closeEvent(self, event): self._clear_history_content() super().closeEvent(event) @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 = "" worker: object = None thread: object = None generation_run_token: str = "" generation_stop_requested: bool = False generation_terminal_streak: int = 0 pull_worker: object = None pull_thread: object = None pull_run_token: str = "" pull_stop_requested: bool = False pull_cleanup_mode: str = "keep" pull_before_asset_ids: set = field(default_factory=set) pull_before_asset_states: list = field(default_factory=list) pull_asset_ids: set = field(default_factory=set) pull_download_asset_ids: set = field(default_factory=set) pull_download_failed: int = 0 pull_started_at: float = None import_worker: object = None import_thread: object = None import_created_draft: bool = False ai_worker: object = None ai_thread: object = None download_queue: list = field(default_factory=list) downloads: dict = field(default_factory=dict) download_tokens: dict = field(default_factory=dict) done: int = 0 failed: int = 0 total: int = 0 started_at: float = None ai_started_at: float = None ai_prompt_snapshot: str = "" pending_ai_text: str = "" undo_records: list = field(default_factory=list) def generation_running(self): return self.worker is not None def pull_running(self): return bool(self.pull_run_token) class ProductSuiteTab(QWidget): """Native PySide6 product-suite UI backed by image_studio services.""" PLATFORM_OPTIONS = product_suite.PLATFORMS COUNTRY_OPTIONS = product_suite.COUNTRIES LANGUAGE_OPTIONS = product_suite.LANGUAGES def __init__( self, parent=None, db_path=None, config=None, config_path=None, status_callback=None, ): super().__init__(parent) self.setObjectName("productSuiteTab") self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config self.db_path = db_path or appconfig.db_path(self.config) self.config_path = config_path or self.config.get("config_path") or appconfig.CONFIG_PATH self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config) self.product_suite_prompt_path = appconfig.product_suite_prompt_path(self.config) self.status_callback = status_callback self.accounts = [] self._states = {} self._retired_states = [] self._next_key = 1 self._next_serial = 1 self._displayed_state = None self._prompt_save_timers = {} self._generation_run_states = {} self._pull_run_states = {} self._original_list_context = None self._loading = False self._result_refresh_pending = False self._prompt_template_init_error = "" self._history_dialog = None try: prompts.ensure_default_product_suite_prompt(self.product_suite_prompt_path) except prompts.PromptError as exc: self._prompt_template_init_error = str(exc) self._build_ui() self._connect_signals() self.refresh_accounts() self._restore_draft_tasks() if not self._states: self.add_task(inherit=False) self.elapsed_timer = QTimer(self) self.elapsed_timer.setInterval(1000) self.elapsed_timer.timeout.connect(self._refresh_elapsed) self.elapsed_timer.start() self.generation_watchdog_timer = QTimer(self) self.generation_watchdog_timer.setInterval(750) self.generation_watchdog_timer.timeout.connect(self._check_generation_watchdogs) self.generation_watchdog_timer.start() if self._prompt_template_init_error: self._status(self._prompt_template_init_error, "danger") def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(10, 8, 10, 8) root.setSpacing(8) root.addWidget(self._build_task_bar()) root.addWidget(self._build_context_bar()) self.main_splitter = QSplitter(Qt.Horizontal) self.main_splitter.setObjectName("suiteMainSplitter") self.main_splitter.addWidget(self._build_config_panel()) self.main_splitter.addWidget(self._build_results_panel()) self.main_splitter.setStretchFactor(0, 2) self.main_splitter.setStretchFactor(1, 3) self.main_splitter.setSizes([430, 700]) root.addWidget(self.main_splitter, 1) def _build_task_bar(self): frame = QFrame() layout = QHBoxLayout(frame) layout.setContentsMargins(0, 0, 0, 0) self.task_tabs = QTabBar() self.task_tabs.setObjectName("suiteTaskTabs") self.task_tabs.setTabsClosable(True) self.task_tabs.setMovable(True) self.task_tabs.setExpanding(False) self.task_tabs.setFixedHeight(36) self.task_tabs.setStyleSheet( """ QTabBar#suiteTaskTabs::tab { min-width: 96px; min-height: 26px; max-height: 26px; padding: 3px 10px; margin-right: 4px; border: 1px solid #c9d1d9; border-bottom-color: #b8c0ca; background: #f4f6f8; color: #24292f; } QTabBar#suiteTaskTabs::tab:selected { background: #ffffff; border-color: #687785; border-bottom-color: #ffffff; font-weight: 600; } QTabBar#suiteTaskTabs::tab:hover:!selected { background: #eaf2ff; } """ ) layout.addWidget(self.task_tabs, 1) self.add_task_button = QToolButton() self.add_task_button.setObjectName("suiteAddTaskButton") self.add_task_button.setText("+") self.add_task_button.setToolTip("新建套图任务") self.add_task_button.setAccessibleName("新建套图任务") self.add_task_button.setMinimumSize(32, 32) layout.addWidget(self.add_task_button) return frame def _build_context_bar(self): frame = QFrame() frame.setObjectName("suiteContextBar") layout = QHBoxLayout(frame) self.context_bar_layout = layout layout.setContentsMargins(10, 7, 10, 7) layout.setSpacing(8) self.history_button = QPushButton("历史生成") self.history_button.setObjectName("suiteHistoryButton") self.history_button.setToolTip("查看当前商品的历史生成记录") layout.addWidget(self.history_button) self.open_folder_button = QPushButton("打开结果文件夹") self.open_folder_button.setObjectName("suiteOpenFolderButton") layout.addWidget(self.open_folder_button) self.add_images_button = QPushButton("添加图片") self.add_images_button.setObjectName("suiteAddImagesButton") self.add_images_button.setAccessibleName("添加商品原图") self.add_images_button.setToolTip("添加本地商品原图") self.add_images_button.setStyleSheet( "QPushButton#suiteAddImagesButton { " "color: #0969da; border: 1px solid #0969da; " "background-color: #eef4ff; font-weight: 600; " "}" "QPushButton#suiteAddImagesButton:hover { background-color: #dbeafe; }" "QPushButton#suiteAddImagesButton:pressed { background-color: #c7ddff; }" "QPushButton#suiteAddImagesButton:disabled { " "color: #8c959f; border-color: #d8dee4; background-color: #f6f8fa; " "}" ) layout.addWidget(self.add_images_button) self.item_id_hint_label = QLabel("请输入正确的商品ID") self.item_id_hint_label.setObjectName("suiteItemIdHintLabel") self.item_id_hint_label.setStyleSheet("color: #9a6700; font-weight: 600;") self.item_id_hint_label.setToolTip("未绑定正式商品ID时,可添加本地图片和生成套图,但不能拉取蝦皮主图") self.item_id_hint_label.setAccessibleName("商品ID提示") self.item_id_hint_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred) layout.addWidget(self.item_id_hint_label) layout.addStretch(1) layout.addWidget(QLabel("账号")) self.account_combo = QComboBox() self.account_combo.setObjectName("suiteAccountCombo") self.account_combo.setMinimumWidth(120) self.account_combo.setMaximumWidth(160) self.account_combo.setMinimumContentsLength(10) layout.addWidget(self.account_combo) layout.addWidget(QLabel("商品ID")) self.item_id_edit = QLineEdit() self.item_id_edit.setObjectName("suiteItemIdEdit") self.item_id_edit.setPlaceholderText("输入商品ID") item_width = max( 120, min(140, self.item_id_edit.fontMetrics().horizontalAdvance("0" * 13) + 30), ) self.item_id_edit.setMinimumWidth(item_width) self.item_id_edit.setMaximumWidth(item_width) layout.addWidget(self.item_id_edit) self.pull_button = QPushButton("拉取蝦皮主图") self.pull_button.setObjectName("suitePullButton") layout.addWidget(self.pull_button) return frame def _build_config_panel(self): panel = QWidget() panel.setMinimumWidth(400) layout = QVBoxLayout(panel) layout.setContentsMargins(0, 0, 4, 0) layout.setSpacing(7) scroll = QScrollArea() scroll.setObjectName("suiteConfigScroll") scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.NoFrame) content = QWidget() self.config_layout = QVBoxLayout(content) self.config_layout.setContentsMargins(4, 2, 8, 2) self.config_layout.setSpacing(9) self.config_layout.addWidget(self._build_original_section()) self.config_layout.addWidget(self._build_settings_section()) self.config_layout.addWidget(self._build_prompt_section()) self.config_layout.addWidget(self._build_category_section()) self.config_layout.addStretch(1) scroll.setWidget(content) layout.addWidget(scroll, 1) self.generate_button = QPushButton("生成套图(0)") self.generate_button.setObjectName("suiteGenerateButton") self.generate_button.setMinimumHeight(42) self.generate_button.setStyleSheet( "QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }" "QPushButton:hover { background: #245fce; }" ) layout.addWidget(self.generate_button) self.generate_helper_label = QLabel("建议填写产品名称、核心卖点、目标人群、使用场景与禁用元素") self.generate_helper_label.setWordWrap(True) self.generate_helper_label.setStyleSheet("color: #6b7280;") layout.addWidget(self.generate_helper_label) return panel def _build_original_section(self): frame = QFrame() layout = QVBoxLayout(frame) layout.setContentsMargins(0, 0, 0, 0) title_row = QHBoxLayout() title = QLabel("商品原图") title.setStyleSheet("font-weight: 600;") title_row.addWidget(title) self.original_count_label = QLabel("0/16") self.original_count_label.setStyleSheet("color: #6b7280;") title_row.addWidget(self.original_count_label) title_row.addStretch(1) self.original_selected_label = QLabel("已选 0 张") self.original_selected_label.setObjectName("suiteOriginalSelectedLabel") self.original_selected_label.setStyleSheet("color: #6b7280;") title_row.addWidget(self.original_selected_label) self.select_all_originals_button = QToolButton() self.select_all_originals_button.setObjectName("suiteSelectAllOriginalsButton") self.select_all_originals_button.setText("全选") self.select_all_originals_button.setToolTip("选择当前商品的全部原图") self.select_all_originals_button.setAccessibleName("全选商品原图") self.select_all_originals_button.setMinimumSize(48, 28) title_row.addWidget(self.select_all_originals_button) self.invert_originals_button = QToolButton() self.invert_originals_button.setObjectName("suiteInvertOriginalsButton") self.invert_originals_button.setText("反选") self.invert_originals_button.setToolTip("反转当前商品原图的勾选状态") self.invert_originals_button.setAccessibleName("反选商品原图") self.invert_originals_button.setMinimumSize(48, 28) title_row.addWidget(self.invert_originals_button) layout.addLayout(title_row) self.original_list = ProductOriginalList() layout.addWidget(self.original_list) return frame def _build_settings_section(self): frame = QFrame() layout = QVBoxLayout(frame) layout.setContentsMargins(0, 0, 0, 0) title = QLabel("生成设置") title.setStyleSheet("font-weight: 600;") layout.addWidget(title) grid = QGridLayout() self.settings_grid = grid grid.setContentsMargins(0, 0, 0, 0) grid.setHorizontalSpacing(6) grid.setVerticalSpacing(3) settings = ( ( "platform_label", "platform_combo", "平台", "suitePlatformLabel", "suitePlatformCombo", self.PLATFORM_OPTIONS, ), ( "country_label", "country_combo", "站点", "suiteCountryLabel", "suiteCountryCombo", self.COUNTRY_OPTIONS, ), ( "language_label", "language_combo", "语言", "suiteLanguageLabel", "suiteLanguageCombo", self.LANGUAGE_OPTIONS, ), ( "ratio_label", "ratio_combo", "比例", "suiteRatioLabel", "suiteRatioCombo", product_suite.RATIOS, ), ) for column, setting in enumerate(settings): label_attr, combo_attr, text, label_name, combo_name, values = setting label = QLabel(text) label.setObjectName(label_name) setattr(self, label_attr, label) combo = self._value_combo(combo_name, text, values) setattr(self, combo_attr, combo) grid.addWidget(label, 0, column) grid.addWidget(combo, 1, column) grid.setColumnStretch(column, 1) layout.addLayout(grid) self.per_image_checkbox = QCheckBox("每张上传图分别作为主图生成") self.per_image_checkbox.setObjectName("suitePerImageCheckbox") layout.addWidget(self.per_image_checkbox) return frame def _value_combo(self, object_name, label, values): combo = QComboBox() combo.setObjectName(object_name) combo.setToolTip("%s设置" % label) combo.setAccessibleName("%s设置" % label) combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) for value in values: combo.addItem(value, value) combo.setItemData(combo.count() - 1, value, Qt.ToolTipRole) return combo def _build_prompt_section(self): frame = QFrame() layout = QVBoxLayout(frame) layout.setContentsMargins(0, 0, 0, 0) title_row = QHBoxLayout() self.prompt_title_layout = title_row title = QLabel("商品卖点与要求") self.prompt_title_label = title title.setStyleSheet("font-weight: 600;") title_row.addWidget(title) self.ai_write_button = QPushButton("AI 帮写") self.ai_write_button.setObjectName("suiteAiWriteButton") title_row.addWidget(self.ai_write_button) self.ai_cancel_button = QPushButton("取消") self.ai_cancel_button.setObjectName("suiteAiCancelButton") self.ai_cancel_button.hide() title_row.addWidget(self.ai_cancel_button) title_row.addStretch(1) self.prompt_settings_button = QPushButton("提示词设置") self.prompt_settings_button.setObjectName("suitePromptSettingsButton") self.prompt_settings_button.setToolTip("编辑并预览套图最终提示词") title_row.addWidget(self.prompt_settings_button) layout.addLayout(title_row) self.prompt_edit = AutoHeightPlainTextEdit() self.prompt_edit.setObjectName("suitePromptEdit") self.prompt_edit.setPlaceholderText("输入产品名称、卖点、目标人群、使用场景和禁用元素") layout.addWidget(self.prompt_edit) helper = QLabel("内容越具体,生成的商品套图越稳定。AI 帮写不会阻塞其他套图任务。") helper.setWordWrap(True) helper.setStyleSheet("color: #6b7280;") layout.addWidget(helper) return frame def _build_category_section(self): frame = QFrame() layout = QVBoxLayout(frame) layout.setContentsMargins(0, 0, 0, 0) title_row = QHBoxLayout() title = QLabel("套图结构配置") title.setStyleSheet("font-weight: 600;") title_row.addWidget(title) title_row.addStretch(1) self.category_total_label = QLabel("合计 0 张") self.category_total_label.setStyleSheet("color: #2f6fed; font-weight: 600;") title_row.addWidget(self.category_total_label) layout.addLayout(title_row) self.category_grid = QGridLayout() self.category_grid.setContentsMargins(0, 0, 0, 0) self.category_grid.setHorizontalSpacing(0) self.category_grid.setVerticalSpacing(3) self.category_grid.setColumnStretch(0, 1) self.category_rows = {} layout.addLayout(self.category_grid) self.add_category_button = QPushButton("添加自定义分类") self.add_category_button.setObjectName("suiteAddCategoryButton") layout.addWidget(self.add_category_button) self.custom_category_edit = QLineEdit() self.custom_category_edit.setObjectName("suiteCustomCategoryEdit") self.custom_category_edit.setPlaceholderText("输入分类名称后按回车") self.custom_category_edit.hide() layout.addWidget(self.custom_category_edit) return frame def _build_results_panel(self): panel = QWidget() layout = QVBoxLayout(panel) layout.setContentsMargins(4, 0, 0, 0) layout.setSpacing(7) toolbar = QHBoxLayout() self.results_toolbar_layout = toolbar title = QLabel("生成结果") title.setStyleSheet("font-weight: 600; font-size: 15px;") toolbar.addWidget(title) self.result_summary_label = QLabel("共 0 张 · 成功 0 张") self.result_summary_label.setObjectName("suiteResultSummaryLabel") self.result_summary_label.setStyleSheet( "background: #eef4ff; color: #2459c4; padding: 3px 8px; border-radius: 4px;" ) toolbar.addWidget(self.result_summary_label) toolbar.addStretch(1) self.undo_button = QPushButton("撤销删除") self.undo_button.setObjectName("suiteUndoButton") self.undo_button.hide() toolbar.addWidget(self.undo_button) self.more_button = QToolButton() self.more_button.setText("⋯") self.more_button.setToolTip("更多操作") self.more_button.setAccessibleName("更多操作") self.more_button.setMinimumSize(32, 32) toolbar.addWidget(self.more_button) layout.addLayout(toolbar) self.result_scroll = QScrollArea() self.result_scroll.setObjectName("suiteResultScroll") self.result_scroll.setWidgetResizable(True) self.result_scroll.setFrameShape(QFrame.StyledPanel) self.result_content = QWidget() self.result_grid = QGridLayout(self.result_content) self.result_grid.setContentsMargins(12, 12, 12, 12) self.result_grid.setSpacing(10) self.result_grid.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.result_scroll.setWidget(self.result_content) layout.addWidget(self.result_scroll, 1) bottom = QHBoxLayout() self.progress_bar = QProgressBar() self.progress_bar.setObjectName("suiteProgressBar") self.progress_bar.setRange(0, 1) self.progress_bar.setValue(0) self.progress_bar.setTextVisible(False) bottom.addWidget(self.progress_bar, 1) self.elapsed_label = QLabel("套图 0/0(0秒) · 失败 0") self.elapsed_label.setObjectName("suiteElapsedLabel") self.elapsed_label.setMinimumWidth(190) bottom.addWidget(self.elapsed_label) layout.addLayout(bottom) return panel def _connect_signals(self): self.task_tabs.currentChanged.connect(self._on_task_changed) self.task_tabs.tabCloseRequested.connect(self.close_task) self.add_task_button.clicked.connect(lambda: self.add_task(inherit=True)) self.account_combo.currentIndexChanged.connect(self._on_account_changed) self.item_id_edit.editingFinished.connect(self._on_item_finished) self.pull_button.clicked.connect(self.pull_main_images) self.add_images_button.clicked.connect(self.choose_images) self.original_list.filesDropped.connect(self.import_files) self.original_list.clipboardImage.connect(self.import_clipboard_image) self.original_list.orderChanged.connect(self.reorder_originals) self.original_list.deleteRequested.connect(self.delete_original) self.original_list.deleteAssetsRequested.connect(self.delete_originals) self.original_list.checkedAssetsChanged.connect(self._on_original_checks_changed) self.original_list.itemClicked.connect(self._on_original_clicked) self.original_list.itemDoubleClicked.connect(self._preview_original) self.select_all_originals_button.clicked.connect( lambda checked=False: self.original_list.select_all_assets() ) self.invert_originals_button.clicked.connect( lambda checked=False: self.original_list.invert_asset_checks() ) for combo in ( self.platform_combo, self.country_combo, self.language_combo, self.ratio_combo, ): combo.currentIndexChanged.connect(self._on_settings_changed) self.per_image_checkbox.toggled.connect(self._on_settings_changed) self.prompt_edit.textChanged.connect(self._on_prompt_changed) self.ai_write_button.clicked.connect(self.start_ai_write) self.ai_cancel_button.clicked.connect(self.cancel_ai_write) self.prompt_settings_button.clicked.connect(self.open_prompt_settings) self.add_category_button.clicked.connect(self.add_custom_category) self.custom_category_edit.returnPressed.connect(self._commit_custom_category) self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit) self.generate_button.clicked.connect(self.toggle_generation) self.history_button.clicked.connect(self.open_history_dialog) self.open_folder_button.clicked.connect(self.open_project_folder) self.undo_button.clicked.connect(self.undo_delete) self.more_button.clicked.connect(self._show_more_menu) def _status(self, message, level=None): _emit_status(self.status_callback, message, level=level) def _message(self, title, message, icon=QMessageBox.Warning): box = QMessageBox(self) box.setIcon(icon) box.setWindowTitle(str(title)) box.setText(str(message)) box.exec() def _confirm( self, title, message, *, destructive=False, confirm_text="确认", cancel_text="取消", ): box = QMessageBox(self) box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question) box.setWindowTitle(str(title)) box.setText(str(message)) confirm_button = box.addButton(str(confirm_text), QMessageBox.AcceptRole) box.addButton(str(cancel_text), QMessageBox.RejectRole) if not destructive: box.setDefaultButton(confirm_button) box.exec() return box.clickedButton() is confirm_button def 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() if state.ai_worker is not None: state.ai_worker.cancel() if state.pull_running(): state.pull_stop_requested = True state.pull_cleanup_mode = "keep" if state.pull_worker is not None: state.pull_worker.cancel() self._cancel_pull_downloads(state) if image_studio.is_draft_project(project): if draft_action == "delete": try: image_studio.soft_delete_project( project.id, reason="用户关闭临时草稿", path=self.db_path, ) except Exception as exc: self._message("删除临时草稿失败", _user_error(exc)) return self._status("临时草稿已删除", "success") else: try: image_studio.discard_empty_draft_project(project.id, path=self.db_path) except Exception as exc: self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger") 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) if state.project_id is not None: self._close_history_dialog_for_project(state.project_id) self._retired_states.append(state) self._states.pop(state.key, None) self.task_tabs.removeTab(index) if self.task_tabs.count() == 0: self.add_task(inherit=False) def _draft_close_action(self): box = QMessageBox(self) box.setIcon(QMessageBox.Question) box.setWindowTitle("关闭临时草稿") box.setText("当前临时草稿包含图片或生成记录。请选择关闭后的处理方式。") keep_button = box.addButton("保留草稿", QMessageBox.AcceptRole) delete_button = box.addButton("删除草稿", QMessageBox.DestructiveRole) cancel_button = box.addButton("取消", QMessageBox.RejectRole) box.setDefaultButton(keep_button) box.exec() clicked = box.clickedButton() if clicked is keep_button: return "keep" if clicked is delete_button: return "delete" if clicked is cancel_button: return "cancel" return "cancel" def _state_for_index(self, index): if index < 0: return None return self._states.get(self.task_tabs.tabData(index)) def _on_task_changed(self, index): if self._loading: return if self._displayed_state is not None: self._save_controls_to_state(self._displayed_state) self._flush_prompt_save(self._displayed_state) state = self._state_for_index(index) self._displayed_state = state if state is not None: self._load_state(state) self._offer_pending_ai_result(state) def _save_controls_to_state(self, state): if self._loading or state is None: return state.account_alias = str(self.account_combo.currentData() or "") state.item_id = self.item_id_edit.text().strip() state.prompt = self.prompt_edit.toPlainText() state.settings = self._settings_from_controls() def _load_state(self, state): self._sync_state_project_binding(state) self._restore_current_generation_results(state) self._loading = True try: self.custom_category_edit.hide() self.add_category_button.show() index = self.account_combo.findData(state.account_alias) if index >= 0: self.account_combo.setCurrentIndex(index) self.item_id_edit.setText(state.item_id) self.prompt_edit.setPlainText(state.prompt) self._set_combo_value(self.platform_combo, state.settings.get("platform")) self._set_combo_value(self.country_combo, state.settings.get("country")) self._set_combo_value(self.language_combo, state.settings.get("language")) self._set_combo_value(self.ratio_combo, state.settings.get("ratio")) self.per_image_checkbox.setChecked(bool(state.settings.get("per_image_primary"))) finally: self._loading = False self._refresh_originals(state) self._rebuild_categories(state) self._refresh_results(state) self._apply_running_state(state) self._refresh_elapsed() def _set_combo_value(self, combo, value): index = combo.findData(value) if index >= 0: combo.setCurrentIndex(index) def _settings_from_controls(self): current = self._displayed_state.settings if self._displayed_state else {} settings = dict(current or {}) settings.update( { "platform": self.platform_combo.currentData(), "country": self.country_combo.currentData(), "language": self.language_combo.currentData(), "ratio": self.ratio_combo.currentData(), "per_image_primary": self.per_image_checkbox.isChecked(), } ) return product_suite.normalize_suite_settings(settings) def _on_account_changed(self, index): self._update_account_tooltip() if self._loading or self._displayed_state is None: return state = self._displayed_state alias = str(self.account_combo.currentData() or "") if state.project_id is not None and alias != state.account_alias: if self._is_draft_state(state): self._message( "不能切换账号", "临时草稿已关联当前账号。请先绑定正式商品ID,或关闭草稿后再切换账号。", ) self._loading = True try: self._set_combo_value(self.account_combo, state.account_alias) finally: self._loading = False return if not self._confirm( "切换账号", "切换账号后,当前任务会改为新的账号与商品上下文。确认继续吗?", ): self._loading = True try: self._set_combo_value(self.account_combo, state.account_alias) finally: self._loading = False return self._clear_project_binding(state) state.account_alias = alias self._persist_last_account_alias(alias) self._update_context_actions(state) def _update_account_tooltip(self): self.account_combo.setToolTip(self.account_combo.currentText()) def _on_item_finished(self): if self._loading or self._displayed_state is None: return state = self._displayed_state item_id = self.item_id_edit.text().strip() if item_id and not item_id.isdigit(): self.item_id_edit.setStyleSheet("border: 1px solid #9a6700;") self._status("商品ID只能输入数字", "warning") self._update_context_actions(state) return self.item_id_edit.setStyleSheet("") if self._is_draft_state(state): if not item_id: state.item_id = "" self._update_context_actions(state) return if not self._confirm( "绑定正式商品", "将当前临时草稿绑定到商品%s吗?\n已添加图片和生成记录会继续保留。" % item_id, ): self._loading = True try: self.item_id_edit.setText(state.item_id) finally: self._loading = False self._update_context_actions(state) return self._flush_prompt_save(state) try: project = image_studio.bind_draft_project( state.project_id, item_id, path=self.db_path, ) except image_studio.ImageStudioProjectConflictError as exc: self._message("商品项目已存在", _user_error(exc)) self._loading = True try: self.item_id_edit.setText(state.item_id) finally: self._loading = False self._update_context_actions(state) return except Exception as exc: self._message("绑定正式商品失败", _user_error(exc)) self._loading = True try: self.item_id_edit.setText(state.item_id) finally: self._loading = False self._update_context_actions(state) return state.item_id = project.item_id state.project_binding_state = project.binding_state self._set_task_title(state) self._status("临时草稿已绑定商品%s" % project.item_id, "success") self._update_context_actions(state) return if state.project_id is not None and item_id != state.item_id: if not self._confirm( "切换商品", "切换商品ID后,原图和生成结果会按新商品重新载入。确认继续吗?", ): self._loading = True try: self.item_id_edit.setText(state.item_id) finally: self._loading = False return self._clear_project_binding(state) state.item_id = item_id if not item_id: self._update_context_actions(state) return if item_id and state.account_alias: self._bind_project(state, load_existing=True) self._update_context_actions(state) def _clear_project_binding(self, state): self._flush_prompt_save(state) state.project_id = None state.project_binding_state = "" state.last_saved_prompt = "" state.current_job_ids = [] state.generation_job_ids = [] state.generation_mode = "batch" state.generation_retry_job_id = None state.pull_run_token = "" state.pull_stop_requested = False state.pull_before_asset_ids = set() state.pull_before_asset_states = [] state.pull_asset_ids = set() state.pull_download_asset_ids = set() state.pull_download_failed = 0 state.done = state.failed = state.total = 0 state.started_at = None if state is self._displayed_state: self._refresh_originals(state) self._refresh_results(state) def _account_for_alias(self, alias): return next((account for account in self.accounts if account.alias == alias), None) def _account_context_label(self, state): account = self._account_for_alias(getattr(state, "account_alias", "")) alias = str(getattr(state, "account_alias", "") or "") name = str(getattr(account, "account_name", "") or "").strip() if name and name != alias: return "%s(%s)" % (name, alias) return name or alias or "未选择店铺" def _state_project(self, state, *, include_deleted=False): if state is None or state.project_id is None: return None try: project = image_studio.get_project( state.project_id, path=self.db_path, include_deleted=include_deleted, ) except Exception as exc: self._status("商品套图项目读取失败:%s" % _user_error(exc), "danger") return None if project is not None: state.project_binding_state = project.binding_state return project def _sync_state_project_binding(self, state): return self._state_project(state) def _is_draft_state(self, state): if state is None: return False if state.project_binding_state in image_studio.PROJECT_BINDING_STATES: return state.project_binding_state == image_studio.PROJECT_BINDING_DRAFT project = self._state_project(state) return image_studio.is_draft_project(project) def _has_account_context(self, state, *, show_message=True): if state is None or not state.account_alias: if show_message: self._message("未选择账号", "请先在顶部选择账号。") return False if self._account_for_alias(state.account_alias) is None: if show_message: self._message("账号不可用", "所选账号不存在,请到④账号管理刷新账号。") return False return True def _valid_context(self, state, *, show_message=True): if not self._has_account_context(state, show_message=show_message): return False if not state.item_id or not state.item_id.isdigit(): if show_message: self._message("商品ID无效", "请输入正确的数字商品ID。") return False return True def _ensure_project_for_local_work(self, state): if state is None: return None if state.project_id is not None: 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 "未绑定商品" else: settings = product_suite.default_suite_settings() base_prompt = "" item_id = "未绑定商品" dialog = ProductSuitePromptDialog( prompt_path=self.product_suite_prompt_path, base_prompt=base_prompt, settings=settings, item_id=item_id, parent=self, ) self.prompt_settings_dialog = dialog dialog.exec() def _update_context_actions(self, state): is_draft = self._is_draft_state(state) pull_running = bool(state and state.pull_running()) self.pull_button.setEnabled( state is not None and ( pull_running or is_draft or self._valid_context(state, show_message=False) ) ) self.pull_button.setToolTip( "正在停止拉取" if pull_running and state.pull_stop_requested else "停止拉取蝦皮主图" if pull_running else "需要先绑定正式商品ID" if is_draft else "拉取蝦皮主图" ) self._refresh_item_id_hint(state) self._refresh_add_images_action(state) def _refresh_item_id_hint(self, state): displayed_item_id = ( self.item_id_edit.text().strip() if state is self._displayed_state else str(state.item_id or "").strip() ) item_id_invalid = bool(displayed_item_id and not image_studio.is_formal_item_id(displayed_item_id)) is_bound = bool( state and state.project_binding_state == image_studio.PROJECT_BINDING_BOUND and image_studio.is_formal_item_id(state.item_id) and not item_id_invalid ) self.item_id_hint_label.setVisible(not is_bound) if self._is_draft_state(state): tooltip = "当前为临时草稿,可添加本地图片和生成套图;绑定正式商品ID后才能拉取蝦皮主图" elif item_id_invalid: tooltip = "商品ID只能输入数字;修正后可绑定或拉取蝦皮主图" else: tooltip = "未绑定正式商品ID时,可添加本地图片和生成套图,但不能拉取蝦皮主图" self.item_id_hint_label.setToolTip(tooltip) def _refresh_add_images_action(self, state): asset_count = len(self._original_assets(state, include_missing=True)) if state else 0 full = asset_count >= image_studio_images.MAX_ORIGINAL_ASSETS generation_running = bool(state and state.generation_running()) importing = bool(state and state.import_worker is not None) self.add_images_button.setEnabled( state is not None and self._has_account_context(state, show_message=False) and not full and not generation_running and not importing ) if not self._has_account_context(state, show_message=False): tooltip = "请先选择账号" elif full: tooltip = "已达到16张商品原图上限" elif generation_running: tooltip = "生成中不能添加商品原图" elif importing: tooltip = "正在添加商品原图" else: tooltip = "添加本地商品原图" self.add_images_button.setToolTip(tooltip) def _start_thread(self, worker, name): thread = run_worker(worker, thread_name=name, start=False) token = id(thread) _PRODUCT_SUITE_THREAD_REFS[token] = (thread, worker) thread.finished.connect(lambda token=token: _PRODUCT_SUITE_THREAD_REFS.pop(token, None)) run_token = str(getattr(worker, "run_token", "") or "") if run_token: thread.setProperty("productSuiteRunToken", run_token) thread.finished.connect(self._on_generation_thread_finished_signal) pull_run_token = str(getattr(worker, "pull_run_token", "") or "") if pull_run_token: thread.setProperty("productSuitePullRunToken", pull_run_token) thread.finished.connect(self._on_pull_thread_finished_signal) thread.start() return thread def _refresh_originals(self, state, *, preserve_checks=True): context = (state.key, state.project_id) if state is not None else None checked_ids = ( set(self.original_list.checked_asset_ids()) if preserve_checks and context == self._original_list_context else set() ) previous = self.original_list.blockSignals(True) self.original_list.clear() assets = self._original_assets(state, include_missing=False) try: for index, asset in enumerate(assets, 1): label = "主图" if index == 1 else "参考%d" % (index - 1) item = QListWidgetItem(label) item.setData(Qt.UserRole, int(asset.id)) item.setData( ORIGINAL_CHECK_STATE_ROLE, Qt.Checked if int(asset.id) in checked_ids else Qt.Unchecked, ) if _asset_usable(asset): item.setIcon(QIcon(_image_pixmap(asset.local_path, QSize(82, 64)))) item.setToolTip("%s,勾选可批量删除;双击预览;拖动可调整顺序" % label) else: item.setIcon(QIcon(_placeholder_pixmap("待下载", QSize(82, 64)))) item.setToolTip("%s尚未下载;勾选可批量删除,单击缩略图后台拉取" % label) self.original_list.addItem(item) finally: self.original_list.blockSignals(previous) self._original_list_context = context self.original_list.update_content_height() self.original_list.viewport().update() self.original_count_label.setText("%d/16" % len(assets)) self._refresh_original_selection_controls() self._refresh_add_images_action(state) self._refresh_totals(state) def _on_original_checks_changed(self, asset_ids): self._refresh_original_selection_controls() def _refresh_original_selection_controls(self): checked_count = len(self.original_list.checked_asset_ids()) asset_count = len(self.original_list.asset_ids()) self.original_selected_label.setText("已选 %d 张" % checked_count) state = self._displayed_state enabled = bool(asset_count) and state is not None and not state.generation_running() self.select_all_originals_button.setEnabled(enabled) self.invert_originals_button.setEnabled(enabled) def _original_assets(self, state, *, include_missing=False): if state is None or state.project_id is None: return [] try: return image_studio.list_assets( state.project_id, kind=image_studio.ASSET_KIND_ORIGINAL, include_missing=include_missing, path=self.db_path, ) except Exception as exc: self._status("商品原图读取失败:%s" % _user_error(exc), "danger") return [] def choose_images(self, checked=False): paths, _ = QFileDialog.getOpenFileNames( self, "选择商品原图", "", "图片文件 (*.png *.jpg *.jpeg *.webp *.bmp)", ) if paths: self.import_files(paths) def import_files(self, paths): self._start_import(file_paths=list(paths or [])) def import_clipboard_image(self, image_bytes): self._start_import(image_bytes=image_bytes) def _start_import(self, *, file_paths=None, image_bytes=None): state = self._displayed_state if state is None: return self._save_controls_to_state(state) state.import_created_draft = False created_project = state.project_id is None project = self._ensure_project_for_local_work(state) if project is None: return state.import_created_draft = created_project and image_studio.is_draft_project(project) if state.import_worker is not None: self._status("当前任务正在添加图片,请稍候", "warning") return current_count = len(self._original_assets(state, include_missing=True)) if current_count >= image_studio_images.MAX_ORIGINAL_ASSETS: self._message("图片数量已满", "每个商品最多添加16张原图。") return worker = ProductSuiteImportImagesWorker( state.project_id, file_paths=file_paths, image_bytes=image_bytes, db_path=self.db_path, config=self.config, ) state.import_worker = worker if state is self._displayed_state: self._refresh_add_images_action(state) worker.finished.connect(lambda result, state=state: self._on_import_finished(state, result)) worker.cancelled.connect(lambda result, state=state: self._on_import_finished(state, result)) worker.failed.connect(lambda row, error, state=state: self._on_import_failed(state, error)) state.import_thread = self._start_thread(worker, "商品套图添加原图") self._status("正在后台添加商品原图", "info") def _discard_empty_import_draft(self, state): if not state.import_created_draft: return state.import_created_draft = False project_id = state.project_id if project_id is None: return try: project = image_studio.discard_empty_draft_project(project_id, path=self.db_path) except Exception as exc: self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger") return if project is not None and project.deleted_at is not None: state.project_id = None state.project_binding_state = "" state.item_id = "" self._set_task_title(state) self._status("未添加有效图片,已丢弃空临时草稿", "warning") def _on_import_failed(self, state, error): state.import_worker = None state.import_thread = None self._discard_empty_import_draft(state) self._status("添加商品原图失败:%s" % _user_error(error), "danger") if state is self._displayed_state: self._refresh_add_images_action(state) def _on_import_finished(self, state, result): state.import_worker = None state.import_thread = None self._discard_empty_import_draft(state) if result.get("ok") is False: self._message("添加商品原图失败", _user_error(result.get("error"))) else: errors = result.get("errors") or [] count = len(result.get("assets") or []) if errors: self._message( "部分图片未添加", "已添加%d张,%d张失败。\n%s" % ( count, len(errors), _user_error(errors[0].get("error")), ), ) else: self._status("已添加%d张商品原图" % count, "success") if state is self._displayed_state: self._refresh_originals(state) def delete_original(self, asset_id): self.delete_originals([asset_id]) def delete_originals(self, asset_ids): state = self._displayed_state if state is None or state.project_id is None: self._status("当前商品没有可删除的原图", "warning") return if state.generation_running(): self._status("生成中不能删除当前任务的商品原图", "warning") return if state.pull_running(): self._status("拉取蝦皮主图时不能删除当前任务的商品原图", "warning") return normalized_ids = [] seen = set() for value in asset_ids or []: asset_id = int(value) if asset_id not in seen: seen.add(asset_id) normalized_ids.append(asset_id) if not normalized_ids: self._status("请先勾选要删除的商品原图", "warning") return downloading = set(state.download_queue) | set(state.downloads) if downloading.intersection(normalized_ids): self._message("暂不能删除商品原图", "选中的图片仍在后台下载,请等待下载结束后再删除。") return visible_ids = [int(asset.id) for asset in self._original_assets(state, include_missing=False)] message = ( "确认从当前商品原图列表移除选中的%d张图片吗?\n\n" "此操作不会删除蝦皮线上图片,也不会删除本地源文件。" "以后重新拉取蝦皮主图时,线上仍存在的图片可能重新出现。" % len(normalized_ids) ) if visible_ids and visible_ids[0] in normalized_ids: message += "\n\n选中内容包含当前主图,删除后下一张图片将成为主图。" if not self._confirm("删除商品原图", message, destructive=True): return try: image_studio.remove_original_assets_if_unused( state.project_id, normalized_ids, path=self.db_path, ) except Exception as exc: self._message("不能删除商品原图", _user_error(exc)) return self._refresh_originals(state, preserve_checks=False) self._status("已移除%d张商品原图" % len(normalized_ids), "success") def reorder_originals(self, visible_ids): state = self._displayed_state if state is None or state.project_id is None: return all_assets = self._original_assets(state, include_missing=True) hidden_ids = [int(asset.id) for asset in all_assets if int(asset.id) not in set(visible_ids)] try: image_studio.reorder_original_assets( state.project_id, list(visible_ids) + hidden_ids, path=self.db_path, ) except Exception as exc: self._message("原图排序失败", _user_error(exc)) self._refresh_originals(state) def _asset_for_item(self, item): state = self._displayed_state if state is None or item is None: return None value = item.data(Qt.UserRole) if value is None: return None asset_id = int(value) return next( (asset for asset in self._original_assets(state, include_missing=True) if int(asset.id) == asset_id), None, ) def _on_original_clicked(self, item): asset = self._asset_for_item(item) if asset is not None and not _asset_usable(asset) and asset.remote_url: self._queue_original_downloads(self._displayed_state, [asset]) def _preview_original(self, item): asset = self._asset_for_item(item) if asset is None: return if not _asset_usable(asset): self._queue_original_downloads(self._displayed_state, [asset]) self._status("原图正在后台下载,完成后可双击预览", "info") return ProductSuitePreviewDialog(asset.local_path, "商品原图预览", self).exec() def pull_main_images(self, checked=False): state = self._displayed_state if state is None: return if state.pull_running(): self._request_stop_pull(state) return self._save_controls_to_state(state) if self._is_draft_state(state): self._message( "无法拉取蝦皮主图", "当前为临时项目,无法拉取蝦皮主图。\n请先输入正式商品ID后再试。", ) return if not self._valid_context(state): return existing = [asset for asset in self._original_assets(state, include_missing=False) if _asset_usable(asset)] message = ( "店铺:%s\n" "商品ID:%s\n" "当前可用商品原图:%d张\n\n" "将读取蝦皮主图并在后台下载,不会修改蝦皮线上商品。" % ( self._account_context_label(state), state.item_id, len(existing), ) ) if existing: message += "\n继续拉取会刷新蝦皮原图列表,本地手动添加图片会保留。" if not self._confirm( "确认拉取蝦皮主图", message, confirm_text="确认拉取", cancel_text="取消", ): return if state.project_id is None: project = image_studio.get_project_by_account_item( state.account_alias, state.item_id, path=self.db_path, ) if project is not None: state.project_id = int(project.id) state.project_binding_state = project.binding_state before_assets = ( image_studio.list_assets( state.project_id, kind=image_studio.ASSET_KIND_ORIGINAL, path=self.db_path, ) if state.project_id is not None else [] ) pull_run_token = uuid.uuid4().hex worker = ImageStudioPullImagesWorker( state.account_alias, state.item_id, pull_run_token=pull_run_token, db_path=self.db_path, config=self.config, ) state.pull_worker = worker state.pull_run_token = pull_run_token state.pull_stop_requested = False state.pull_cleanup_mode = "keep" state.pull_before_asset_ids = {int(asset.id) for asset in before_assets} state.pull_before_asset_states = [ { "id": int(asset.id), "status": asset.status, "source_order": int(asset.source_order or 0), } for asset in before_assets ] state.pull_asset_ids = set() state.pull_download_asset_ids = set() state.pull_download_failed = 0 state.pull_started_at = time.monotonic() self._pull_run_states[pull_run_token] = state.key self._log_pull_lifecycle( state, pull_run_token, "started", { "before_count": len(state.pull_before_asset_ids), }, ) worker.finished.connect( lambda result, token=pull_run_token: self._on_pull_finished( token, result, ) ) worker.cancelled.connect( lambda result, token=pull_run_token: self._on_pull_finished( token, result, ) ) worker.failed.connect( lambda row, error, token=pull_run_token: self._on_pull_failed( token, error, ) ) state.pull_thread = self._start_thread(worker, "商品套图拉取蝦皮主图") if state is self._displayed_state: self._apply_running_state(state) self._status("开始拉取蝦皮主图,可继续操作其他套图任务", "info") def _pull_state(self, pull_run_token): token = str(pull_run_token or "") state = self._states.get(self._pull_run_states.get(token)) if state is None or state.pull_run_token != token: return None return state def _pull_stop_action(self): box = QMessageBox(self) box.setIcon(QMessageBox.Question) box.setWindowTitle("停止拉取蝦皮主图") box.setText("请选择停止后如何处理本轮已经拉取的图片。") keep_button = box.addButton("停止并保留", QMessageBox.AcceptRole) clear_button = box.addButton( "停止并清除本次新增", QMessageBox.DestructiveRole, ) continue_button = box.addButton("继续拉取", QMessageBox.RejectRole) box.setDefaultButton(keep_button) box.exec() clicked = box.clickedButton() if clicked is keep_button: return "keep" if clicked is clear_button: return "clear_current" if clicked is continue_button: return "continue" return "continue" def _request_stop_pull(self, state): if state.pull_stop_requested: self._status("正在停止当前拉取任务", "warning") return action = self._pull_stop_action() if action == "continue": return state.pull_stop_requested = True state.pull_cleanup_mode = action if state.pull_worker is not None: state.pull_worker.cancel() self._cancel_pull_downloads(state) self._log_pull_lifecycle( state, state.pull_run_token, "stop_requested", {"cleanup_mode": action}, level="WARNING", ) if state is self._displayed_state: self._apply_running_state(state) self._status("已请求停止拉取蝦皮主图", "warning") self._maybe_finalize_pull(state, state.pull_run_token) def _cancel_pull_downloads(self, state): pull_ids = set(state.pull_download_asset_ids) if not pull_ids: return state.download_queue = [ asset_id for asset_id in state.download_queue if int(asset_id) not in pull_ids ] for asset_id in list(pull_ids): running = state.downloads.get(int(asset_id)) if running is None: state.pull_download_asset_ids.discard(int(asset_id)) state.download_tokens.pop(int(asset_id), None) continue worker, _ = running worker.cancel() def _on_pull_failed(self, pull_run_token, error): state = self._pull_state(pull_run_token) if state is None or state.pull_stop_requested: return self._status("拉取蝦皮主图失败:%s" % _user_error(error), "danger") def _on_pull_finished(self, pull_run_token, result): state = self._pull_state(pull_run_token) if state is None: return state.pull_worker = None state.pull_thread = None result = dict(result or {}) if result.get("ok") is False and not state.pull_stop_requested: self._finalize_pull( state, pull_run_token, error=_user_error(result.get("error")), ) return project = result.get("project") if project is not None: state.project_id = int(project.id) state.project_binding_state = project.binding_state assets = [ asset for asset in (result.get("assets") or []) if asset.status != image_studio.ASSET_STATUS_MISSING ][: image_studio_images.MAX_ORIGINAL_ASSETS] state.pull_asset_ids.update( int(asset.id) for asset in assets if getattr(asset, "remote_url", None) ) if result.get("cancelled") or state.pull_stop_requested: self._cancel_pull_downloads(state) else: self._queue_original_downloads( state, assets, pull_run_token=pull_run_token, ) self._status("已读取%d张蝦皮主图,正在后台下载" % len(assets), "success") if state is self._displayed_state: self._refresh_originals(state) self._apply_running_state(state) self._maybe_finalize_pull(state, pull_run_token) @Slot() def _on_pull_thread_finished_signal(self): sender = self.sender() token = str( sender.property("productSuitePullRunToken") if sender is not None else "" ) QTimer.singleShot(0, lambda token=token: self._handle_pull_thread_finished(token)) def _handle_pull_thread_finished(self, pull_run_token): state = self._pull_state(pull_run_token) if state is None or state.pull_worker is None: return state.pull_worker = None state.pull_thread = None if state.pull_stop_requested: self._maybe_finalize_pull(state, pull_run_token) else: self._finalize_pull( state, pull_run_token, error="拉取线程已结束,请稍后重试", ) def _maybe_finalize_pull(self, state, pull_run_token): if self._pull_state(pull_run_token) is not state: return False if state.pull_worker is not None or state.pull_download_asset_ids: return False return self._finalize_pull(state, pull_run_token) def _clear_current_pull_assets(self, state): new_ids = sorted( set(state.pull_asset_ids) - set(state.pull_before_asset_ids) ) removable = [] retained = 0 for asset_id in new_ids: asset = image_studio.get_asset(asset_id, path=self.db_path) if ( asset is None or int(asset.project_id) != int(state.project_id or 0) or asset.kind != image_studio.ASSET_KIND_ORIGINAL or not asset.remote_url ): continue if image_studio.asset_reference_counts( asset.id, path=self.db_path, )["total"]: retained += 1 else: removable.append(asset.id) removed = 0 if removable: image_studio.remove_original_assets_if_unused( state.project_id, removable, path=self.db_path, ) removed = len(removable) existing_ids = { int(asset.id) for asset in image_studio.list_assets( state.project_id, kind=image_studio.ASSET_KIND_ORIGINAL, path=self.db_path, ) } snapshot = [ item for item in state.pull_before_asset_states if int(item["id"]) in existing_ids ] if snapshot: image_studio.restore_original_asset_snapshot( state.project_id, snapshot, path=self.db_path, ) return removed, retained def _finalize_pull(self, state, pull_run_token, *, error=""): if self._pull_state(pull_run_token) is not state: return False cancelled = state.pull_stop_requested cleanup_mode = state.pull_cleanup_mode pulled_count = len(state.pull_asset_ids) new_count = len(set(state.pull_asset_ids) - set(state.pull_before_asset_ids)) removed = 0 retained = 0 cleanup_error = "" if cancelled and cleanup_mode == "clear_current" and state.project_id is not None: try: removed, retained = self._clear_current_pull_assets(state) except Exception as exc: cleanup_error = _user_error(exc) elapsed = int( max(0, time.monotonic() - state.pull_started_at) if state.pull_started_at else 0 ) self._pull_run_states.pop(pull_run_token, None) state.pull_worker = None state.pull_thread = None state.pull_run_token = "" state.pull_stop_requested = False state.pull_cleanup_mode = "keep" state.pull_before_asset_ids = set() state.pull_before_asset_states = [] state.pull_asset_ids = set() state.pull_download_asset_ids = set() failed = int(state.pull_download_failed or 0) self._log_pull_lifecycle( state, pull_run_token, "finalized", { "cancelled": cancelled, "cleanup_mode": cleanup_mode, "pulled_count": pulled_count, "new_count": new_count, "removed_count": removed, "retained_count": retained, "failed_count": failed, "elapsed_seconds": elapsed, "has_error": bool(error or cleanup_error), }, level="WARNING" if cancelled or error or cleanup_error else "INFO", ) state.pull_download_failed = 0 state.pull_started_at = None if state is self._displayed_state: self._refresh_originals(state) self._apply_running_state(state) if cleanup_error: self._message( "停止拉取后清理失败", "拉取已停止,但本轮图片清理失败:%s" % cleanup_error, ) self._status("拉取已停止,本轮图片清理失败", "danger") return True if error: if state is self._displayed_state: self._message("拉取蝦皮主图失败", error) self._status("拉取蝦皮主图失败:%s" % error, "danger") return True if cancelled: kept = max(0, new_count - removed) if state is self._displayed_state: self._message( "拉取蝦皮主图已停止", "本轮新增%d张:保留%d张,清理%d张," "因引用保留%d张,下载失败%d张;总用时%d秒。" % ( new_count, kept, removed, retained, failed, elapsed, ), icon=QMessageBox.Information, ) self._status( "拉取已停止:保留%d张,清理%d张" % (kept, removed), "warning", ) return True self._status( "蝦皮主图拉取完成:本轮%d张,下载失败%d张,用时%d秒" % (pulled_count, failed, elapsed), "success" if not failed else "warning", ) return True def _log_pull_lifecycle( self, state, pull_run_token, event, payload=None, *, level="INFO", ): data = { "pull_run_token": str(pull_run_token or "")[:8], "project_id": getattr(state, "project_id", None), "event": str(event or ""), } data.update(dict(payload or {})) try: diagnostics.write_diagnostic_log( "商品套图拉取生命周期", level=level, step="product_suite_pull", task_id=getattr(state, "project_id", None), item_id=getattr(state, "item_id", None), payload=data, log_dir=appconfig.diagnostic_log_dir(self.config), ) except Exception: pass def _queue_original_downloads(self, state, assets, *, pull_run_token=None): if state is None: return queued = set(state.download_queue) | set(state.downloads) for asset in assets or []: if _asset_usable(asset) or not getattr(asset, "remote_url", None): continue asset_id = int(asset.id) if asset_id not in queued: state.download_queue.append(asset_id) state.download_tokens[asset_id] = str(pull_run_token or "") if pull_run_token: state.pull_download_asset_ids.add(asset_id) queued.add(asset_id) self._start_queued_downloads(state) def _start_queued_downloads(self, state): while state.download_queue and len(state.downloads) < ORIGINAL_DOWNLOAD_CONCURRENCY: asset_id = int(state.download_queue.pop(0)) worker = ImageStudioDownloadOriginalWorker( asset_id, db_path=self.db_path, config=self.config, max_retries=2, ) download_token = str(state.download_tokens.get(asset_id, "") or "") worker.finished.connect( lambda result, state=state, asset_id=asset_id, token=download_token: self._on_download_finished( state, asset_id, result, pull_run_token=token ) ) worker.cancelled.connect( lambda result, state=state, asset_id=asset_id, token=download_token: self._on_download_finished( state, asset_id, result, pull_run_token=token ) ) worker.failed.connect( lambda row, error, state=state, asset_id=asset_id: self._status( "商品原图 #%d 下载失败:%s" % (asset_id, _user_error(error)), "danger", ) ) thread = self._start_thread(worker, "商品套图下载原图") state.downloads[asset_id] = (worker, thread) def _on_download_finished( self, state, asset_id, result, *, pull_run_token="", ): expected_token = str(state.download_tokens.get(int(asset_id), "") or "") if expected_token != str(pull_run_token or ""): return state.downloads.pop(int(asset_id), None) state.download_tokens.pop(int(asset_id), None) if pull_run_token: state.pull_download_asset_ids.discard(int(asset_id)) if result.get("ok") is False: if pull_run_token: state.pull_download_failed += 1 self._status( "商品原图 #%d 下载失败:%s" % (asset_id, _user_error(result.get("error"))), "danger", ) elif not result.get("cancelled"): self._status("商品原图 #%d 已下载" % asset_id, "success") if state is self._displayed_state: self._refresh_originals(state) self._start_queued_downloads(state) if pull_run_token: self._maybe_finalize_pull(state, pull_run_token) def _rebuild_categories(self, state): while self.category_grid.count(): item = self.category_grid.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() settings = product_suite.normalize_suite_settings(state.settings) state.settings = settings categories = settings["categories"] ordered = product_suite.category_order(settings) self.category_rows = {} for index, name in enumerate(ordered): custom = name not in product_suite.FIXED_CATEGORIES row = SuiteCategoryRow( name, categories.get(name, 0), helper=product_suite.category_helper(name), custom=custom, ) row.countChangeRequested.connect(self.change_category_count) if custom: row.renameRequested.connect(self.rename_custom_category) row.deleteRequested.connect(self.delete_custom_category) row.set_controls_enabled(not state.generation_running()) self.category_rows[name] = row self.category_grid.addWidget(row, index, 0) self._refresh_totals(state) def change_category_count(self, name, delta): state = self._displayed_state if state is None: return settings = product_suite.normalize_suite_settings(state.settings) categories = dict(settings["categories"]) categories[name] = max(0, int(categories.get(name, 0)) + int(delta)) settings["categories"] = categories state.settings = settings self._persist_state(state) row = self.category_rows.get(name) if state is self._displayed_state else None if row is None: self._rebuild_categories(state) else: row.set_count(categories[name]) self._refresh_totals(state) def add_custom_category(self, checked=False): if self._displayed_state is None: return self.add_category_button.hide() self.custom_category_edit.clear() self.custom_category_edit.show() self.custom_category_edit.setFocus(Qt.OtherFocusReason) def _commit_custom_category(self): state = self._displayed_state if state is None: return name = self.custom_category_edit.text() settings = product_suite.normalize_suite_settings(state.settings) error = product_suite.suite_name_error(name, settings["categories"]) if error: self._message("分类名称无效", error) self.custom_category_edit.setFocus(Qt.OtherFocusReason) return categories = dict(settings["categories"]) categories[name] = 1 settings["categories"] = categories settings["custom_category_order"] = list(settings["custom_category_order"]) + [name] state.settings = product_suite.normalize_suite_settings(settings) self._persist_state(state) self.custom_category_edit.hide() self.add_category_button.show() self._rebuild_categories(state) def _finish_custom_category_edit(self): if not self.custom_category_edit.isHidden() and not self.custom_category_edit.text().strip(): self.custom_category_edit.hide() self.add_category_button.show() def rename_custom_category(self, old_name): state = self._displayed_state if state is None or old_name in product_suite.FIXED_CATEGORIES: return name, ok = QInputDialog.getText( self, "重命名自定义分类", "分类名称", text=old_name, ) if not ok or name == old_name: return settings = product_suite.normalize_suite_settings(state.settings) error = product_suite.suite_name_error(name, settings["categories"], old_name=old_name) if error: self._message("分类名称无效", error) return categories = dict(settings["categories"]) count = categories.pop(old_name, 1) categories[name] = count settings["categories"] = categories settings["custom_category_order"] = [ name if value == old_name else value for value in settings["custom_category_order"] ] state.settings = product_suite.normalize_suite_settings(settings) self._persist_state(state) self._rebuild_categories(state) def delete_custom_category(self, name): state = self._displayed_state if state is None or name in product_suite.FIXED_CATEGORIES: return settings = product_suite.normalize_suite_settings(state.settings) categories = dict(settings["categories"]) categories.pop(name, None) settings["categories"] = categories settings["custom_category_order"] = [ value for value in settings["custom_category_order"] if value != name ] state.settings = product_suite.normalize_suite_settings(settings) self._persist_state(state) self._rebuild_categories(state) def _refresh_totals(self, state): image_count = sum(1 for asset in self._original_assets(state) if _asset_usable(asset)) total = product_suite.suite_total_count(state.settings, image_count) self.category_total_label.setText("合计 %d 张" % total) if not state.generation_running(): self.generate_button.setText("生成套图(%d)" % total) def start_ai_write(self, checked=False): state = self._displayed_state if state is None: return if state.ai_worker is not None: self._status("当前套图任务正在AI帮写", "info") return local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] if not local_assets: self._message("缺少商品原图", "请先添加或拉取至少一张可用商品原图。") return self._save_controls_to_state(state) context = ( "商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s" % ( state.item_id or "未绑定商品", state.settings["platform"], state.settings["country"], state.settings["language"], state.prompt or "无", ) ) instruction = ( "请为电商商品图片生成简洁、可执行的商品卖点与画面要求。" "只输出可直接用于生图的中文提示词,包含核心卖点、目标人群、使用场景和禁用元素," "不要虚构商品参数。" ) state.ai_prompt_snapshot = state.prompt state.ai_started_at = time.monotonic() worker = ProductSuiteAiWriteWorker( instruction, context, config=self.config, cmhub_config_path=self.cmhub_config_path, ) state.ai_worker = worker worker.finished.connect(lambda result, state=state: self._on_ai_write_finished(state, result)) worker.cancelled.connect(lambda result, state=state: self._on_ai_write_finished(state, result)) worker.failed.connect(lambda row, error, state=state: self._on_ai_write_failed(state, error)) state.ai_thread = self._start_thread(worker, "商品套图AI帮写") if state is self._displayed_state: self._apply_running_state(state) self._status("AI帮写已开始,可切换到其他套图任务", "info") def cancel_ai_write(self, checked=False): state = self._displayed_state if state is not None and state.ai_worker is not None: state.ai_worker.cancel() self._status("已请求取消AI帮写", "warning") def _on_ai_write_failed(self, state, error): self._status("AI帮写失败:%s" % _user_error(error), "danger") def _on_ai_write_finished(self, state, result): state.ai_worker = None state.ai_thread = None state.ai_started_at = None if result.get("ok") is False: self._message("AI帮写失败", _user_error(result.get("error"))) elif not result.get("cancelled"): text = str(result.get("text") or "").strip() if text: if state.prompt != state.ai_prompt_snapshot: state.pending_ai_text = text if state is self._displayed_state: self._offer_pending_ai_result(state) else: self._status("AI帮写已完成,切回对应套图任务后确认是否采用", "success") else: state.prompt = text self._persist_state(state) if state is self._displayed_state: self._loading = True try: self.prompt_edit.setPlainText(text) finally: self._loading = False self._status("AI帮写已完成", "success") if state is self._displayed_state: self._apply_running_state(state) def _offer_pending_ai_result(self, state): if not state.pending_ai_text: return if self._confirm( "采用AI帮写结果", "AI帮写期间卖点内容已被修改。是否用AI帮写结果覆盖当前内容?", ): state.prompt = state.pending_ai_text self._loading = True try: self.prompt_edit.setPlainText(state.prompt) finally: self._loading = False self._persist_state(state) state.pending_ai_text = "" def toggle_generation(self, checked=False): state = self._displayed_state if state is None: return if state.generation_running(): retrying = state.generation_mode == "retry" if state.generation_stop_requested: self._status( "正在停止当前图片重试" if retrying else "正在停止当前套图任务", "warning", ) return if self._confirm( "停止图片重试" if retrying else "停止生成套图", ( "确认停止当前图片重试吗?已提交任务会在安全边界停止。" if retrying else "确认取消当前任务吗?已提交任务会在安全边界停止。" ), destructive=True, ): state.generation_stop_requested = True state.worker.cancel() self._log_generation_lifecycle( state, state.generation_run_token, "stop_requested", ) self._apply_running_state(state) self._status( "已请求停止当前图片重试" if retrying else "已请求停止当前套图任务", "warning", ) return self.start_generation(state) def start_generation(self, state, specs=None, *, retry_job_id=None): if state.generation_running(): self._status("当前套图任务仍在生成", "warning") return False retry_job_id = int(retry_job_id) if retry_job_id is not None else None retrying = retry_job_id is not None confirm_batch = specs is None and not retrying if state is self._displayed_state: self._save_controls_to_state(state) template_text = None if specs is None: try: template_text = prompts.load_product_suite_prompt( self.product_suite_prompt_path ) except prompts.PromptError: self._message( "套图提示词模板无效", "套图提示词模板无效,请在提示词设置中修复或恢复默认。", ) return False if self._ensure_project_for_local_work(state) is None: return False local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] if not local_assets: self._message("缺少商品原图", "请先添加图片,或等待蝦皮主图下载完成。") return False if specs is None and not state.prompt.strip(): self._message("商品卖点不能为空", "请填写商品卖点与要求,或使用AI帮写。") return False specs = list(specs or product_suite.build_job_specs( local_assets, state.prompt, state.settings, state.item_id or "未绑定商品", template_text=template_text, )) if not specs: self._message("生成数量为0", "请至少把一个套图分类的数量设为1。") return False generation_round_key = "" if retrying: try: original_job = image_studio.get_job(retry_job_id, path=self.db_path) except Exception as exc: self._message("读取重试图片失败", _user_error(exc)) return False if original_job is None or int(original_job.project_id) != int(state.project_id): self._message("重试图片无效", "该图片不属于当前商品套图任务。") return False for spec in specs: spec["generation_round_key"] = original_job.generation_round_key spec["generation_slot_index"] = original_job.generation_slot_index generation_round_key = str(original_job.generation_round_key or "") else: generation_round_key = uuid.uuid4().hex for slot_index, spec in enumerate(specs): spec["generation_round_key"] = generation_round_key spec["generation_slot_index"] = slot_index if confirm_batch and not self._confirm( "确认生成商品套图", self._generation_confirmation_message( state, local_assets, specs, ), confirm_text="确认生成", cancel_text="返回修改", ): return False self._persist_state(state) run_token = uuid.uuid4().hex worker = ProductSuiteGenerateWorker( state.project_id, specs, run_token=run_token, generation_round_key=generation_round_key or None, aspect_ratio=state.settings["ratio"], db_path=self.db_path, config=self.config, cmhub_config_path=self.cmhub_config_path, ) state.worker = worker state.generation_run_token = run_token state.generation_stop_requested = False state.generation_terminal_streak = 0 state.generation_job_ids = [] state.generation_mode = "retry" if retrying else "batch" state.generation_retry_job_id = retry_job_id state.generation_round_key = generation_round_key state.done = 0 state.failed = 0 state.total = len(specs) state.started_at = time.monotonic() if not retrying: state.current_job_ids = [] self._generation_run_states[run_token] = state.key worker.progress.connect(self._on_generation_progress_signal) worker.finished.connect(self._on_generation_finished_signal) worker.cancelled.connect(self._on_generation_finished_signal) worker.failed.connect(self._on_generation_failed_signal) state.thread = self._start_thread(worker, "商品套图生成") self._log_generation_lifecycle( state, run_token, "started", { "total": len(specs), "job_ids": 0, "mode": state.generation_mode, }, ) if state is self._displayed_state: self._apply_running_state(state) self._refresh_results(state) self._status( "图片重试已开始;可切换到其他任务" if retrying else "商品套图生成已开始,共%d张;可切换到其他任务" % len(specs), "info", ) return True def _generation_confirmation_message(self, state, local_assets, specs): counts = {} for spec in specs: category = str( spec.get("category") or spec.get("job_type") or "未分类" ) counts[category] = counts.get(category, 0) + 1 ordered = list(product_suite.category_order(state.settings)) ordered.extend(name for name in counts if name not in ordered) category_lines = [ "%s:%d张" % (name, counts[name]) for name in ordered if counts.get(name, 0) > 0 ] return ( "店铺:%s\n" "商品ID:%s\n" "可用商品原图:%d张\n" "每张上传图分别作为主图生成:%s\n" "%s\n" "图片比例:%s\n" "生成总数:%d张\n" "商品卖点:已填写\n\n" "本次生成会消耗 cmhub 点数。" % ( self._account_context_label(state), state.item_id or "未绑定商品", len(local_assets), "是" if state.settings.get("per_image_primary") else "否", "\n".join(category_lines), state.settings.get("ratio") or "1:1", len(specs), ) ) def _generation_signal_token(self, payload=None): token = str((payload or {}).get("run_token") or "") sender = self.sender() return token or str(getattr(sender, "run_token", "") or "") def _generation_state(self, run_token): token = str(run_token or "") state = self._states.get(self._generation_run_states.get(token)) if state is None or state.generation_run_token != token: return None return state @Slot(dict) def _on_generation_progress_signal(self, payload): token = self._generation_signal_token(payload) state = self._generation_state(token) if state is None: return self._on_generation_progress(state, payload) def _on_generation_progress(self, state, payload): if "total" in payload: state.total = max(0, int(payload.get("total") or 0)) state.done = int(payload.get("done", state.done) or 0) state.failed = int(payload.get("failed", state.failed) or 0) job_ids = payload.get("job_ids") if job_ids is not None: self._set_generation_job_ids(state, job_ids) state.generation_terminal_streak = 0 if state is self._displayed_state: self._refresh_results(state) self._refresh_elapsed() @Slot(int, str) def _on_generation_failed_signal(self, row, error): token = self._generation_signal_token() state = self._generation_state(token) if state is None: return self._log_generation_lifecycle( state, token, "worker_failed", {"has_error": True}, level="ERROR", ) self._status( ( "图片重试失败:%s" if state.generation_mode == "retry" else "商品套图生成失败:%s" ) % _user_error(error), "danger", ) @Slot(dict) def _on_generation_finished_signal(self, result): token = self._generation_signal_token(result) state = self._generation_state(token) if state is None: return if result.get("job_ids") is not None: self._set_generation_job_ids(state, result.get("job_ids")) self._log_generation_lifecycle( state, token, "worker_finished", { "ok": result.get("ok", True), "success": result.get("success", 0), "failed": result.get("failed", 0), "cancelled": result.get( "cancelled_count", result.get("cancelled", 0), ), }, ) if result.get("ok") is False: self._reconcile_generation_jobs(state, "生成线程异常结束") if ( state.generation_stop_requested and not self._generation_job_ids(state) and result.get("cancelled") is True ): result = dict(result) result["total"] = state.total result["cancelled_count"] = state.total self._finalize_generation(state, token, result, source="worker") @Slot() def _on_generation_thread_finished_signal(self): sender = self.sender() token = str( sender.property("productSuiteRunToken") if sender is not None else "" ) self._handle_generation_thread_finished(token) def _handle_generation_thread_finished(self, run_token): state = self._generation_state(run_token) if state is None: return snapshot = self._generation_job_snapshot(state) had_active = bool(snapshot["active"]) self._log_generation_lifecycle( state, run_token, "thread_finished_fallback", snapshot, level="WARNING" if snapshot["active"] else "INFO", ) if had_active: self._reconcile_generation_jobs(state, "生成线程已结束") snapshot = self._generation_job_snapshot(state) result = self._generation_result_from_snapshot(snapshot) if not snapshot["job_ids"] and state.generation_stop_requested: result.update( { "total": state.total, "cancelled_count": state.total, } ) elif had_active: result.update( { "ok": False, "error": "生成线程已结束,部分任务可稍后继续查询", } ) self._finalize_generation( state, run_token, result, source="thread_finished", ) def _check_generation_watchdogs(self): for state in list(self._states.values()): token = state.generation_run_token if not token or not state.generation_running(): continue snapshot = self._generation_job_snapshot(state) if snapshot["all_terminal"]: state.generation_terminal_streak += 1 else: state.generation_terminal_streak = 0 if state.generation_terminal_streak < 2: continue self._log_generation_lifecycle( state, token, "terminal_watchdog_finalize", snapshot, level="WARNING", ) self._finalize_generation( state, token, self._generation_result_from_snapshot(snapshot), source="terminal_watchdog", ) def _generation_job_ids(self, state): job_ids = list(state.generation_job_ids) if not job_ids and state.worker is not None: worker_job_ids = getattr(state.worker, "job_ids", []) if not isinstance(worker_job_ids, (list, tuple, set)): worker_job_ids = [] job_ids = [ int(job_id) for job_id in list(worker_job_ids or []) ] if job_ids: self._set_generation_job_ids(state, job_ids) return job_ids def _set_generation_job_ids(self, state, job_ids): normalized = [] for job_id in job_ids or []: value = int(job_id) if value not in normalized: normalized.append(value) state.generation_job_ids = normalized if state.generation_mode != "retry": state.current_job_ids = list(normalized) return retry_job_id = state.generation_retry_job_id current = [int(job_id) for job_id in state.current_job_ids] if retry_job_id in current: index = current.index(retry_job_id) current[index:index + 1] = normalized else: for job_id in normalized: if job_id not in current: current.append(job_id) state.current_job_ids = current def _restore_current_generation_results(self, state, *, force=False, allow_running=False): if state.project_id is None or ( state.generation_running() and not allow_running ): return False if state.current_job_ids and not force: return False try: round_key = image_studio.get_current_generation_round( state.project_id, path=self.db_path, ) jobs = image_studio.list_generation_round_current_jobs( state.project_id, round_key, path=self.db_path, ) except Exception as exc: self._status("当前生成结果恢复失败:%s" % _user_error(exc), "danger") return False state.current_generation_round_key = str(round_key or "") state.current_job_ids = [int(job.id) for job in jobs] return True def _generation_job_snapshot(self, state): job_ids = self._generation_job_ids(state) counts = { "success": 0, "failed": 0, "cancelled": 0, "active": 0, "job_ids": len(job_ids), "all_terminal": False, } if not job_ids: counts["active"] = max(0, int(state.total or 0)) return counts for job_id in job_ids: try: job = image_studio.get_job(job_id, path=self.db_path) except Exception: job = None status = str(getattr(job, "status", "") or "") if status == "succeeded": counts["success"] += 1 elif status in {"failed", "expired"}: counts["failed"] += 1 elif status == "cancelled": counts["cancelled"] += 1 else: counts["active"] += 1 counts["all_terminal"] = ( len(job_ids) == int(state.total or 0) and counts["active"] == 0 ) return counts def _generation_result_from_snapshot(self, snapshot): return { "total": int(snapshot.get("job_ids", 0) or 0), "success": int(snapshot.get("success", 0) or 0), "failed": int(snapshot.get("failed", 0) or 0), "cancelled_count": int(snapshot.get("cancelled", 0) or 0), } def _reconcile_generation_jobs(self, state, reason): for job_id in self._generation_job_ids(state): try: job = image_studio.get_job(job_id, path=self.db_path) if job is None or job.status in { "succeeded", "failed", "expired", "cancelled", }: continue recovery = ( image_studio.JOB_RECOVERY_RESUME if job.task_id else image_studio.JOB_RECOVERY_REGENERATE ) image_studio.update_job_status( job.id, "cancelled", error="%s,任务可稍后继续处理" % reason, recovery_action=recovery, path=self.db_path, ) except Exception as exc: self._status( "商品套图任务状态收尾失败:%s" % _user_error(exc), "danger", ) def _finalize_generation(self, state, run_token, result, *, source): if self._generation_state(run_token) is not state: return False retrying = state.generation_mode == "retry" snapshot = self._generation_job_snapshot(state) if snapshot["job_ids"] and ( snapshot["all_terminal"] or source in {"thread_finished", "terminal_watchdog"} ): result = dict(result or {}) result.update(self._generation_result_from_snapshot(snapshot)) result = dict(result or {}) stop_requested = state.generation_stop_requested success = int(result.get("success", 0) or 0) failed = int(result.get("failed", 0) or 0) cancelled = int( result.get("cancelled_count", result.get("cancelled", 0)) or 0 ) total = int(result.get("total", state.total) or state.total) active = max(0, total - success - failed - cancelled) elapsed = ( int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0 ) promoted = False if not retrying and state.generation_round_key and success: try: promoted = image_studio.promote_generation_round_if_success( state.project_id, state.generation_round_key, path=self.db_path, ) except Exception as exc: self._status("当前生成轮次保存失败:%s" % _user_error(exc), "danger") self._restore_current_generation_results( state, force=True, allow_running=True, ) elif not retrying: self._restore_current_generation_results( state, force=True, allow_running=True, ) elif state.generation_round_key: self._restore_current_generation_results( state, force=True, allow_running=True, ) self._generation_run_states.pop(run_token, None) state.generation_run_token = "" state.generation_stop_requested = False state.generation_terminal_streak = 0 state.generation_job_ids = [] state.generation_retry_job_id = None state.generation_round_key = "" state.worker = None state.thread = None state.done = success + failed + cancelled state.failed = failed state.total = total state.started_at = None if state is self._displayed_state: self._apply_running_state(state) self._refresh_results(state) self._refresh_elapsed() self._log_generation_lifecycle( state, run_token, "finalized", { "source": source, "total": total, "success": success, "failed": failed, "cancelled": cancelled, "active": active, "elapsed_seconds": elapsed, "mode": "retry" if retrying else "batch", "current_round_promoted": promoted, }, level="WARNING" if active or result.get("ok") is False else "INFO", ) if state.key not in self._states: return True if active or result.get("ok") is False: message = str( result.get("error") or "生成线程已结束,部分任务可稍后继续查询" ) if state is self._displayed_state: self._message( "图片重试未完整结束" if retrying else "商品套图生成未完整结束", _user_error(message), ) else: self._status( ( "套图任务%d图片重试未完整结束" if retrying else "套图任务%d生成未完整结束" ) % state.serial, "danger", ) return True if stop_requested or cancelled: if state is self._displayed_state: if retrying: self._message( "图片重试已停止", "本次重试:成功%d张,失败%d张,停止%d张;" "已提交任务可稍后继续查询;总用时%d秒。" % (success, failed, cancelled, elapsed), icon=QMessageBox.Information, ) else: self._message( "商品套图生成已停止", "本轮共%d张:成功%d张,失败%d张,停止%d张;" "已提交任务可稍后继续查询;总用时%d秒。" % (total, success, failed, cancelled, elapsed), icon=QMessageBox.Information, ) self._status( ( "图片重试已停止:成功%d张,失败%d张,停止%d张" if retrying else "商品套图生成已停止:成功%d张,失败%d张,停止%d张" ) % (success, failed, cancelled), "warning", ) return True if state is self._displayed_state: if retrying: self._message( "图片重试成功" if success and not failed else "图片重试失败", "本次重试:成功%d张,失败%d张,停止%d张;总用时%d秒。" % (success, failed, cancelled, elapsed), icon=QMessageBox.Information, ) else: self._message( "商品套图生成完成", "本轮共%d张:成功%d张,失败%d张,停止%d张;总用时%d秒。" % (total, success, failed, cancelled, elapsed), icon=QMessageBox.Information, ) if retrying: self._status( "图片重试成功" if success and not failed else "图片重试失败,请查看失败卡片", "success" if success and not failed else "danger", ) else: self._status( "商品套图生成完成:成功%d张,失败%d张" % (success, failed), "success", ) return True def _log_generation_lifecycle( self, state, run_token, event, payload=None, *, level="INFO", ): data = { "run_token": str(run_token or "")[:8], "project_id": getattr(state, "project_id", None), "event": str(event or ""), } data.update(dict(payload or {})) try: diagnostics.write_diagnostic_log( "商品套图生成生命周期", level=level, step="product_suite_generation", task_id=getattr(state, "project_id", None), item_id=getattr(state, "item_id", None), payload=data, log_dir=appconfig.diagnostic_log_dir(self.config), ) except Exception: pass def _apply_running_state(self, state): generation_running = state.generation_running() pull_running = state.pull_running() self.pull_button.setText( "正在停止..." if pull_running and state.pull_stop_requested else "停止拉取蝦皮" if pull_running else "拉取蝦皮主图" ) self.account_combo.setEnabled(not generation_running and not pull_running) self.item_id_edit.setEnabled(not generation_running and not pull_running) self._refresh_add_images_action(state) self.original_list.setEnabled(not generation_running) self._refresh_original_selection_controls() for widget in ( self.platform_combo, self.country_combo, self.language_combo, self.ratio_combo, self.per_image_checkbox, self.prompt_edit, self.add_category_button, self.custom_category_edit, ): widget.setEnabled(not generation_running) for row in self.category_rows.values(): row.set_controls_enabled(not generation_running) self.generate_button.setEnabled(True) if generation_running: self.generate_button.setText( "正在停止..." if state.generation_stop_requested else "停止重试" if state.generation_mode == "retry" else "停止生成" ) self.generate_button.setStyleSheet( "QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }" "QPushButton:hover { background: #a40e26; }" ) else: self.generate_button.setStyleSheet( "QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }" "QPushButton:hover { background: #245fce; }" ) self._refresh_totals(state) ai_running = state.ai_worker is not None self.ai_write_button.setEnabled(not ai_running and not generation_running) self.ai_cancel_button.setVisible(ai_running) self._update_context_actions(state) def _refresh_elapsed(self): state = self._displayed_state if state is None: return elapsed = int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0 self.elapsed_label.setText( "%s %d/%d(%d秒) · 失败 %d" % ( "重试" if state.generation_mode == "retry" else "套图", state.done, state.total, elapsed, state.failed, ) ) self.progress_bar.setRange(0, max(1, state.total)) self.progress_bar.setValue(min(state.done, max(1, state.total))) if state.ai_worker is not None and state.ai_started_at is not None: ai_elapsed = int(max(0, time.monotonic() - state.ai_started_at)) self.ai_write_button.setText("AI 帮写中(%d秒)" % ai_elapsed) else: self.ai_write_button.setText("AI 帮写") def _jobs_for_state(self, state): if state.project_id is None: return [] try: jobs = image_studio.list_jobs(state.project_id, path=self.db_path) except Exception as exc: self._status("生成结果读取失败:%s" % _user_error(exc), "danger") return [] by_id = {int(job.id): job for job in jobs} return [ by_id[int(job_id)] for job_id in state.current_job_ids if int(job_id) in by_id ] def _refresh_results(self, state): while self.result_grid.count(): item = self.result_grid.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() jobs = self._jobs_for_state(state) assets = {} if state.project_id is not None: try: assets = { int(asset.id): asset for asset in image_studio.list_assets(state.project_id, path=self.db_path) } except Exception: assets = {} success = 0 if not jobs: empty = QLabel("还没有生成结果,点击左侧“生成套图”开始") empty.setAlignment(Qt.AlignCenter) empty.setStyleSheet("color: #6b7280; padding: 48px;") self.result_grid.addWidget(empty, 0, 0, 1, 3) else: columns = max(2, min(4, max(1, self.result_scroll.viewport().width() // 200))) for index, job in enumerate(jobs): asset = assets.get(int(job.output_asset_id or 0)) if job.status == "succeeded" and asset is not None and _asset_usable(asset): success += 1 card = SuiteResultCard(job, asset) card.previewRequested.connect(self.preview_job) card.retryRequested.connect(self.retry_job) card.menuRequested.connect(self._show_job_menu) card.deleteRequested.connect(self.delete_job_asset) self.result_grid.addWidget(card, index // columns, index % columns) self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success)) self.undo_button.setVisible(bool(state.undo_records)) def open_history_dialog(self, checked=False): state = self._displayed_state if state is None or state.project_id is None: self._message( "未创建商品套图", "请先选择账号并添加本地图片,完成生成后再查看历史记录。", ) return try: project = image_studio.get_project(state.project_id, path=self.db_path) rounds = image_studio.list_generation_rounds( state.project_id, limit=1, path=self.db_path, ) except Exception as exc: self._message("读取历史生成失败", _user_error(exc)) return if project is None: self._message("商品项目不存在", "当前商品套图项目已经不存在或已删除。") return if not rounds: self._message( "暂无历史生成记录", "完成套图生成后会自动出现在历史生成记录中。", ) return dialog = self._history_dialog if dialog is not None: try: if dialog.project_id == int(project.id): dialog.refresh_history() dialog.show() dialog.raise_() dialog.activateWindow() return dialog.close() except RuntimeError: pass self._history_dialog = None dialog = ProductSuiteHistoryDialog( project.id, 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): dialog = self._history_dialog if dialog is None: return try: if project_id is None or dialog.project_id == int(project_id): dialog.close() self._history_dialog = None except RuntimeError: self._history_dialog = None def preview_job(self, job): asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None if asset is None or not _asset_usable(asset): self._status("这张生成图片尚不可预览", "warning") return ProductSuitePreviewDialog(asset.local_path, "%s预览" % job.job_type, self).exec() def retry_job(self, job): state = self._displayed_state if state is None: return if state.generation_running(): self._message("当前任务正在生成", "请等待当前生成结束或停止后再重试单张图片。") return if str(getattr(job, "status", "") or "") not in { "failed", "expired", "cancelled", }: self._status("当前图片无需重试", "warning") return spec = { "source_asset_id": job.source_asset_id, "job_type": job.job_type, "prompt": job.prompt, } self.start_generation(state, specs=[spec], retry_job_id=job.id) def _show_job_menu(self, job, global_position): menu = QMenu(self) preview_action = menu.addAction("预览") copy_action = menu.addAction("复制路径") folder_action = menu.addAction("打开文件夹") retry_action = menu.addAction("重新生成") delete_action = menu.addAction("删除") action = menu.exec(global_position) asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None if action is preview_action: self.preview_job(job) elif action is copy_action: if asset is None or not _asset_usable(asset): self._status("当前图片没有可复制的本地路径", "warning") else: QApplication.clipboard().setText(asset.local_path) self._status("生成图片路径已复制", "success") elif action is folder_action: if asset is not None and _asset_usable(asset): try: file_manager.open_in_file_manager(os.path.dirname(asset.local_path)) except Exception as exc: self._message("打开文件夹失败", _user_error(exc)) else: self.open_project_folder() elif action is retry_action: self.retry_job(job) elif action is delete_action: self.delete_job_asset(job) def delete_job_asset(self, job): state = self._displayed_state if state is None or not job.output_asset_id: return if not self._confirm("删除生成图片", "确认把这张图片移到当前商品的废纸篓吗?"): return try: record = image_studio_images.trash_generated_asset( job.output_asset_id, path=self.db_path, config=self.config, ) except Exception as exc: self._message("删除生成图片失败", _user_error(exc)) return state.undo_records.append(record) self._refresh_results(state) self._status("生成图片已删除,可点击“撤销删除”恢复", "success") def undo_delete(self, checked=False): state = self._displayed_state if state is None or not state.undo_records: return record = state.undo_records.pop() try: image_studio_images.restore_trashed_asset( record, path=self.db_path, config=self.config, ) except Exception as exc: state.undo_records.append(record) self._message("撤销删除失败", _user_error(exc)) return self._refresh_results(state) self._status("生成图片已恢复", "success") def _show_more_menu(self): menu = QMenu(self) delete_all_action = menu.addAction("删除当前视图全部图片") if menu.exec(self.more_button.mapToGlobal(self.more_button.rect().bottomLeft())) is delete_all_action: self.delete_all_visible_results() def delete_all_visible_results(self): state = self._displayed_state if state is None: return jobs = [job for job in self._jobs_for_state(state) if job.output_asset_id] if not jobs: self._status("当前视图没有可删除的生成图片", "warning") return if not self._confirm( "删除全部生成图片", "确认把当前视图中的%d张生成图片移到废纸篓吗?" % len(jobs), destructive=True, ): return failures = 0 for job in jobs: try: state.undo_records.append( image_studio_images.trash_generated_asset( job.output_asset_id, path=self.db_path, config=self.config, ) ) except Exception: failures += 1 self._refresh_results(state) if failures: self._message("部分图片未删除", "有%d张图片无法删除,请查看当前结果。" % failures) else: self._status("当前视图的生成图片已移到废纸篓", "success") def open_project_folder(self, checked=False): state = self._displayed_state if state is None or state.project_id is None: self._message("未创建套图任务", "请先选择账号并添加本地图片,或输入商品ID后拉取蝦皮主图。") return project = image_studio.get_project(state.project_id, path=self.db_path) if project is None: self._message("商品任务不存在", "当前商品套图任务已经不存在。") return target = image_studio.default_project_image_dirs(project, config=self.config)["generated"] os.makedirs(target, exist_ok=True) try: file_manager.open_in_file_manager(target) except Exception as exc: self._message("打开文件夹失败", _user_error(exc)) def resizeEvent(self, event): super().resizeEvent(event) if not self._result_refresh_pending: self._result_refresh_pending = True QTimer.singleShot(80, self._refresh_after_resize) def _refresh_after_resize(self): self._result_refresh_pending = False if self._displayed_state is not None: self._refresh_results(self._displayed_state) def closeEvent(self, event): self._close_history_dialog_for_project() if self._displayed_state is not None: self._save_controls_to_state(self._displayed_state) for state in list(self._states.values()): self._flush_prompt_save(state) for state in list(self._states.values()) + list(self._retired_states): for worker in (state.worker, state.pull_worker, state.import_worker, state.ai_worker): if worker is not None and hasattr(worker, "cancel"): worker.cancel() for worker, thread in list(state.downloads.values()): worker.cancel() for state in list(self._states.values()) + list(self._retired_states): self._release_prompt_save_timer(state) self._generation_run_states.clear() self._pull_run_states.clear() super().closeEvent(event)