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