2026-07-11 14:16:11 +08:00
|
|
|
|
"""Tab 6: AI image studio UI."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
from PySide6.QtCore import QObject, QMimeData, QSize, Signal
|
2026-07-11 17:46:58 +08:00
|
|
|
|
from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem, QSizePolicy
|
2026-07-11 14:20:56 +08:00
|
|
|
|
|
2026-07-13 09:54:08 +08:00
|
|
|
|
from ... import accounts, appconfig, cmhub_models, db, diagnostics, image_studio, image_studio_export, image_studio_images, prompts
|
2026-07-11 14:16:11 +08:00
|
|
|
|
from .. import file_manager
|
2026-07-16 09:13:44 +08:00
|
|
|
|
from ..image_preview import ImagePreviewDialog
|
2026-07-11 14:16:11 +08:00
|
|
|
|
from ..widgets import *
|
|
|
|
|
|
from ..workers import (
|
|
|
|
|
|
ImageStudioDownloadOriginalWorker as _RealImageStudioDownloadOriginalWorker,
|
|
|
|
|
|
)
|
2026-07-11 14:26:01 +08:00
|
|
|
|
from ..workers import ImageStudioExportWorker as _RealImageStudioExportWorker
|
2026-07-11 14:16:11 +08:00
|
|
|
|
from ..workers import ImageStudioGenerateJobsWorker as _RealImageStudioGenerateJobsWorker
|
|
|
|
|
|
from ..workers import ImageStudioPullImagesWorker as _RealImageStudioPullImagesWorker
|
2026-07-11 14:29:58 +08:00
|
|
|
|
from ..workers import ImageStudioResumeJobsWorker as _RealImageStudioResumeJobsWorker
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 14:20:56 +08:00
|
|
|
|
ASSET_MIME_TYPE = "application/x-cmshopee-image-studio-asset"
|
2026-07-11 17:28:37 +08:00
|
|
|
|
ORIGINAL_DOWNLOAD_CONCURRENCY = 2
|
|
|
|
|
|
_ORIGINAL_DOWNLOAD_THREAD_REFS = {}
|
2026-07-11 14:20:56 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def ImageStudioPullImagesWorker(*args, **kwargs):
|
|
|
|
|
|
return _call_package_attr(
|
|
|
|
|
|
"ImageStudioPullImagesWorker",
|
|
|
|
|
|
_RealImageStudioPullImagesWorker,
|
|
|
|
|
|
*args,
|
|
|
|
|
|
**kwargs,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ImageStudioDownloadOriginalWorker(*args, **kwargs):
|
|
|
|
|
|
return _call_package_attr(
|
|
|
|
|
|
"ImageStudioDownloadOriginalWorker",
|
|
|
|
|
|
_RealImageStudioDownloadOriginalWorker,
|
|
|
|
|
|
*args,
|
|
|
|
|
|
**kwargs,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ImageStudioGenerateJobsWorker(*args, **kwargs):
|
|
|
|
|
|
return _call_package_attr(
|
|
|
|
|
|
"ImageStudioGenerateJobsWorker",
|
|
|
|
|
|
_RealImageStudioGenerateJobsWorker,
|
|
|
|
|
|
*args,
|
|
|
|
|
|
**kwargs,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 14:26:01 +08:00
|
|
|
|
def ImageStudioExportWorker(*args, **kwargs):
|
|
|
|
|
|
return _call_package_attr(
|
|
|
|
|
|
"ImageStudioExportWorker",
|
|
|
|
|
|
_RealImageStudioExportWorker,
|
|
|
|
|
|
*args,
|
|
|
|
|
|
**kwargs,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 14:29:58 +08:00
|
|
|
|
def ImageStudioResumeJobsWorker(*args, **kwargs):
|
|
|
|
|
|
return _call_package_attr(
|
|
|
|
|
|
"ImageStudioResumeJobsWorker",
|
|
|
|
|
|
_RealImageStudioResumeJobsWorker,
|
|
|
|
|
|
*args,
|
|
|
|
|
|
**kwargs,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 14:20:56 +08:00
|
|
|
|
def _drop_event_position(event):
|
|
|
|
|
|
if hasattr(event, "position"):
|
|
|
|
|
|
return event.position().toPoint()
|
|
|
|
|
|
return event.pos()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mime_asset_id(mime_data):
|
|
|
|
|
|
if mime_data is None or not mime_data.hasFormat(ASSET_MIME_TYPE):
|
|
|
|
|
|
return None
|
|
|
|
|
|
raw = bytes(mime_data.data(ASSET_MIME_TYPE)).decode("utf-8", errors="ignore")
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(str(raw).strip())
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
class _ThumbnailSignals(QObject):
|
|
|
|
|
|
"""Deliver executor callbacks to the GUI thread without exposing remote URLs."""
|
2026-07-11 14:20:56 +08:00
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
loaded = Signal(object)
|
|
|
|
|
|
failed = Signal(object)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImageStudioThumbnailGrid(QListWidget):
|
|
|
|
|
|
"""Stable icon grid for original images, pool assets and job cards."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, *, draggable_assets=False, parent=None):
|
2026-07-11 14:20:56 +08:00
|
|
|
|
super().__init__(parent)
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self._draggable_assets = bool(draggable_assets)
|
|
|
|
|
|
self.setViewMode(QListView.IconMode)
|
|
|
|
|
|
self.setFlow(QListView.LeftToRight)
|
|
|
|
|
|
self.setWrapping(True)
|
|
|
|
|
|
self.setResizeMode(QListView.Adjust)
|
|
|
|
|
|
self.setMovement(QListView.Static)
|
|
|
|
|
|
self.setSpacing(6)
|
|
|
|
|
|
self.setUniformItemSizes(True)
|
|
|
|
|
|
self.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
|
|
|
|
self.setDragEnabled(self._draggable_assets)
|
|
|
|
|
|
self.setDragDropMode(
|
|
|
|
|
|
QAbstractItemView.DragOnly if self._draggable_assets else QAbstractItemView.NoDragDrop
|
|
|
|
|
|
)
|
2026-07-11 14:20:56 +08:00
|
|
|
|
self.setDefaultDropAction(Qt.CopyAction)
|
|
|
|
|
|
|
|
|
|
|
|
def mimeData(self, items):
|
|
|
|
|
|
mime = QMimeData()
|
2026-07-11 16:47:18 +08:00
|
|
|
|
if not self._draggable_assets or not items:
|
2026-07-11 14:20:56 +08:00
|
|
|
|
return mime
|
2026-07-11 16:47:18 +08:00
|
|
|
|
data = items[0].data(Qt.UserRole) or {}
|
2026-07-11 14:20:56 +08:00
|
|
|
|
if data.get("type") != "asset" or not data.get("draggable"):
|
|
|
|
|
|
return mime
|
|
|
|
|
|
mime.setData(ASSET_MIME_TYPE, str(data.get("asset_id")).encode("utf-8"))
|
|
|
|
|
|
return mime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImageStudioSelectionList(QListWidget):
|
|
|
|
|
|
"""Ordered final tray for main/detail selected assets."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, selection_type, tab, parent=None):
|
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
|
self.selection_type = selection_type
|
|
|
|
|
|
self.tab = tab
|
|
|
|
|
|
self.setAcceptDrops(True)
|
|
|
|
|
|
self.setDragEnabled(True)
|
|
|
|
|
|
self.setDefaultDropAction(Qt.MoveAction)
|
|
|
|
|
|
self.setDragDropMode(QAbstractItemView.DragDrop)
|
|
|
|
|
|
self.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
|
|
|
|
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
2026-07-11 17:46:58 +08:00
|
|
|
|
self.setViewMode(QListView.IconMode)
|
|
|
|
|
|
self.setFlow(QListView.LeftToRight)
|
|
|
|
|
|
self.setWrapping(False)
|
|
|
|
|
|
self.setResizeMode(QListView.Fixed)
|
|
|
|
|
|
self.setMovement(QListView.Static)
|
|
|
|
|
|
self.setSpacing(4)
|
|
|
|
|
|
self.setUniformItemSizes(True)
|
|
|
|
|
|
self.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
|
|
|
|
|
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
|
|
|
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
2026-07-11 14:20:56 +08:00
|
|
|
|
self.customContextMenuRequested.connect(self._show_context_menu)
|
|
|
|
|
|
|
|
|
|
|
|
def mimeData(self, items):
|
|
|
|
|
|
mime = QMimeData()
|
|
|
|
|
|
if not items:
|
|
|
|
|
|
return mime
|
|
|
|
|
|
asset_id = items[0].data(Qt.UserRole)
|
|
|
|
|
|
if asset_id is not None:
|
|
|
|
|
|
mime.setData(ASSET_MIME_TYPE, str(asset_id).encode("utf-8"))
|
|
|
|
|
|
return mime
|
|
|
|
|
|
|
|
|
|
|
|
def dragEnterEvent(self, event):
|
|
|
|
|
|
if _mime_asset_id(event.mimeData()) is not None:
|
|
|
|
|
|
event.acceptProposedAction()
|
|
|
|
|
|
return
|
|
|
|
|
|
super().dragEnterEvent(event)
|
|
|
|
|
|
|
|
|
|
|
|
def dragMoveEvent(self, event):
|
|
|
|
|
|
if _mime_asset_id(event.mimeData()) is not None:
|
|
|
|
|
|
event.acceptProposedAction()
|
|
|
|
|
|
return
|
|
|
|
|
|
super().dragMoveEvent(event)
|
|
|
|
|
|
|
|
|
|
|
|
def dropEvent(self, event):
|
|
|
|
|
|
asset_id = _mime_asset_id(event.mimeData())
|
|
|
|
|
|
if asset_id is None:
|
|
|
|
|
|
super().dropEvent(event)
|
|
|
|
|
|
return
|
|
|
|
|
|
index = self.indexAt(_drop_event_position(event))
|
|
|
|
|
|
insert_index = index.row() if index.isValid() else self.count()
|
|
|
|
|
|
if event.source() is self:
|
|
|
|
|
|
current_index = self._row_for_asset(asset_id)
|
|
|
|
|
|
if current_index is None:
|
|
|
|
|
|
event.ignore()
|
|
|
|
|
|
return
|
|
|
|
|
|
self.tab.move_selection_asset(self.selection_type, current_index, insert_index)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.tab.add_asset_to_selection(self.selection_type, asset_id, insert_index=insert_index)
|
|
|
|
|
|
event.acceptProposedAction()
|
|
|
|
|
|
|
|
|
|
|
|
def keyPressEvent(self, event):
|
|
|
|
|
|
if event.key() == Qt.Key_Delete:
|
|
|
|
|
|
self.remove_current_asset()
|
|
|
|
|
|
event.accept()
|
|
|
|
|
|
return
|
|
|
|
|
|
super().keyPressEvent(event)
|
|
|
|
|
|
|
|
|
|
|
|
def remove_current_asset(self):
|
|
|
|
|
|
item = self.currentItem()
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
asset_id = item.data(Qt.UserRole)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
if asset_id is None:
|
|
|
|
|
|
return
|
2026-07-11 14:20:56 +08:00
|
|
|
|
self.tab.remove_asset_from_selection(self.selection_type, asset_id)
|
|
|
|
|
|
|
|
|
|
|
|
def _show_context_menu(self, position):
|
|
|
|
|
|
item = self.itemAt(position)
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
menu = QMenu(self)
|
|
|
|
|
|
remove_action = menu.addAction("移出终选")
|
|
|
|
|
|
action = menu.exec(self.viewport().mapToGlobal(position))
|
|
|
|
|
|
if action is remove_action:
|
|
|
|
|
|
self.setCurrentItem(item)
|
|
|
|
|
|
self.remove_current_asset()
|
|
|
|
|
|
|
|
|
|
|
|
def _row_for_asset(self, asset_id):
|
|
|
|
|
|
for row in range(self.count()):
|
|
|
|
|
|
item = self.item(row)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
item_asset_id = item.data(Qt.UserRole) if item is not None else None
|
|
|
|
|
|
if item_asset_id is not None and int(item_asset_id) == int(asset_id):
|
2026-07-11 14:20:56 +08:00
|
|
|
|
return row
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 09:13:44 +08:00
|
|
|
|
class ImageStudioPreviewDialog(ImagePreviewDialog):
|
|
|
|
|
|
"""Compatibility wrapper for the shared full-image preview."""
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, asset, parent=None):
|
|
|
|
|
|
self.asset = asset
|
|
|
|
|
|
path = str(getattr(asset, "local_path", "") or "")
|
2026-07-16 09:13:44 +08:00
|
|
|
|
super().__init__(path, self._title_for_asset(asset), parent)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def _title_for_asset(self, asset):
|
|
|
|
|
|
badge = _asset_badge(getattr(asset, "kind", ""))
|
|
|
|
|
|
asset_id = getattr(asset, "id", "")
|
|
|
|
|
|
return f"AI工场图片预览:{badge} #{asset_id}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImageStudioTab(QWidget):
|
|
|
|
|
|
"""Sixth tab: project-based AI image studio."""
|
|
|
|
|
|
|
2026-07-11 16:23:22 +08:00
|
|
|
|
PROJECT_COLUMNS = ["店铺", "商品ID", "更新时间"]
|
2026-07-11 14:16:11 +08:00
|
|
|
|
JOB_STATUS_LABELS = {
|
2026-07-13 09:54:08 +08:00
|
|
|
|
"pending": "等待提交",
|
2026-07-11 14:16:11 +08:00
|
|
|
|
"submitted": "已提交",
|
|
|
|
|
|
"running": "生成中",
|
|
|
|
|
|
"succeeded": "成功",
|
2026-07-13 09:54:08 +08:00
|
|
|
|
"failed": "生成失败",
|
|
|
|
|
|
"expired": "任务过期",
|
2026-07-11 14:16:11 +08:00
|
|
|
|
"cancelled": "已停止",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
parent=None,
|
|
|
|
|
|
db_path=None,
|
|
|
|
|
|
config=None,
|
|
|
|
|
|
config_path=None,
|
|
|
|
|
|
status_callback=None,
|
|
|
|
|
|
prompts_dir=None,
|
2026-07-11 16:47:18 +08:00
|
|
|
|
thumbnail_loader=None,
|
2026-07-11 14:16:11 +08:00
|
|
|
|
):
|
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
|
self.setObjectName("imageStudioTab")
|
|
|
|
|
|
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.prompts_dir = prompts_dir or appconfig.image_studio_prompts_dir(self.config)
|
|
|
|
|
|
self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config)
|
|
|
|
|
|
self.status_callback = status_callback
|
|
|
|
|
|
self.projects = []
|
|
|
|
|
|
self.accounts = []
|
|
|
|
|
|
self.current_project = None
|
|
|
|
|
|
self.assets = []
|
|
|
|
|
|
self.jobs = []
|
|
|
|
|
|
self.selections = []
|
|
|
|
|
|
self.selected_source_asset_id = None
|
|
|
|
|
|
self._running_worker = None
|
|
|
|
|
|
self._running_thread = None
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._operation_running = False
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self._operation_kind = ""
|
2026-07-11 15:13:09 +08:00
|
|
|
|
self._worker_error_handled = False
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self._download_open_after = {}
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self._thumbnail_loader = thumbnail_loader or image_studio_images.ThumbnailLoader()
|
|
|
|
|
|
self._thumbnail_pixmaps = {}
|
|
|
|
|
|
self._thumbnail_pending = set()
|
|
|
|
|
|
self._thumbnail_errors = set()
|
|
|
|
|
|
self._thumbnail_project_id = None
|
2026-07-11 17:28:37 +08:00
|
|
|
|
self._original_downloads = {}
|
|
|
|
|
|
self._original_download_queue = []
|
|
|
|
|
|
self._original_download_states = {}
|
|
|
|
|
|
self._original_downloads_closing = False
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self._thumbnail_signals = _ThumbnailSignals(self)
|
|
|
|
|
|
self._thumbnail_signals.loaded.connect(self._on_thumbnail_loaded)
|
|
|
|
|
|
self._thumbnail_signals.failed.connect(self._on_thumbnail_failed)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
self._build_ui()
|
|
|
|
|
|
self._connect_signals()
|
2026-07-11 14:49:39 +08:00
|
|
|
|
self._refresh_model_hint()
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.refresh_accounts()
|
|
|
|
|
|
self.refresh_templates()
|
|
|
|
|
|
self.refresh_projects()
|
|
|
|
|
|
|
|
|
|
|
|
def _build_ui(self):
|
|
|
|
|
|
root = QVBoxLayout(self)
|
|
|
|
|
|
root.setContentsMargins(10, 8, 10, 8)
|
|
|
|
|
|
root.setSpacing(8)
|
|
|
|
|
|
|
2026-07-11 15:41:38 +08:00
|
|
|
|
root.addWidget(self._build_top_bar(), 0)
|
|
|
|
|
|
|
2026-07-11 17:28:37 +08:00
|
|
|
|
self.main_splitter = QSplitter(Qt.Horizontal)
|
|
|
|
|
|
self.main_splitter.setObjectName("imageStudioMainSplitter")
|
|
|
|
|
|
self.main_splitter.addWidget(self._build_project_panel())
|
|
|
|
|
|
self.main_splitter.addWidget(self._build_pool_panel())
|
|
|
|
|
|
self.main_splitter.addWidget(self._build_generation_panel())
|
|
|
|
|
|
self.main_splitter.setStretchFactor(0, 0)
|
|
|
|
|
|
self.main_splitter.setStretchFactor(1, 4)
|
|
|
|
|
|
self.main_splitter.setStretchFactor(2, 2)
|
|
|
|
|
|
self.main_splitter.setSizes([150, 640, 360])
|
|
|
|
|
|
root.addWidget(self.main_splitter, 1)
|
2026-07-11 17:46:58 +08:00
|
|
|
|
self.final_panel = self._build_final_panel()
|
|
|
|
|
|
root.addWidget(self.final_panel, 0)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self._apply_workspace_style()
|
|
|
|
|
|
self._update_generation_action_text()
|
|
|
|
|
|
|
|
|
|
|
|
def _build_top_bar(self):
|
|
|
|
|
|
panel = QWidget()
|
|
|
|
|
|
panel.setObjectName("imageStudioTopBar")
|
|
|
|
|
|
layout = QHBoxLayout(panel)
|
|
|
|
|
|
layout.setContentsMargins(8, 6, 8, 6)
|
|
|
|
|
|
layout.setSpacing(8)
|
|
|
|
|
|
|
|
|
|
|
|
self.account_combo = QComboBox()
|
|
|
|
|
|
self.account_combo.setObjectName("imageStudioAccountCombo")
|
|
|
|
|
|
self.account_combo.setMinimumWidth(180)
|
|
|
|
|
|
self.item_id_edit = QLineEdit()
|
|
|
|
|
|
self.item_id_edit.setObjectName("imageStudioItemIdEdit")
|
|
|
|
|
|
self.item_id_edit.setPlaceholderText("商品ID")
|
|
|
|
|
|
self.item_id_edit.setMinimumWidth(160)
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self.current_project_label = QLabel("未选择商品")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.current_project_label.setObjectName("imageStudioCurrentProjectLabel")
|
|
|
|
|
|
self.current_project_label.setMinimumWidth(220)
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self.autosave_label = QLabel("选择店铺并输入商品ID后拉取")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.autosave_label.setObjectName("imageStudioAutosaveLabel")
|
|
|
|
|
|
|
|
|
|
|
|
self.pull_images_button = QPushButton("拉取蝦皮主图")
|
|
|
|
|
|
self.pull_images_button.setObjectName("imageStudioPullImagesButton")
|
|
|
|
|
|
self.open_folder_button = QPushButton("打开项目文件夹")
|
|
|
|
|
|
self.open_folder_button.setObjectName("imageStudioOpenFolderButton")
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self.delete_project_button = QPushButton("删除项目")
|
|
|
|
|
|
self.delete_project_button.setObjectName("imageStudioDeleteProjectButton")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
|
|
|
|
|
|
layout.addWidget(QLabel("账号"))
|
|
|
|
|
|
layout.addWidget(self.account_combo)
|
|
|
|
|
|
layout.addWidget(QLabel("商品ID"))
|
|
|
|
|
|
layout.addWidget(self.item_id_edit)
|
|
|
|
|
|
layout.addSpacing(8)
|
|
|
|
|
|
layout.addWidget(self.current_project_label, 1)
|
2026-07-11 16:23:22 +08:00
|
|
|
|
layout.addWidget(self.pull_images_button)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
layout.addWidget(self.autosave_label)
|
|
|
|
|
|
layout.addStretch(1)
|
|
|
|
|
|
layout.addWidget(self.open_folder_button)
|
2026-07-11 16:23:22 +08:00
|
|
|
|
layout.addWidget(self.delete_project_button)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
return panel
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def _build_project_panel(self):
|
|
|
|
|
|
panel = QWidget()
|
|
|
|
|
|
panel.setObjectName("imageStudioProjectPanel")
|
|
|
|
|
|
layout = QVBoxLayout(panel)
|
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
|
layout.setSpacing(8)
|
|
|
|
|
|
|
2026-07-11 16:23:22 +08:00
|
|
|
|
title = QLabel("商品列表")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
title.setObjectName("imageStudioSectionTitle")
|
|
|
|
|
|
hint = QLabel("点击切换项目,项目会自动保存")
|
|
|
|
|
|
hint.setObjectName("imageStudioMutedLabel")
|
|
|
|
|
|
hint.setWordWrap(True)
|
|
|
|
|
|
layout.addWidget(title)
|
|
|
|
|
|
layout.addWidget(hint)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
self.project_table = QTableWidget(0, len(self.PROJECT_COLUMNS))
|
|
|
|
|
|
self.project_table.setObjectName("imageStudioProjectList")
|
|
|
|
|
|
self.project_table.setHorizontalHeaderLabels(self.PROJECT_COLUMNS)
|
|
|
|
|
|
self.project_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
|
|
|
|
self.project_table.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
|
|
|
|
self.project_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
|
|
|
|
self.project_table.horizontalHeader().setStretchLastSection(True)
|
|
|
|
|
|
self.project_table.verticalHeader().setVisible(False)
|
2026-07-11 17:28:37 +08:00
|
|
|
|
self.project_table.setMinimumWidth(150)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
layout.addWidget(self.project_table, 1)
|
|
|
|
|
|
return panel
|
|
|
|
|
|
|
|
|
|
|
|
def _build_pool_panel(self):
|
|
|
|
|
|
panel = QWidget()
|
|
|
|
|
|
panel.setObjectName("imageStudioPoolPanel")
|
|
|
|
|
|
layout = QVBoxLayout(panel)
|
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
|
layout.setSpacing(8)
|
|
|
|
|
|
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self.workspace_empty_label = QLabel("选择店铺并输入商品ID后,点击「拉取蝦皮主图」开始。")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.workspace_empty_label.setObjectName("imageStudioWorkspaceEmptyLabel")
|
|
|
|
|
|
self.workspace_empty_label.setWordWrap(True)
|
|
|
|
|
|
layout.addWidget(self.workspace_empty_label)
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
original_header = QHBoxLayout()
|
2026-07-11 15:41:38 +08:00
|
|
|
|
original_title = QLabel("蝦皮原主图")
|
|
|
|
|
|
original_title.setObjectName("imageStudioSectionTitle")
|
|
|
|
|
|
original_header.addWidget(original_title)
|
2026-07-11 17:28:37 +08:00
|
|
|
|
self.original_download_label = QLabel("")
|
|
|
|
|
|
self.original_download_label.setObjectName("imageStudioOriginalDownloadLabel")
|
|
|
|
|
|
self.original_download_label.setMinimumWidth(156)
|
|
|
|
|
|
original_header.addWidget(self.original_download_label)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
original_header.addStretch(1)
|
|
|
|
|
|
self.original_hint_label = QLabel("单击下载并加入照片池,双击查看大图")
|
|
|
|
|
|
self.original_hint_label.setObjectName("imageStudioOriginalHintLabel")
|
|
|
|
|
|
original_header.addWidget(self.original_hint_label)
|
|
|
|
|
|
layout.addLayout(original_header)
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self.original_grid = ImageStudioThumbnailGrid(parent=self)
|
|
|
|
|
|
self.original_grid.setObjectName("imageStudioOriginalGrid")
|
2026-07-11 17:28:37 +08:00
|
|
|
|
self.original_grid.setIconSize(QSize(48, 48))
|
|
|
|
|
|
self.original_grid.setGridSize(QSize(58, 78))
|
|
|
|
|
|
self.original_grid.setMinimumHeight(94)
|
|
|
|
|
|
self.original_grid.setMaximumHeight(110)
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self.original_grid.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
|
|
|
|
layout.addWidget(self.original_grid, 0)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
pool_header = QHBoxLayout()
|
2026-07-11 15:41:38 +08:00
|
|
|
|
pool_title = QLabel("照片池")
|
|
|
|
|
|
pool_title.setObjectName("imageStudioSectionTitle")
|
|
|
|
|
|
pool_header.addWidget(pool_title)
|
2026-07-13 09:54:08 +08:00
|
|
|
|
self.pool_summary_label = QLabel("可用图片 0 张")
|
|
|
|
|
|
self.pool_summary_label.setObjectName("imageStudioMutedLabel")
|
|
|
|
|
|
pool_header.addWidget(self.pool_summary_label)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
pool_header.addStretch(1)
|
|
|
|
|
|
self.source_label = QLabel("源图:未选择")
|
|
|
|
|
|
self.source_label.setObjectName("imageStudioSourceLabel")
|
|
|
|
|
|
pool_header.addWidget(self.source_label)
|
|
|
|
|
|
layout.addLayout(pool_header)
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self.pool_grid = ImageStudioThumbnailGrid(draggable_assets=True, parent=self)
|
|
|
|
|
|
self.pool_grid.setObjectName("imageStudioPoolGrid")
|
|
|
|
|
|
self.pool_grid.setIconSize(QSize(86, 86))
|
|
|
|
|
|
self.pool_grid.setGridSize(QSize(108, 124))
|
|
|
|
|
|
self.pool_grid.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
|
|
|
|
layout.addWidget(self.pool_grid, 2)
|
2026-07-13 09:54:08 +08:00
|
|
|
|
|
|
|
|
|
|
self.job_section = QWidget()
|
|
|
|
|
|
self.job_section.setObjectName("imageStudioJobSection")
|
|
|
|
|
|
job_layout = QVBoxLayout(self.job_section)
|
|
|
|
|
|
job_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
|
job_layout.setSpacing(4)
|
|
|
|
|
|
job_header = QHBoxLayout()
|
|
|
|
|
|
job_title = QLabel("生成任务")
|
|
|
|
|
|
job_title.setObjectName("imageStudioSectionTitle")
|
|
|
|
|
|
job_header.addWidget(job_title)
|
|
|
|
|
|
self.job_summary_label = QLabel("未完成/异常任务 0 个")
|
|
|
|
|
|
self.job_summary_label.setObjectName("imageStudioMutedLabel")
|
|
|
|
|
|
job_header.addWidget(self.job_summary_label)
|
|
|
|
|
|
job_header.addStretch(1)
|
|
|
|
|
|
job_layout.addLayout(job_header)
|
|
|
|
|
|
self.job_grid = ImageStudioThumbnailGrid(parent=self.job_section)
|
|
|
|
|
|
self.job_grid.setObjectName("imageStudioJobGrid")
|
|
|
|
|
|
self.job_grid.setIconSize(QSize(42, 42))
|
|
|
|
|
|
self.job_grid.setGridSize(QSize(162, 94))
|
|
|
|
|
|
self.job_grid.setMinimumHeight(100)
|
|
|
|
|
|
self.job_grid.setMaximumHeight(112)
|
|
|
|
|
|
job_layout.addWidget(self.job_grid)
|
|
|
|
|
|
self.job_section.setVisible(False)
|
|
|
|
|
|
layout.addWidget(self.job_section, 0)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
return panel
|
|
|
|
|
|
|
|
|
|
|
|
def _build_generation_panel(self):
|
|
|
|
|
|
panel = QWidget()
|
|
|
|
|
|
panel.setObjectName("imageStudioGenerationPanel")
|
|
|
|
|
|
layout = QVBoxLayout(panel)
|
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
|
layout.setSpacing(8)
|
|
|
|
|
|
|
2026-07-11 15:41:38 +08:00
|
|
|
|
title = QLabel("生成设置")
|
|
|
|
|
|
title.setObjectName("imageStudioSectionTitle")
|
|
|
|
|
|
layout.addWidget(title)
|
|
|
|
|
|
|
|
|
|
|
|
source_row = QHBoxLayout()
|
|
|
|
|
|
self.source_preview_label = QLabel("未选择源图")
|
|
|
|
|
|
self.source_preview_label.setObjectName("imageStudioSourcePreview")
|
|
|
|
|
|
self.source_preview_label.setAlignment(Qt.AlignCenter)
|
|
|
|
|
|
self.source_preview_label.setMinimumSize(86, 86)
|
2026-07-11 16:47:18 +08:00
|
|
|
|
source_preview_tooltip = "当前源图。请在照片池单击选择源图,双击可查看大图。"
|
|
|
|
|
|
self.source_preview_label.setToolTip(source_preview_tooltip)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
source_row.addWidget(self.source_preview_label, 0)
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
template_layout = QVBoxLayout()
|
2026-07-11 15:41:38 +08:00
|
|
|
|
template_layout.setSpacing(6)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.template_combo = QComboBox()
|
|
|
|
|
|
self.template_combo.setObjectName("imageStudioTemplateCombo")
|
|
|
|
|
|
self.template_new_button = QPushButton("新建")
|
|
|
|
|
|
self.template_new_button.setObjectName("imageStudioTemplateNewButton")
|
|
|
|
|
|
self.template_rename_button = QPushButton("重命名")
|
|
|
|
|
|
self.template_rename_button.setObjectName("imageStudioTemplateRenameButton")
|
|
|
|
|
|
self.template_save_button = QPushButton("保存")
|
|
|
|
|
|
self.template_save_button.setObjectName("imageStudioTemplateSaveButton")
|
|
|
|
|
|
self.template_delete_button = QPushButton("删除")
|
|
|
|
|
|
self.template_delete_button.setObjectName("imageStudioTemplateDeleteButton")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
template_label = QLabel("提示词模板")
|
|
|
|
|
|
template_label.setObjectName("imageStudioMutedLabel")
|
2026-07-11 16:47:18 +08:00
|
|
|
|
template_select_row = QHBoxLayout()
|
|
|
|
|
|
template_select_row.addWidget(template_label)
|
|
|
|
|
|
template_select_row.addWidget(self.template_combo, 1)
|
|
|
|
|
|
template_actions = QHBoxLayout()
|
|
|
|
|
|
for button in (
|
|
|
|
|
|
self.template_new_button,
|
|
|
|
|
|
self.template_rename_button,
|
|
|
|
|
|
self.template_save_button,
|
|
|
|
|
|
self.template_delete_button,
|
|
|
|
|
|
):
|
|
|
|
|
|
template_actions.addWidget(button, 1)
|
|
|
|
|
|
template_layout.addLayout(template_select_row)
|
|
|
|
|
|
template_layout.addLayout(template_actions)
|
|
|
|
|
|
source_row.addLayout(template_layout, 1)
|
|
|
|
|
|
layout.addLayout(source_row)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
self.prompt_edit = QPlainTextEdit()
|
|
|
|
|
|
self.prompt_edit.setObjectName("imageStudioPromptEdit")
|
|
|
|
|
|
self.prompt_edit.setPlaceholderText("输入完整图片生成提示词")
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self.prompt_edit.setMinimumHeight(210)
|
|
|
|
|
|
layout.addWidget(self.prompt_edit, 3)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
prompt_hint = QLabel("程序原样提交完整提示词,不自动拆分,也不自动追加动作词。")
|
|
|
|
|
|
prompt_hint.setObjectName("imageStudioMutedLabel")
|
|
|
|
|
|
prompt_hint.setWordWrap(True)
|
|
|
|
|
|
layout.addWidget(prompt_hint)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
self.job_type_combo = QComboBox()
|
|
|
|
|
|
self.job_type_combo.setObjectName("imageStudioJobTypeCombo")
|
|
|
|
|
|
self.job_type_combo.addItem("主图", "main")
|
|
|
|
|
|
self.job_type_combo.addItem("详情图", "detail")
|
|
|
|
|
|
self.count_spin = QSpinBox()
|
|
|
|
|
|
self.count_spin.setObjectName("imageStudioCountSpin")
|
|
|
|
|
|
self.count_spin.setRange(1, 12)
|
|
|
|
|
|
self.count_spin.setValue(4)
|
|
|
|
|
|
self.aspect_combo = QComboBox()
|
|
|
|
|
|
self.aspect_combo.setObjectName("imageStudioAspectCombo")
|
|
|
|
|
|
for value in ("1:1", "3:4", "4:3", "9:16", "16:9"):
|
|
|
|
|
|
self.aspect_combo.addItem(value, value)
|
2026-07-11 16:47:18 +08:00
|
|
|
|
generation_form = QHBoxLayout()
|
|
|
|
|
|
generation_form.setSpacing(8)
|
|
|
|
|
|
for label, field in (
|
|
|
|
|
|
("类型", self.job_type_combo),
|
|
|
|
|
|
("数量", self.count_spin),
|
|
|
|
|
|
("比例", self.aspect_combo),
|
|
|
|
|
|
):
|
|
|
|
|
|
group = QHBoxLayout()
|
|
|
|
|
|
group.addWidget(QLabel(label))
|
|
|
|
|
|
group.addWidget(field, 1)
|
|
|
|
|
|
generation_form.addLayout(group, 1)
|
|
|
|
|
|
layout.addLayout(generation_form)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
2026-07-11 14:49:39 +08:00
|
|
|
|
self.billing_label = QLabel("cmhub 托管默认档:扣点以返回结果为准")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.billing_label.setObjectName("imageStudioBillingLabel")
|
|
|
|
|
|
self.billing_label.setWordWrap(True)
|
|
|
|
|
|
layout.addWidget(self.billing_label)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.generation_guard_label = QLabel("不会在失败后静默切换生成来源。")
|
|
|
|
|
|
self.generation_guard_label.setObjectName("imageStudioMutedLabel")
|
|
|
|
|
|
self.generation_guard_label.setWordWrap(True)
|
|
|
|
|
|
layout.addWidget(self.generation_guard_label)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
action_layout = QHBoxLayout()
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.start_button = QPushButton("生成 4 张主图")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.start_button.setObjectName("imageStudioStartButton")
|
2026-07-11 14:29:58 +08:00
|
|
|
|
self.resume_button = QPushButton("继续查询任务")
|
|
|
|
|
|
self.resume_button.setObjectName("imageStudioResumeButton")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.stop_button = QPushButton("停止")
|
|
|
|
|
|
self.stop_button.setObjectName("imageStudioStopButton")
|
|
|
|
|
|
self.stop_button.setEnabled(False)
|
|
|
|
|
|
action_layout.addWidget(self.start_button)
|
2026-07-11 14:29:58 +08:00
|
|
|
|
action_layout.addWidget(self.resume_button)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
action_layout.addWidget(self.stop_button)
|
|
|
|
|
|
layout.addLayout(action_layout)
|
|
|
|
|
|
|
|
|
|
|
|
self.progress_bar = QProgressBar()
|
|
|
|
|
|
self.progress_bar.setObjectName("imageStudioProgressBar")
|
|
|
|
|
|
self.progress_bar.setRange(0, 1)
|
|
|
|
|
|
self.progress_bar.setValue(0)
|
|
|
|
|
|
layout.addWidget(self.progress_bar)
|
|
|
|
|
|
|
|
|
|
|
|
self.log_view = QPlainTextEdit()
|
|
|
|
|
|
self.log_view.setObjectName("imageStudioLogView")
|
|
|
|
|
|
self.log_view.setReadOnly(True)
|
|
|
|
|
|
self.log_view.setPlaceholderText("运行日志会在开始后显示")
|
|
|
|
|
|
layout.addWidget(self.log_view, 1)
|
|
|
|
|
|
return panel
|
|
|
|
|
|
|
|
|
|
|
|
def _build_final_panel(self):
|
|
|
|
|
|
panel = QWidget()
|
|
|
|
|
|
panel.setObjectName("imageStudioFinalPanel")
|
2026-07-11 17:46:58 +08:00
|
|
|
|
panel.setMinimumHeight(150)
|
|
|
|
|
|
panel.setMaximumHeight(170)
|
|
|
|
|
|
panel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
|
|
|
|
layout = QGridLayout(panel)
|
|
|
|
|
|
layout.setContentsMargins(10, 8, 10, 8)
|
|
|
|
|
|
layout.setHorizontalSpacing(8)
|
|
|
|
|
|
layout.setVerticalSpacing(4)
|
|
|
|
|
|
|
|
|
|
|
|
header_layout = QHBoxLayout()
|
2026-07-11 15:41:38 +08:00
|
|
|
|
final_title = QLabel("终选与排序")
|
|
|
|
|
|
final_title.setObjectName("imageStudioSectionTitle")
|
2026-07-11 17:46:58 +08:00
|
|
|
|
header_hint = QLabel("从照片池拖入,可在同一行拖动排序,双击查看大图")
|
|
|
|
|
|
header_hint.setObjectName("imageStudioMutedLabel")
|
|
|
|
|
|
header_layout.addWidget(final_title)
|
|
|
|
|
|
header_layout.addWidget(header_hint)
|
|
|
|
|
|
header_layout.addStretch(1)
|
|
|
|
|
|
layout.addLayout(header_layout, 0, 0, 1, 2)
|
|
|
|
|
|
|
2026-07-11 14:20:56 +08:00
|
|
|
|
self.main_selection_label = QLabel("主图终选 0/9")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.main_selection_label.setObjectName("imageStudioMainSelectionLabel")
|
2026-07-11 17:46:58 +08:00
|
|
|
|
self.main_selection_label.setFixedWidth(64)
|
|
|
|
|
|
self.main_selection_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
2026-07-11 14:20:56 +08:00
|
|
|
|
self.main_selection_list = ImageStudioSelectionList("main", self)
|
|
|
|
|
|
self.main_selection_list.setObjectName("imageStudioMainSelectionList")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.main_selection_list.setIconSize(QSize(48, 48))
|
2026-07-11 17:46:58 +08:00
|
|
|
|
self.main_selection_list.setGridSize(QSize(60, 62))
|
|
|
|
|
|
self.main_selection_list.setFixedHeight(66)
|
|
|
|
|
|
layout.addWidget(self.main_selection_label, 1, 0)
|
|
|
|
|
|
layout.addWidget(self.main_selection_list, 1, 1)
|
2026-07-11 14:20:56 +08:00
|
|
|
|
|
|
|
|
|
|
self.detail_selection_label = QLabel("详情图终选 0/12")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.detail_selection_label.setObjectName("imageStudioDetailSelectionLabel")
|
2026-07-11 17:46:58 +08:00
|
|
|
|
self.detail_selection_label.setFixedWidth(64)
|
|
|
|
|
|
self.detail_selection_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
2026-07-11 14:20:56 +08:00
|
|
|
|
self.detail_selection_list = ImageStudioSelectionList("detail", self)
|
|
|
|
|
|
self.detail_selection_list.setObjectName("imageStudioDetailSelectionList")
|
2026-07-11 17:46:58 +08:00
|
|
|
|
self.detail_selection_list.setIconSize(QSize(38, 38))
|
|
|
|
|
|
self.detail_selection_list.setGridSize(QSize(50, 52))
|
|
|
|
|
|
self.detail_selection_list.setFixedHeight(56)
|
|
|
|
|
|
layout.addWidget(self.detail_selection_label, 2, 0)
|
|
|
|
|
|
layout.addWidget(self.detail_selection_list, 2, 1)
|
|
|
|
|
|
|
2026-07-11 14:26:01 +08:00
|
|
|
|
action_panel = QWidget()
|
|
|
|
|
|
action_layout = QVBoxLayout(action_panel)
|
|
|
|
|
|
action_layout.setContentsMargins(0, 0, 0, 0)
|
2026-07-11 17:46:58 +08:00
|
|
|
|
action_layout.setSpacing(3)
|
|
|
|
|
|
self.export_summary_label = QLabel("当前未选择终选图片")
|
|
|
|
|
|
self.export_summary_label.setObjectName("imageStudioExportSummaryLabel")
|
|
|
|
|
|
self.export_summary_label.setWordWrap(True)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.export_button = QPushButton("导出到文件夹")
|
2026-07-11 14:26:01 +08:00
|
|
|
|
self.export_button.setObjectName("imageStudioExportButton")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.export_hint_label = QLabel("可部分导出;文件名按终选顺序连续,目标目录存在时可覆盖或新建时间目录。")
|
2026-07-11 14:26:01 +08:00
|
|
|
|
self.export_hint_label.setObjectName("imageStudioExportHintLabel")
|
|
|
|
|
|
self.export_hint_label.setWordWrap(True)
|
2026-07-11 17:46:58 +08:00
|
|
|
|
action_layout.addWidget(self.export_summary_label)
|
2026-07-11 14:26:01 +08:00
|
|
|
|
action_layout.addWidget(self.export_hint_label)
|
|
|
|
|
|
action_layout.addStretch(1)
|
2026-07-11 17:46:58 +08:00
|
|
|
|
action_layout.addWidget(self.export_button)
|
|
|
|
|
|
action_panel.setMinimumWidth(248)
|
|
|
|
|
|
action_panel.setMaximumWidth(280)
|
|
|
|
|
|
layout.addWidget(action_panel, 1, 2, 2, 1)
|
|
|
|
|
|
layout.setColumnStretch(1, 1)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
return panel
|
|
|
|
|
|
|
2026-07-11 15:41:38 +08:00
|
|
|
|
def _apply_workspace_style(self):
|
|
|
|
|
|
self.setStyleSheet(
|
|
|
|
|
|
"""
|
|
|
|
|
|
#imageStudioTopBar,
|
|
|
|
|
|
#imageStudioProjectPanel,
|
|
|
|
|
|
#imageStudioPoolPanel,
|
|
|
|
|
|
#imageStudioGenerationPanel,
|
|
|
|
|
|
#imageStudioFinalPanel {
|
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
border: 1px solid #d8dee4;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
#imageStudioTopBar {
|
|
|
|
|
|
background: #f6f8fa;
|
|
|
|
|
|
}
|
|
|
|
|
|
#imageStudioSectionTitle {
|
|
|
|
|
|
color: #202938;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
#imageStudioMutedLabel,
|
|
|
|
|
|
#imageStudioOriginalHintLabel,
|
|
|
|
|
|
#imageStudioAutosaveLabel,
|
|
|
|
|
|
#imageStudioWorkspaceEmptyLabel,
|
|
|
|
|
|
#imageStudioExportHintLabel {
|
|
|
|
|
|
color: #6e7781;
|
|
|
|
|
|
}
|
2026-07-11 17:46:58 +08:00
|
|
|
|
#imageStudioMainSelectionLabel {
|
|
|
|
|
|
color: #0969da;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
#imageStudioDetailSelectionLabel {
|
|
|
|
|
|
color: #1a7f37;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
#imageStudioExportSummaryLabel {
|
|
|
|
|
|
color: #1a7f37;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
2026-07-11 15:41:38 +08:00
|
|
|
|
#imageStudioCurrentProjectLabel {
|
|
|
|
|
|
color: #24292f;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
2026-07-11 17:28:37 +08:00
|
|
|
|
#imageStudioOriginalDownloadLabel {
|
|
|
|
|
|
color: #0969da;
|
|
|
|
|
|
}
|
2026-07-11 15:41:38 +08:00
|
|
|
|
#imageStudioSourcePreview {
|
|
|
|
|
|
border: 1px dashed #bfc8d3;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
background: #fbfcfe;
|
|
|
|
|
|
color: #6e7781;
|
|
|
|
|
|
}
|
|
|
|
|
|
#imageStudioBillingLabel {
|
|
|
|
|
|
border: 1px solid #b9d7fa;
|
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
|
padding: 6px;
|
|
|
|
|
|
background: #eef6ff;
|
|
|
|
|
|
color: #24292f;
|
|
|
|
|
|
}
|
2026-07-11 16:23:22 +08:00
|
|
|
|
#imageStudioDeleteProjectButton {
|
|
|
|
|
|
color: #b42318;
|
|
|
|
|
|
}
|
2026-07-11 16:47:18 +08:00
|
|
|
|
QListWidget#imageStudioOriginalGrid,
|
|
|
|
|
|
QListWidget#imageStudioPoolGrid,
|
2026-07-11 15:41:38 +08:00
|
|
|
|
QListWidget#imageStudioMainSelectionList,
|
|
|
|
|
|
QListWidget#imageStudioDetailSelectionList {
|
|
|
|
|
|
border: 1px solid #d8dee4;
|
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
|
background: #fbfcfe;
|
|
|
|
|
|
gridline-color: #eaeef2;
|
|
|
|
|
|
selection-background-color: #ddf4ff;
|
|
|
|
|
|
selection-color: #24292f;
|
|
|
|
|
|
}
|
2026-07-11 17:46:58 +08:00
|
|
|
|
QListWidget#imageStudioMainSelectionList::item,
|
|
|
|
|
|
QListWidget#imageStudioDetailSelectionList::item {
|
|
|
|
|
|
border: 1px dashed #bfc8d3;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
QListWidget#imageStudioMainSelectionList::item:selected,
|
|
|
|
|
|
QListWidget#imageStudioDetailSelectionList::item:selected {
|
|
|
|
|
|
border: 2px solid #0969da;
|
|
|
|
|
|
background: #eaf2ff;
|
|
|
|
|
|
}
|
2026-07-11 15:41:38 +08:00
|
|
|
|
"""
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def _connect_signals(self):
|
|
|
|
|
|
self.pull_images_button.clicked.connect(self.pull_main_images)
|
|
|
|
|
|
self.open_folder_button.clicked.connect(self.open_project_folder)
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self.delete_project_button.clicked.connect(self.delete_current_project)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.project_table.itemSelectionChanged.connect(self._on_project_selection_changed)
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self.original_grid.itemClicked.connect(self._on_original_clicked)
|
|
|
|
|
|
self.original_grid.itemDoubleClicked.connect(self._on_original_double_clicked)
|
|
|
|
|
|
self.original_grid.customContextMenuRequested.connect(self._show_original_context_menu)
|
|
|
|
|
|
self.pool_grid.itemClicked.connect(self._on_pool_clicked)
|
|
|
|
|
|
self.pool_grid.itemDoubleClicked.connect(self._on_pool_double_clicked)
|
|
|
|
|
|
self.pool_grid.customContextMenuRequested.connect(self._show_pool_context_menu)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.template_combo.currentIndexChanged.connect(self.load_selected_template)
|
|
|
|
|
|
self.template_new_button.clicked.connect(self.create_template)
|
|
|
|
|
|
self.template_rename_button.clicked.connect(self.rename_template)
|
|
|
|
|
|
self.template_save_button.clicked.connect(self.save_template)
|
|
|
|
|
|
self.template_delete_button.clicked.connect(self.delete_template)
|
|
|
|
|
|
self.prompt_edit.textChanged.connect(self._save_project_prompt)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.job_type_combo.currentIndexChanged.connect(self._update_generation_action_text)
|
|
|
|
|
|
self.count_spin.valueChanged.connect(self._update_generation_action_text)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.start_button.clicked.connect(self.start_generation)
|
2026-07-11 14:29:58 +08:00
|
|
|
|
self.resume_button.clicked.connect(self.resume_generation_jobs)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.stop_button.clicked.connect(self.stop_generation)
|
2026-07-11 14:26:01 +08:00
|
|
|
|
self.export_button.clicked.connect(self.export_selections)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def refresh_accounts(self):
|
|
|
|
|
|
self.account_combo.clear()
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.accounts = accounts.list_accounts(path=self.db_path, config=self.config)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.accounts = []
|
|
|
|
|
|
self._status(f"账号读取失败:{exc}", "danger")
|
|
|
|
|
|
for account in self.accounts:
|
|
|
|
|
|
self.account_combo.addItem(
|
|
|
|
|
|
f"{account.account_name}({account.alias})",
|
|
|
|
|
|
account.alias,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not self.accounts:
|
|
|
|
|
|
self.account_combo.addItem("暂无账号,请先到④账号管理添加", "")
|
|
|
|
|
|
|
|
|
|
|
|
def refresh_projects(self):
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.init_db(self.db_path)
|
|
|
|
|
|
self.projects = image_studio.list_projects(path=self.db_path)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.projects = []
|
|
|
|
|
|
self._status(f"AI工场项目读取失败:{exc}", "danger")
|
|
|
|
|
|
self._fill_project_table()
|
2026-07-11 16:23:22 +08:00
|
|
|
|
active_ids = {int(project.id) for project in self.projects}
|
|
|
|
|
|
if self.current_project is not None and int(self.current_project.id) in active_ids:
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self._select_project(self.current_project.id, quiet=True)
|
2026-07-11 16:23:22 +08:00
|
|
|
|
elif self.projects:
|
|
|
|
|
|
self._select_project(self.projects[0].id)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
else:
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._clear_current_project()
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def _fill_project_table(self):
|
|
|
|
|
|
self.project_table.setRowCount(len(self.projects))
|
|
|
|
|
|
for row, project in enumerate(self.projects):
|
|
|
|
|
|
values = [
|
|
|
|
|
|
project.account_name or project.account_alias,
|
|
|
|
|
|
project.item_id,
|
|
|
|
|
|
project.updated_at,
|
|
|
|
|
|
]
|
|
|
|
|
|
for column, value in enumerate(values):
|
|
|
|
|
|
item = QTableWidgetItem(str(value or ""))
|
|
|
|
|
|
item.setData(Qt.UserRole, int(project.id))
|
|
|
|
|
|
self.project_table.setItem(row, column, item)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.project_table.setRowHeight(row, 44)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.project_table.resizeColumnsToContents()
|
|
|
|
|
|
|
|
|
|
|
|
def pull_main_images(self, checked=False):
|
2026-07-13 09:38:30 +08:00
|
|
|
|
if self._operation_running:
|
|
|
|
|
|
self._status("AI工场当前任务仍在进行,暂不能拉取蝦皮主图", "warning")
|
|
|
|
|
|
return
|
2026-07-11 14:16:11 +08:00
|
|
|
|
alias = str(self.account_combo.currentData() or "").strip()
|
|
|
|
|
|
item_id = self.item_id_edit.text().strip()
|
|
|
|
|
|
if not alias or not item_id:
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._message("信息未填写完整", "请先选择店铺并输入商品ID,再拉取蝦皮主图。")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
return
|
2026-07-11 17:28:37 +08:00
|
|
|
|
project = image_studio.get_project_by_account_item(
|
|
|
|
|
|
alias,
|
|
|
|
|
|
item_id,
|
|
|
|
|
|
path=self.db_path,
|
|
|
|
|
|
include_deleted=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
if project is not None and self._has_original_download_activity(project.id):
|
|
|
|
|
|
self._message(
|
|
|
|
|
|
"原图下载尚未完成",
|
|
|
|
|
|
"当前商品还有蝦皮原主图正在下载或等待下载,请等待完成后再重新拉取。",
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
local_count = self._local_original_count(project)
|
|
|
|
|
|
if local_count and not self._confirm(
|
|
|
|
|
|
"重新拉取蝦皮主图",
|
|
|
|
|
|
f"本地已保存 {local_count} 张蝦皮原主图。是否重新拉取线上主图?\n"
|
|
|
|
|
|
"重新拉取会刷新当前线上主图列表;不会删除本地图片、生成记录或终选记录。",
|
|
|
|
|
|
):
|
|
|
|
|
|
self._status("已取消重新拉取蝦皮主图", "muted")
|
|
|
|
|
|
return
|
2026-07-11 14:16:11 +08:00
|
|
|
|
worker = ImageStudioPullImagesWorker(
|
|
|
|
|
|
alias,
|
|
|
|
|
|
item_id,
|
|
|
|
|
|
db_path=self.db_path,
|
|
|
|
|
|
config=self.config,
|
|
|
|
|
|
)
|
|
|
|
|
|
worker.log.connect(self._append_log)
|
|
|
|
|
|
worker.finished.connect(self._on_pull_finished)
|
|
|
|
|
|
worker.failed.connect(self._on_worker_failed)
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self._start_worker(worker, "AI工场拉取蝦皮主图", operation_kind="pull_images")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self._append_log("[AI工场] 拉取蝦皮主图开始")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
2026-07-11 17:28:37 +08:00
|
|
|
|
def _local_original_count(self, project):
|
|
|
|
|
|
if project is None:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
originals = image_studio.list_assets(
|
|
|
|
|
|
project.id,
|
|
|
|
|
|
kind=image_studio.ASSET_KIND_ORIGINAL,
|
|
|
|
|
|
path=self.db_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
return sum(
|
|
|
|
|
|
1
|
|
|
|
|
|
for asset in originals
|
|
|
|
|
|
if str(asset.local_path or "").strip() and os.path.isfile(asset.local_path)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _has_original_download_activity(self, project_id):
|
|
|
|
|
|
"""Return whether a project still owns queued or live original downloads."""
|
|
|
|
|
|
|
|
|
|
|
|
if project_id is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
target_project_id = int(project_id)
|
|
|
|
|
|
if any(
|
|
|
|
|
|
int(request.get("project_id") or 0) == target_project_id
|
|
|
|
|
|
for request in self._original_download_queue
|
|
|
|
|
|
):
|
|
|
|
|
|
return True
|
|
|
|
|
|
return any(
|
|
|
|
|
|
int(record.get("project_id") or 0) == target_project_id
|
|
|
|
|
|
for record in self._original_downloads.values()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _original_download_count(self):
|
|
|
|
|
|
return len(self._original_downloads)
|
|
|
|
|
|
|
|
|
|
|
|
def _original_asset_order(self, asset_id):
|
|
|
|
|
|
for index, asset in enumerate(self.assets, start=1):
|
|
|
|
|
|
if int(asset.id) == int(asset_id):
|
|
|
|
|
|
return int(asset.source_order or index)
|
|
|
|
|
|
asset = image_studio.get_asset(asset_id, path=self.db_path)
|
|
|
|
|
|
if asset is not None:
|
|
|
|
|
|
return int(asset.source_order or 1)
|
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
def _set_original_download_state(self, asset_id, project_id, state, **fields):
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"project_id": int(project_id),
|
|
|
|
|
|
"state": str(state),
|
|
|
|
|
|
}
|
|
|
|
|
|
payload.update(fields)
|
|
|
|
|
|
self._original_download_states[int(asset_id)] = payload
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_original_download_state(self, asset_id, *, keep_failed=False):
|
|
|
|
|
|
state = self._original_download_states.get(int(asset_id))
|
|
|
|
|
|
if keep_failed and state and state.get("state") == "failed":
|
|
|
|
|
|
return
|
|
|
|
|
|
self._original_download_states.pop(int(asset_id), None)
|
|
|
|
|
|
|
|
|
|
|
|
def _original_card_state(self, asset):
|
|
|
|
|
|
asset_id = int(asset.id)
|
|
|
|
|
|
state = self._original_download_states.get(asset_id, {})
|
|
|
|
|
|
state_name = state.get("state")
|
|
|
|
|
|
if state_name == "queued":
|
|
|
|
|
|
return "等待下载"
|
|
|
|
|
|
if state_name == "downloading":
|
|
|
|
|
|
return "下载中"
|
|
|
|
|
|
if state_name == "retrying":
|
|
|
|
|
|
return f"重试 {state.get('retry', 1)}/{state.get('max_retries', 2)}"
|
|
|
|
|
|
if state_name == "failed":
|
|
|
|
|
|
return "下载失败"
|
|
|
|
|
|
return self._original_thumbnail_state(asset)
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_original_download_label(self):
|
|
|
|
|
|
project = self.current_project
|
|
|
|
|
|
if project is None:
|
|
|
|
|
|
self.original_download_label.setText("")
|
|
|
|
|
|
return
|
|
|
|
|
|
project_id = int(project.id)
|
|
|
|
|
|
states = [
|
|
|
|
|
|
(asset_id, state)
|
|
|
|
|
|
for asset_id, state in self._original_download_states.items()
|
|
|
|
|
|
if int(state.get("project_id") or 0) == project_id
|
|
|
|
|
|
]
|
|
|
|
|
|
retrying = [item for item in states if item[1].get("state") == "retrying"]
|
|
|
|
|
|
active = [
|
|
|
|
|
|
item
|
|
|
|
|
|
for item in states
|
|
|
|
|
|
if item[1].get("state") in {"downloading", "retrying"}
|
|
|
|
|
|
]
|
|
|
|
|
|
queued = [item for item in states if item[1].get("state") == "queued"]
|
|
|
|
|
|
failed = [item for item in states if item[1].get("state") == "failed"]
|
|
|
|
|
|
if retrying:
|
|
|
|
|
|
asset_id, state = retrying[0]
|
|
|
|
|
|
self.original_download_label.setText(
|
|
|
|
|
|
"蝦皮原主图 #%s 下载失败,正在重试 %s/%s"
|
|
|
|
|
|
% (
|
|
|
|
|
|
self._original_asset_order(asset_id),
|
|
|
|
|
|
state.get("retry", 1),
|
|
|
|
|
|
state.get("max_retries", 2),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
if active:
|
|
|
|
|
|
orders = "、".join(
|
|
|
|
|
|
"#%s" % self._original_asset_order(asset_id)
|
|
|
|
|
|
for asset_id, _state in active
|
|
|
|
|
|
)
|
|
|
|
|
|
text = (
|
|
|
|
|
|
f"正在下载蝦皮原主图 {orders}({len(active)}/{ORIGINAL_DOWNLOAD_CONCURRENCY})"
|
|
|
|
|
|
)
|
|
|
|
|
|
if failed:
|
|
|
|
|
|
failed_asset_id, _state = failed[0]
|
|
|
|
|
|
text += (
|
|
|
|
|
|
f";主图 #{self._original_asset_order(failed_asset_id)} 下载失败,可再次点击图片重试"
|
|
|
|
|
|
)
|
|
|
|
|
|
self.original_download_label.setText(text)
|
|
|
|
|
|
return
|
|
|
|
|
|
if queued:
|
|
|
|
|
|
asset_id, _state = queued[0]
|
|
|
|
|
|
self.original_download_label.setText(
|
|
|
|
|
|
f"蝦皮原主图 #{self._original_asset_order(asset_id)} 等待下载"
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
if failed:
|
|
|
|
|
|
self.original_download_label.setText("蝦皮原主图下载失败,可再次点击图片重试")
|
|
|
|
|
|
return
|
|
|
|
|
|
self.original_download_label.setText("")
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def _on_pull_finished(self, summary):
|
2026-07-11 15:41:38 +08:00
|
|
|
|
if self._handle_finished_error(summary, "拉取蝦皮主图失败"):
|
2026-07-11 14:16:11 +08:00
|
|
|
|
return
|
|
|
|
|
|
project = summary.get("project")
|
|
|
|
|
|
if project is not None:
|
|
|
|
|
|
self.current_project = project
|
|
|
|
|
|
self._set_account_combo(project.account_alias)
|
|
|
|
|
|
self.item_id_edit.setText(project.item_id)
|
|
|
|
|
|
self._finish_worker()
|
|
|
|
|
|
self.refresh_projects()
|
|
|
|
|
|
if project is not None:
|
|
|
|
|
|
self._select_project(project.id)
|
|
|
|
|
|
self._status(f"已拉取 {summary.get('count', 0)} 张蝦皮原主图", "success")
|
|
|
|
|
|
|
|
|
|
|
|
def open_project_folder(self, checked=False):
|
|
|
|
|
|
if self.current_project is None:
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
return
|
|
|
|
|
|
dirs = image_studio.default_project_image_dirs(self.current_project, self.config)
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.makedirs(dirs["root"], exist_ok=True)
|
|
|
|
|
|
opened = file_manager.open_in_file_manager(dirs["root"])
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("打开项目文件夹失败", str(exc))
|
|
|
|
|
|
self._status(f"打开项目文件夹失败:{exc}", "warning")
|
|
|
|
|
|
return
|
|
|
|
|
|
self._status(f"已打开项目文件夹:{opened}", "success")
|
|
|
|
|
|
|
2026-07-11 16:23:22 +08:00
|
|
|
|
def delete_current_project(self, checked=False):
|
2026-07-13 09:38:30 +08:00
|
|
|
|
if self._operation_running:
|
|
|
|
|
|
self._status("AI工场当前任务仍在进行,暂不能删除项目", "warning")
|
|
|
|
|
|
return
|
2026-07-11 16:23:22 +08:00
|
|
|
|
project = self.current_project
|
|
|
|
|
|
if project is None:
|
|
|
|
|
|
self._message("未选择商品", "请先从商品列表选择要删除的项目。")
|
|
|
|
|
|
return
|
2026-07-11 17:28:37 +08:00
|
|
|
|
if self._has_original_download_activity(project.id):
|
|
|
|
|
|
self._message(
|
|
|
|
|
|
"暂不能删除项目",
|
|
|
|
|
|
"该商品还有蝦皮原主图正在下载或等待下载,请等待完成后再删除。",
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
2026-07-11 16:23:22 +08:00
|
|
|
|
active_jobs = [
|
|
|
|
|
|
job
|
|
|
|
|
|
for job in self._list_project_jobs(project.id)
|
|
|
|
|
|
if job.status in {"pending", "submitted", "running"}
|
|
|
|
|
|
]
|
|
|
|
|
|
if active_jobs:
|
|
|
|
|
|
self._message(
|
|
|
|
|
|
"暂不能删除项目",
|
|
|
|
|
|
"该商品还有未完成的图片任务。请先等待任务完成、停止本轮或继续查询任务后再删除。",
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
display_name = _project_display_name(project)
|
|
|
|
|
|
if not self._confirm(
|
|
|
|
|
|
"删除项目",
|
|
|
|
|
|
f"确定从AI工场商品列表删除“{display_name}”吗?\n"
|
|
|
|
|
|
"不会删除蝦皮商品,也不会删除本地图片。再次拉取同一商品会恢复原项目记录。",
|
|
|
|
|
|
):
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
image_studio.soft_delete_project(
|
|
|
|
|
|
project.id,
|
|
|
|
|
|
reason="用户从AI工场商品列表删除",
|
|
|
|
|
|
path=self.db_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
message = diagnostics.redact_log_text(str(exc or "未知错误"))
|
|
|
|
|
|
self._message("删除项目失败", f"无法从AI工场商品列表删除:{message}")
|
|
|
|
|
|
self._status(f"删除AI工场项目失败:{message}", "danger")
|
|
|
|
|
|
return
|
|
|
|
|
|
self._clear_current_project()
|
|
|
|
|
|
self.refresh_projects()
|
|
|
|
|
|
self._status(f"已从AI工场商品列表删除:{display_name}", "success")
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def _on_project_selection_changed(self):
|
|
|
|
|
|
items = self.project_table.selectedItems()
|
|
|
|
|
|
if not items:
|
|
|
|
|
|
return
|
|
|
|
|
|
project_id = items[0].data(Qt.UserRole)
|
|
|
|
|
|
if project_id is not None:
|
|
|
|
|
|
self._select_project(project_id)
|
|
|
|
|
|
|
|
|
|
|
|
def _select_project(self, project_id, quiet=False):
|
|
|
|
|
|
try:
|
|
|
|
|
|
project = image_studio.get_project(project_id, path=self.db_path)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._status(f"读取AI工场项目失败:{exc}", "danger")
|
|
|
|
|
|
return
|
|
|
|
|
|
if project is None:
|
|
|
|
|
|
return
|
2026-07-11 16:47:18 +08:00
|
|
|
|
previous_project_id = getattr(self.current_project, "id", None)
|
|
|
|
|
|
if previous_project_id != project.id:
|
|
|
|
|
|
self._reset_thumbnail_view(project.id)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.current_project = project
|
|
|
|
|
|
self._set_account_combo(project.account_alias)
|
|
|
|
|
|
self.item_id_edit.setText(project.item_id)
|
|
|
|
|
|
self.prompt_edit.blockSignals(True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.prompt_edit.setPlainText(project.draft_prompt or "")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.prompt_edit.blockSignals(False)
|
|
|
|
|
|
self.selected_source_asset_id = None
|
|
|
|
|
|
self.refresh_project_assets()
|
|
|
|
|
|
self._sync_project_selection(project.id)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self._refresh_project_summary()
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if not quiet:
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._status(f"当前AI工场商品:{_project_display_name(project)}", "muted")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def _sync_project_selection(self, project_id):
|
|
|
|
|
|
for row in range(self.project_table.rowCount()):
|
|
|
|
|
|
item = self.project_table.item(row, 0)
|
|
|
|
|
|
if item is not None and item.data(Qt.UserRole) == int(project_id):
|
|
|
|
|
|
if not self.project_table.item(row, 0).isSelected():
|
|
|
|
|
|
self.project_table.selectRow(row)
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
def refresh_project_assets(self):
|
|
|
|
|
|
if self.current_project is None:
|
|
|
|
|
|
self.assets = []
|
|
|
|
|
|
self.jobs = []
|
|
|
|
|
|
self.selections = []
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.assets = image_studio.list_assets(self.current_project.id, path=self.db_path)
|
|
|
|
|
|
self.jobs = self._list_project_jobs(self.current_project.id)
|
|
|
|
|
|
self.selections = image_studio.list_selections(self.current_project.id, path=self.db_path)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self._refresh_project_summary()
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self._fill_original_grid()
|
|
|
|
|
|
self._fill_pool_grid()
|
2026-07-13 09:54:08 +08:00
|
|
|
|
self._fill_job_grid()
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self._refresh_selection_labels()
|
|
|
|
|
|
self._refresh_source_label()
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _fill_original_grid(self):
|
|
|
|
|
|
originals = sorted(
|
|
|
|
|
|
[asset for asset in self.assets if asset.kind == image_studio.ASSET_KIND_ORIGINAL],
|
|
|
|
|
|
key=lambda asset: (int(asset.source_order or 0), int(asset.id)),
|
|
|
|
|
|
)
|
|
|
|
|
|
self.original_grid.clear()
|
2026-07-11 14:16:11 +08:00
|
|
|
|
for row, asset in enumerate(originals):
|
2026-07-11 15:41:38 +08:00
|
|
|
|
order = asset.source_order or row + 1
|
2026-07-11 17:28:37 +08:00
|
|
|
|
item = QListWidgetItem(f"主图 {order}\n{self._original_card_state(asset)}")
|
2026-07-11 16:47:18 +08:00
|
|
|
|
item.setData(Qt.UserRole, {"type": "asset", "asset_id": int(asset.id)})
|
|
|
|
|
|
item.setIcon(
|
|
|
|
|
|
_asset_icon(
|
|
|
|
|
|
asset,
|
|
|
|
|
|
"原",
|
2026-07-11 17:28:37 +08:00
|
|
|
|
size=QSize(48, 48),
|
2026-07-11 16:47:18 +08:00
|
|
|
|
cached_pixmap=self._thumbnail_pixmaps.get(int(asset.id)),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-11 17:28:37 +08:00
|
|
|
|
item.setSizeHint(QSize(58, 78))
|
2026-07-11 16:47:18 +08:00
|
|
|
|
tooltip = "单击下载并加入照片池,双击查看大图。缩略图仅用于预览,不保存原图。"
|
2026-07-11 17:28:37 +08:00
|
|
|
|
if asset.status == image_studio.ASSET_STATUS_MISSING and str(asset.local_path or "").strip():
|
|
|
|
|
|
tooltip += "\n该图片不在当前蝦皮主图列表中,本地历史文件仍已保留。"
|
2026-07-11 16:47:18 +08:00
|
|
|
|
if int(asset.id) in self._thumbnail_errors:
|
|
|
|
|
|
tooltip += "\n缩略图加载失败,可右键重新加载,或直接单击下载原图。"
|
|
|
|
|
|
item.setToolTip(tooltip)
|
|
|
|
|
|
self.original_grid.addItem(item)
|
|
|
|
|
|
self._queue_original_thumbnails(originals)
|
|
|
|
|
|
for row, asset in enumerate(originals):
|
|
|
|
|
|
item = self.original_grid.item(row)
|
|
|
|
|
|
if item is not None:
|
|
|
|
|
|
order = asset.source_order or row + 1
|
2026-07-11 17:28:37 +08:00
|
|
|
|
item.setText(f"主图 {order}\n{self._original_card_state(asset)}")
|
|
|
|
|
|
self._refresh_original_download_label()
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _fill_pool_grid(self):
|
2026-07-13 09:54:08 +08:00
|
|
|
|
assets = [
|
|
|
|
|
|
asset
|
|
|
|
|
|
for asset in self.assets
|
|
|
|
|
|
if asset.kind in {"original", "generated_main", "generated_detail"} and _asset_is_usable(asset)
|
|
|
|
|
|
]
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self.pool_grid.clear()
|
2026-07-13 09:54:08 +08:00
|
|
|
|
for asset in assets:
|
|
|
|
|
|
draggable = _asset_is_usable(asset)
|
|
|
|
|
|
data = {"type": "asset", "asset_id": int(asset.id), "draggable": draggable}
|
|
|
|
|
|
text = f"{_asset_badge(asset.kind)} #{asset.id}\n{asset.aspect_ratio or '比例未知'}"
|
|
|
|
|
|
tooltip = "单击设为源图,双击查看大图,可拖入终选槽。"
|
2026-07-11 16:47:18 +08:00
|
|
|
|
item = QListWidgetItem(text)
|
|
|
|
|
|
item.setData(Qt.UserRole, data)
|
|
|
|
|
|
item.setSizeHint(QSize(108, 124))
|
|
|
|
|
|
item.setToolTip(tooltip)
|
2026-07-13 09:54:08 +08:00
|
|
|
|
item.setIcon(_asset_icon(asset, _asset_badge(asset.kind), size=QSize(86, 86)))
|
|
|
|
|
|
if asset.id == self.selected_source_asset_id:
|
|
|
|
|
|
item.setBackground(QColor("#eaf2ff"))
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self.pool_grid.addItem(item)
|
2026-07-13 09:54:08 +08:00
|
|
|
|
self.pool_summary_label.setText(f"可用图片 {len(assets)} 张")
|
|
|
|
|
|
|
|
|
|
|
|
def _fill_job_grid(self):
|
|
|
|
|
|
usable_asset_ids = {
|
|
|
|
|
|
int(asset.id)
|
|
|
|
|
|
for asset in self.assets
|
|
|
|
|
|
if _asset_is_usable(asset)
|
|
|
|
|
|
}
|
|
|
|
|
|
jobs = [
|
|
|
|
|
|
job
|
|
|
|
|
|
for job in self.jobs
|
|
|
|
|
|
if job.status in {"pending", "submitted", "running", "failed", "expired", "cancelled"}
|
|
|
|
|
|
and int(job.output_asset_id or 0) not in usable_asset_ids
|
|
|
|
|
|
]
|
|
|
|
|
|
self.job_grid.clear()
|
|
|
|
|
|
self.job_summary_label.setText(f"未完成/异常任务 {len(jobs)} 个")
|
|
|
|
|
|
self.job_section.setVisible(bool(jobs))
|
|
|
|
|
|
for job in jobs:
|
|
|
|
|
|
status_text = self.JOB_STATUS_LABELS.get(job.status, "任务状态未知")
|
|
|
|
|
|
reason = _job_error_summary(job)
|
|
|
|
|
|
recovery = _job_recovery_summary(job)
|
|
|
|
|
|
billing = _job_billing_text(job)
|
|
|
|
|
|
headline = status_text if not reason else f"{status_text}:{reason}"
|
|
|
|
|
|
lines = [headline, recovery]
|
|
|
|
|
|
if billing:
|
|
|
|
|
|
lines.append(billing)
|
|
|
|
|
|
item = QListWidgetItem("\n".join(lines))
|
|
|
|
|
|
item.setData(Qt.UserRole, {"type": "job", "job_id": int(job.id), "draggable": False})
|
|
|
|
|
|
item.setSizeHint(QSize(162, 94))
|
|
|
|
|
|
tooltip_lines = [f"状态:{status_text}", recovery]
|
|
|
|
|
|
if reason:
|
|
|
|
|
|
tooltip_lines.append(f"原因:{reason}")
|
|
|
|
|
|
if billing:
|
|
|
|
|
|
tooltip_lines.append(billing)
|
|
|
|
|
|
item.setToolTip("\n".join(tooltip_lines))
|
|
|
|
|
|
item.setIcon(_job_icon(job.status, size=QSize(42, 42)))
|
|
|
|
|
|
if job.status in {"failed", "expired"}:
|
|
|
|
|
|
item.setForeground(_qcolor(COLOR_DANGER))
|
|
|
|
|
|
item.setBackground(QColor("#ffebe9"))
|
|
|
|
|
|
elif job.status in {"pending", "submitted", "running"}:
|
|
|
|
|
|
item.setForeground(_qcolor(COLOR_WARNING))
|
|
|
|
|
|
item.setBackground(QColor("#fff8c5"))
|
|
|
|
|
|
else:
|
|
|
|
|
|
item.setForeground(_qcolor(COLOR_MUTED))
|
|
|
|
|
|
item.setBackground(QColor("#f6f8fa"))
|
|
|
|
|
|
self.job_grid.addItem(item)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
2026-07-11 14:20:56 +08:00
|
|
|
|
def _selection_asset_ids(self, selection_type):
|
|
|
|
|
|
return [
|
|
|
|
|
|
int(selection.asset_id)
|
|
|
|
|
|
for selection in self.selections
|
|
|
|
|
|
if selection.selection_type == selection_type
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def add_asset_to_selection(self, selection_type, asset_id, insert_index=None):
|
|
|
|
|
|
if self.current_project is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
asset = self._asset_by_id(asset_id)
|
|
|
|
|
|
if asset is None:
|
|
|
|
|
|
self._message("不能加入终选", "照片不存在。")
|
|
|
|
|
|
return False
|
|
|
|
|
|
if not _asset_is_usable(asset):
|
|
|
|
|
|
self._message("不能加入终选", "照片尚未下载或本地文件缺失。")
|
|
|
|
|
|
return False
|
|
|
|
|
|
ids = self._selection_asset_ids(selection_type)
|
|
|
|
|
|
if int(asset.id) in ids:
|
|
|
|
|
|
self._message("不能重复加入", "同一张照片在同一类终选中只能出现一次。")
|
|
|
|
|
|
return False
|
|
|
|
|
|
target = self._selection_target_count(selection_type)
|
|
|
|
|
|
if len(ids) >= target:
|
|
|
|
|
|
self._message("终选已满", f"{_selection_label(selection_type)}最多 {target} 张。")
|
|
|
|
|
|
return False
|
|
|
|
|
|
index = _clamp_insert_index(insert_index, len(ids))
|
|
|
|
|
|
ids.insert(index, int(asset.id))
|
|
|
|
|
|
return self._persist_selection(selection_type, ids, success_message="已加入终选")
|
|
|
|
|
|
|
|
|
|
|
|
def move_selection_asset(self, selection_type, from_index, to_index):
|
|
|
|
|
|
ids = self._selection_asset_ids(selection_type)
|
|
|
|
|
|
if not ids:
|
|
|
|
|
|
return False
|
|
|
|
|
|
from_index = int(from_index)
|
|
|
|
|
|
if from_index < 0 or from_index >= len(ids):
|
|
|
|
|
|
return False
|
|
|
|
|
|
asset_id = ids.pop(from_index)
|
|
|
|
|
|
target_index = _clamp_insert_index(to_index, len(ids))
|
|
|
|
|
|
ids.insert(target_index, asset_id)
|
|
|
|
|
|
return self._persist_selection(selection_type, ids, success_message="终选顺序已更新")
|
|
|
|
|
|
|
|
|
|
|
|
def remove_asset_from_selection(self, selection_type, asset_id):
|
|
|
|
|
|
ids = self._selection_asset_ids(selection_type)
|
|
|
|
|
|
filtered = [value for value in ids if int(value) != int(asset_id)]
|
|
|
|
|
|
if len(filtered) == len(ids):
|
|
|
|
|
|
return False
|
|
|
|
|
|
return self._persist_selection(selection_type, filtered, success_message="已移出终选")
|
|
|
|
|
|
|
|
|
|
|
|
def _persist_selection(self, selection_type, asset_ids, success_message):
|
|
|
|
|
|
try:
|
|
|
|
|
|
image_studio.replace_selections(
|
|
|
|
|
|
self.current_project.id,
|
|
|
|
|
|
selection_type,
|
|
|
|
|
|
asset_ids,
|
|
|
|
|
|
path=self.db_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("终选保存失败", str(exc))
|
|
|
|
|
|
self.refresh_project_assets()
|
|
|
|
|
|
return False
|
|
|
|
|
|
self.refresh_project_assets()
|
|
|
|
|
|
self._status(success_message, "success")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def _selection_target_count(self, selection_type):
|
|
|
|
|
|
if self.current_project is None:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
if selection_type == "detail":
|
|
|
|
|
|
return int(self.current_project.target_detail_count or 12)
|
|
|
|
|
|
return int(self.current_project.target_main_count or 9)
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _on_original_clicked(self, item):
|
|
|
|
|
|
asset = self._asset_from_grid_item(item)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if asset is not None:
|
|
|
|
|
|
self._ensure_original_in_pool(asset, open_after=False)
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _on_original_double_clicked(self, item):
|
|
|
|
|
|
asset = self._asset_from_grid_item(item)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if asset is not None:
|
|
|
|
|
|
self._ensure_original_in_pool(asset, open_after=True)
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _on_pool_clicked(self, item):
|
|
|
|
|
|
data = self._grid_data(item)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if not data or data.get("type") != "asset":
|
|
|
|
|
|
return
|
|
|
|
|
|
asset = self._asset_by_id(data.get("asset_id"))
|
|
|
|
|
|
if asset is not None:
|
|
|
|
|
|
self._select_source_asset(asset)
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _on_pool_double_clicked(self, item):
|
|
|
|
|
|
data = self._grid_data(item)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if not data or data.get("type") != "asset":
|
|
|
|
|
|
return
|
|
|
|
|
|
asset = self._asset_by_id(data.get("asset_id"))
|
|
|
|
|
|
if asset is not None:
|
|
|
|
|
|
self._open_preview(asset)
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _show_original_context_menu(self, position):
|
|
|
|
|
|
item = self.original_grid.itemAt(position)
|
|
|
|
|
|
asset = self._asset_from_grid_item(item)
|
|
|
|
|
|
if asset is None or int(asset.id) not in self._thumbnail_errors:
|
|
|
|
|
|
return
|
|
|
|
|
|
menu = QMenu(self)
|
|
|
|
|
|
retry_action = menu.addAction("重新加载缩略图")
|
|
|
|
|
|
action = menu.exec(self.original_grid.viewport().mapToGlobal(position))
|
|
|
|
|
|
if action is retry_action:
|
|
|
|
|
|
self._retry_original_thumbnail(asset)
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def _ensure_original_in_pool(self, asset, open_after=False):
|
|
|
|
|
|
if str(asset.local_path or "").strip() and os.path.isfile(asset.local_path):
|
|
|
|
|
|
self._select_source_asset(asset)
|
|
|
|
|
|
if open_after:
|
|
|
|
|
|
self._open_preview(asset)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-07-11 17:28:37 +08:00
|
|
|
|
self._enqueue_original_download(asset, open_after=open_after)
|
|
|
|
|
|
|
|
|
|
|
|
def _enqueue_original_download(self, asset, *, open_after=False):
|
|
|
|
|
|
"""Queue one full original download without locking the workspace."""
|
|
|
|
|
|
|
|
|
|
|
|
asset_id = int(asset.id)
|
|
|
|
|
|
project_id = int(asset.project_id)
|
|
|
|
|
|
active = self._original_downloads.get(asset_id)
|
|
|
|
|
|
if active is not None:
|
|
|
|
|
|
if open_after:
|
|
|
|
|
|
active["open_after"] = True
|
|
|
|
|
|
worker = active.get("worker")
|
|
|
|
|
|
if worker is not None:
|
|
|
|
|
|
worker.open_after = True
|
|
|
|
|
|
self._status(
|
|
|
|
|
|
f"蝦皮原主图 #{self._original_asset_order(asset_id)} 正在下载,请稍候",
|
|
|
|
|
|
"info",
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
for request in self._original_download_queue:
|
|
|
|
|
|
if int(request.get("asset_id") or 0) != asset_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
request["open_after"] = bool(request.get("open_after") or open_after)
|
|
|
|
|
|
self._status(
|
|
|
|
|
|
f"蝦皮原主图 #{self._original_asset_order(asset_id)} 已在下载队列中",
|
|
|
|
|
|
"info",
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
self._set_original_download_state(asset_id, project_id, "queued")
|
|
|
|
|
|
self._original_download_queue.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"asset_id": asset_id,
|
|
|
|
|
|
"project_id": project_id,
|
|
|
|
|
|
"open_after": bool(open_after),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
self._refresh_original_download_ui(project_id)
|
|
|
|
|
|
self._start_queued_original_downloads()
|
|
|
|
|
|
|
|
|
|
|
|
def _start_queued_original_downloads(self):
|
|
|
|
|
|
while (
|
|
|
|
|
|
self._original_download_queue
|
|
|
|
|
|
and self._original_download_count() < ORIGINAL_DOWNLOAD_CONCURRENCY
|
|
|
|
|
|
):
|
|
|
|
|
|
request = self._original_download_queue.pop(0)
|
|
|
|
|
|
asset_id = int(request["asset_id"])
|
|
|
|
|
|
project_id = int(request["project_id"])
|
|
|
|
|
|
try:
|
|
|
|
|
|
asset = image_studio.get_asset(asset_id, path=self.db_path)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
asset = None
|
|
|
|
|
|
if asset is None or int(asset.project_id) != project_id:
|
|
|
|
|
|
self._clear_original_download_state(asset_id)
|
|
|
|
|
|
continue
|
|
|
|
|
|
worker = ImageStudioDownloadOriginalWorker(
|
|
|
|
|
|
asset_id,
|
|
|
|
|
|
db_path=self.db_path,
|
|
|
|
|
|
config=self.config,
|
|
|
|
|
|
open_after=bool(request.get("open_after")),
|
|
|
|
|
|
max_retries=2,
|
|
|
|
|
|
)
|
|
|
|
|
|
worker.log.connect(self._append_log)
|
|
|
|
|
|
worker.progress.connect(
|
|
|
|
|
|
lambda payload, aid=asset_id, pid=project_id: self._on_original_download_progress(
|
|
|
|
|
|
aid, pid, payload
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
worker.finished.connect(
|
|
|
|
|
|
lambda summary, aid=asset_id, pid=project_id: self._on_original_download_finished(
|
|
|
|
|
|
aid, pid, summary
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
worker.cancelled.connect(
|
|
|
|
|
|
lambda summary, aid=asset_id, pid=project_id: self._on_original_download_cancelled(
|
|
|
|
|
|
aid, pid, summary
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
thread = run_worker(worker, thread_name="AI工场下载蝦皮原主图", start=False)
|
|
|
|
|
|
thread.finished.connect(
|
|
|
|
|
|
lambda aid=asset_id, active_thread=thread: self._forget_original_download(
|
|
|
|
|
|
aid, active_thread
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
_ORIGINAL_DOWNLOAD_THREAD_REFS[id(thread)] = (thread, worker)
|
|
|
|
|
|
self._original_downloads[asset_id] = {
|
|
|
|
|
|
"worker": worker,
|
|
|
|
|
|
"thread": thread,
|
|
|
|
|
|
"project_id": project_id,
|
|
|
|
|
|
"open_after": bool(request.get("open_after")),
|
|
|
|
|
|
}
|
|
|
|
|
|
self._set_original_download_state(asset_id, project_id, "downloading")
|
|
|
|
|
|
self._refresh_original_download_ui(project_id)
|
|
|
|
|
|
thread.start()
|
|
|
|
|
|
|
|
|
|
|
|
def _on_original_download_progress(self, asset_id, project_id, payload):
|
|
|
|
|
|
if self._original_downloads_closing:
|
|
|
|
|
|
return
|
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
|
return
|
|
|
|
|
|
state = str(payload.get("state") or "")
|
|
|
|
|
|
if state == "retry":
|
|
|
|
|
|
self._set_original_download_state(
|
|
|
|
|
|
asset_id,
|
|
|
|
|
|
project_id,
|
|
|
|
|
|
"retrying",
|
|
|
|
|
|
retry=int(payload.get("retry") or 1),
|
|
|
|
|
|
max_retries=int(payload.get("max_retries") or 2),
|
|
|
|
|
|
)
|
|
|
|
|
|
elif state in {"start", "success"}:
|
|
|
|
|
|
self._set_original_download_state(asset_id, project_id, "downloading")
|
|
|
|
|
|
elif state == "failed":
|
|
|
|
|
|
self._set_original_download_state(asset_id, project_id, "failed")
|
|
|
|
|
|
self._refresh_original_download_ui(project_id)
|
|
|
|
|
|
|
|
|
|
|
|
def _on_original_download_finished(self, asset_id, project_id, summary):
|
|
|
|
|
|
if self._original_downloads_closing:
|
|
|
|
|
|
self._clear_original_download_state(asset_id)
|
|
|
|
|
|
return
|
|
|
|
|
|
summary = dict(summary or {})
|
|
|
|
|
|
if summary.get("ok") is False:
|
|
|
|
|
|
self._set_original_download_state(asset_id, project_id, "failed")
|
|
|
|
|
|
self._refresh_original_download_ui(project_id)
|
|
|
|
|
|
if self.current_project is not None and int(self.current_project.id) == project_id:
|
|
|
|
|
|
self._status(
|
|
|
|
|
|
f"蝦皮原主图 #{self._original_asset_order(asset_id)} 下载失败,可再次点击图片重试",
|
|
|
|
|
|
"warning",
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
self._clear_original_download_state(asset_id)
|
|
|
|
|
|
if self.current_project is None or int(self.current_project.id) != project_id:
|
2026-07-11 14:16:11 +08:00
|
|
|
|
return
|
|
|
|
|
|
self.refresh_project_assets()
|
2026-07-11 17:28:37 +08:00
|
|
|
|
asset = summary.get("asset")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if asset is not None:
|
|
|
|
|
|
refreshed = self._asset_by_id(asset.id) or asset
|
|
|
|
|
|
self._select_source_asset(refreshed)
|
|
|
|
|
|
if summary.get("open_after"):
|
|
|
|
|
|
self._open_preview(refreshed)
|
|
|
|
|
|
self._status("原图已加入照片池", "success")
|
|
|
|
|
|
|
2026-07-11 17:28:37 +08:00
|
|
|
|
def _on_original_download_cancelled(self, asset_id, project_id, summary):
|
|
|
|
|
|
self._clear_original_download_state(asset_id)
|
|
|
|
|
|
if self._original_downloads_closing:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._refresh_original_download_ui(project_id)
|
|
|
|
|
|
|
|
|
|
|
|
def _forget_original_download(self, asset_id, thread):
|
|
|
|
|
|
_ORIGINAL_DOWNLOAD_THREAD_REFS.pop(id(thread), None)
|
|
|
|
|
|
record = self._original_downloads.get(int(asset_id))
|
|
|
|
|
|
if record is None or record.get("thread") is not thread:
|
|
|
|
|
|
return
|
|
|
|
|
|
project_id = int(record.get("project_id") or 0)
|
|
|
|
|
|
self._original_downloads.pop(int(asset_id), None)
|
|
|
|
|
|
if self._original_downloads_closing:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._refresh_original_download_ui(project_id)
|
|
|
|
|
|
self._start_queued_original_downloads()
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_original_download_ui(self, project_id):
|
|
|
|
|
|
if self.current_project is None or int(self.current_project.id) != int(project_id):
|
|
|
|
|
|
return
|
|
|
|
|
|
self._fill_original_grid()
|
|
|
|
|
|
self._update_project_action_buttons()
|
|
|
|
|
|
|
|
|
|
|
|
def _cancel_original_downloads(self):
|
|
|
|
|
|
for request in self._original_download_queue:
|
|
|
|
|
|
self._clear_original_download_state(request.get("asset_id"))
|
|
|
|
|
|
self._original_download_queue = []
|
|
|
|
|
|
for record in list(self._original_downloads.values()):
|
|
|
|
|
|
worker = record.get("worker")
|
|
|
|
|
|
if worker is not None:
|
|
|
|
|
|
worker.cancel()
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def _select_source_asset(self, asset):
|
|
|
|
|
|
self.selected_source_asset_id = int(asset.id)
|
|
|
|
|
|
self._refresh_source_label()
|
|
|
|
|
|
self._status(f"已选择源图:{_asset_badge(asset.kind)} #{asset.id}", "success")
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_source_label(self):
|
|
|
|
|
|
asset = self._asset_by_id(self.selected_source_asset_id)
|
|
|
|
|
|
if asset is None:
|
|
|
|
|
|
self.source_label.setText("源图:未选择")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.source_preview_label.setPixmap(QPixmap())
|
|
|
|
|
|
self.source_preview_label.setText("未选择源图")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
return
|
|
|
|
|
|
self.source_label.setText(f"源图:{_asset_badge(asset.kind)} #{asset.id}")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.source_preview_label.setText("")
|
|
|
|
|
|
self.source_preview_label.setPixmap(_asset_pixmap(asset, QSize(86, 86)))
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_project_summary(self):
|
|
|
|
|
|
if self.current_project is None:
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self.current_project_label.setText("未选择商品")
|
|
|
|
|
|
self.current_project_label.setToolTip("")
|
|
|
|
|
|
self.autosave_label.setText("选择店铺并输入商品ID后拉取")
|
|
|
|
|
|
self.workspace_empty_label.setText("选择店铺并输入商品ID后,点击「拉取蝦皮主图」开始。")
|
|
|
|
|
|
self._update_project_action_buttons()
|
2026-07-11 15:41:38 +08:00
|
|
|
|
return
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self.current_project_label.setText(_project_display_name(self.current_project))
|
|
|
|
|
|
self.current_project_label.setToolTip(f"账号别名:{self.current_project.account_alias}")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
self.autosave_label.setText("项目已自动保存")
|
|
|
|
|
|
self.workspace_empty_label.setText(
|
2026-07-11 16:23:22 +08:00
|
|
|
|
f"当前商品:{_project_display_name(self.current_project)}。按顺序拉取蝦皮主图、选择源图、生成并拖入终选。"
|
2026-07-11 15:41:38 +08:00
|
|
|
|
)
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._update_project_action_buttons()
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_current_project(self):
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self._reset_thumbnail_view(None)
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self.current_project = None
|
|
|
|
|
|
self.assets = []
|
|
|
|
|
|
self.jobs = []
|
|
|
|
|
|
self.selections = []
|
|
|
|
|
|
self.selected_source_asset_id = None
|
|
|
|
|
|
self.item_id_edit.clear()
|
|
|
|
|
|
self.prompt_edit.blockSignals(True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.prompt_edit.clear()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.prompt_edit.blockSignals(False)
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self._fill_original_grid()
|
|
|
|
|
|
self._fill_pool_grid()
|
2026-07-13 09:54:08 +08:00
|
|
|
|
self._fill_job_grid()
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._refresh_selection_labels()
|
|
|
|
|
|
self._refresh_source_label()
|
|
|
|
|
|
self._refresh_project_summary()
|
|
|
|
|
|
|
|
|
|
|
|
def _update_project_action_buttons(self):
|
2026-07-13 09:38:30 +08:00
|
|
|
|
has_project = self.current_project is not None
|
|
|
|
|
|
self.open_folder_button.setEnabled(
|
|
|
|
|
|
has_project and (not self._operation_running or self._is_generation_operation())
|
|
|
|
|
|
)
|
2026-07-11 17:28:37 +08:00
|
|
|
|
self.delete_project_button.setEnabled(
|
2026-07-13 09:38:30 +08:00
|
|
|
|
has_project
|
|
|
|
|
|
and not self._operation_running
|
2026-07-11 17:28:37 +08:00
|
|
|
|
and not self._has_original_download_activity(
|
|
|
|
|
|
getattr(self.current_project, "id", None)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _thumbnail_key(self, project_id, asset_id):
|
|
|
|
|
|
return f"{int(project_id)}:{int(asset_id)}"
|
|
|
|
|
|
|
|
|
|
|
|
def _original_thumbnail_state(self, asset):
|
|
|
|
|
|
asset_id = int(asset.id)
|
|
|
|
|
|
if _asset_is_usable(asset):
|
|
|
|
|
|
return "已下载"
|
|
|
|
|
|
if asset_id in self._thumbnail_pixmaps:
|
|
|
|
|
|
return "可预览"
|
|
|
|
|
|
if asset_id in self._thumbnail_errors:
|
|
|
|
|
|
return "加载失败"
|
|
|
|
|
|
project_id = getattr(self.current_project, "id", None)
|
|
|
|
|
|
key = self._thumbnail_key(project_id, asset_id) if project_id is not None else ""
|
|
|
|
|
|
if key in self._thumbnail_pending:
|
|
|
|
|
|
return "加载中"
|
|
|
|
|
|
return "待加载"
|
|
|
|
|
|
|
|
|
|
|
|
def _queue_original_thumbnails(self, originals):
|
|
|
|
|
|
project = self.current_project
|
|
|
|
|
|
if project is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
project_id = int(project.id)
|
|
|
|
|
|
for asset in originals:
|
|
|
|
|
|
asset_id = int(asset.id)
|
|
|
|
|
|
remote_url = str(asset.remote_url or "").strip()
|
|
|
|
|
|
key = self._thumbnail_key(project_id, asset_id)
|
|
|
|
|
|
if (
|
|
|
|
|
|
not remote_url
|
|
|
|
|
|
or _asset_is_usable(asset)
|
|
|
|
|
|
or asset_id in self._thumbnail_pixmaps
|
|
|
|
|
|
or asset_id in self._thumbnail_errors
|
|
|
|
|
|
or key in self._thumbnail_pending
|
|
|
|
|
|
):
|
|
|
|
|
|
continue
|
|
|
|
|
|
self._thumbnail_pending.add(key)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._thumbnail_loader.submit(
|
|
|
|
|
|
key,
|
|
|
|
|
|
remote_url,
|
|
|
|
|
|
lambda result, pid=project_id, aid=asset_id, thumbnail_key=key: self._thumbnail_signals.loaded.emit(
|
|
|
|
|
|
{
|
|
|
|
|
|
"project_id": pid,
|
|
|
|
|
|
"asset_id": aid,
|
|
|
|
|
|
"key": thumbnail_key,
|
|
|
|
|
|
"result": result,
|
|
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
lambda failed_key, exc, pid=project_id, aid=asset_id, thumbnail_key=key: self._thumbnail_signals.failed.emit(
|
|
|
|
|
|
{
|
|
|
|
|
|
"project_id": pid,
|
|
|
|
|
|
"asset_id": aid,
|
|
|
|
|
|
"key": thumbnail_key,
|
|
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
self._thumbnail_signals.failed.emit(
|
|
|
|
|
|
{"project_id": project_id, "asset_id": asset_id, "key": key}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _on_thumbnail_loaded(self, payload):
|
|
|
|
|
|
project_id = int(payload.get("project_id") or 0)
|
|
|
|
|
|
asset_id = int(payload.get("asset_id") or 0)
|
|
|
|
|
|
key = str(payload.get("key") or "")
|
|
|
|
|
|
self._thumbnail_pending.discard(key)
|
|
|
|
|
|
if self.current_project is None or int(self.current_project.id) != project_id:
|
|
|
|
|
|
return
|
|
|
|
|
|
result = payload.get("result")
|
|
|
|
|
|
image = QImage.fromData(bytes(getattr(result, "image_bytes", b"") or b""))
|
|
|
|
|
|
if image.isNull():
|
|
|
|
|
|
self._thumbnail_errors.add(asset_id)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._thumbnail_errors.discard(asset_id)
|
|
|
|
|
|
self._thumbnail_pixmaps[asset_id] = QPixmap.fromImage(image)
|
|
|
|
|
|
self._fill_original_grid()
|
|
|
|
|
|
|
|
|
|
|
|
def _on_thumbnail_failed(self, payload):
|
|
|
|
|
|
project_id = int(payload.get("project_id") or 0)
|
|
|
|
|
|
asset_id = int(payload.get("asset_id") or 0)
|
|
|
|
|
|
self._thumbnail_pending.discard(str(payload.get("key") or ""))
|
|
|
|
|
|
if self.current_project is None or int(self.current_project.id) != project_id:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._thumbnail_errors.add(asset_id)
|
|
|
|
|
|
self._fill_original_grid()
|
|
|
|
|
|
|
|
|
|
|
|
def _retry_original_thumbnail(self, asset):
|
|
|
|
|
|
if self.current_project is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
asset_id = int(asset.id)
|
|
|
|
|
|
self._thumbnail_errors.discard(asset_id)
|
|
|
|
|
|
self._thumbnail_pixmaps.pop(asset_id, None)
|
|
|
|
|
|
self._fill_original_grid()
|
|
|
|
|
|
|
|
|
|
|
|
def _reset_thumbnail_view(self, project_id):
|
|
|
|
|
|
for key in list(self._thumbnail_pending):
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._thumbnail_loader.cancel(key)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
self._thumbnail_pending.clear()
|
|
|
|
|
|
self._thumbnail_pixmaps.clear()
|
|
|
|
|
|
self._thumbnail_errors.clear()
|
|
|
|
|
|
self._thumbnail_project_id = int(project_id) if project_id is not None else None
|
|
|
|
|
|
|
2026-07-11 15:41:38 +08:00
|
|
|
|
def _update_generation_action_text(self, *args):
|
|
|
|
|
|
count = self.count_spin.value() if hasattr(self, "count_spin") else 0
|
|
|
|
|
|
label = self.job_type_combo.currentText() if hasattr(self, "job_type_combo") else "图片"
|
|
|
|
|
|
self.start_button.setText(f"生成 {count} 张{label}")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def _open_preview(self, asset):
|
|
|
|
|
|
dialog = ImageStudioPreviewDialog(asset, self)
|
|
|
|
|
|
dialog.exec()
|
|
|
|
|
|
|
|
|
|
|
|
def _show_pool_context_menu(self, position):
|
2026-07-11 16:47:18 +08:00
|
|
|
|
item = self.pool_grid.itemAt(position)
|
|
|
|
|
|
data = self._grid_data(item)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if not data or data.get("type") != "asset":
|
|
|
|
|
|
return
|
|
|
|
|
|
asset_id = data.get("asset_id")
|
|
|
|
|
|
menu = QMenu(self)
|
|
|
|
|
|
try:
|
|
|
|
|
|
counts = image_studio.asset_reference_counts(asset_id, path=self.db_path)
|
|
|
|
|
|
referenced = bool(counts.get("total"))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
referenced = True
|
|
|
|
|
|
remove_action = menu.addAction(
|
|
|
|
|
|
"移除照片" if not referenced else "移除照片(已被任务或终选引用)"
|
|
|
|
|
|
)
|
|
|
|
|
|
remove_action.setEnabled(not referenced)
|
2026-07-11 16:47:18 +08:00
|
|
|
|
action = menu.exec(self.pool_grid.viewport().mapToGlobal(position))
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if action is remove_action and not referenced:
|
|
|
|
|
|
self.remove_asset(asset_id)
|
|
|
|
|
|
|
|
|
|
|
|
def remove_asset(self, asset_id):
|
2026-07-13 09:38:30 +08:00
|
|
|
|
if self._operation_running:
|
|
|
|
|
|
self._status("AI工场当前任务仍在进行,暂不能移除照片", "warning")
|
|
|
|
|
|
return
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if not self._confirm("移除照片", "只从AI工场照片池移除记录,不删除本地图片文件。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
image_studio.remove_asset_if_unused(asset_id, path=self.db_path)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("不能移除照片", str(exc))
|
|
|
|
|
|
return
|
|
|
|
|
|
if self.selected_source_asset_id == int(asset_id):
|
|
|
|
|
|
self.selected_source_asset_id = None
|
|
|
|
|
|
self.refresh_project_assets()
|
|
|
|
|
|
self._status("照片已从池中移除", "success")
|
|
|
|
|
|
|
|
|
|
|
|
def refresh_templates(self, selected=None):
|
|
|
|
|
|
current = selected or self.template_combo.currentData()
|
|
|
|
|
|
self.template_combo.blockSignals(True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.template_combo.clear()
|
|
|
|
|
|
self.template_combo.addItem("选择模板", "")
|
|
|
|
|
|
for name in prompts.list_image_studio_templates(self.prompts_dir):
|
|
|
|
|
|
self.template_combo.addItem(name, name)
|
|
|
|
|
|
if current:
|
|
|
|
|
|
index = self.template_combo.findData(current)
|
|
|
|
|
|
if index >= 0:
|
|
|
|
|
|
self.template_combo.setCurrentIndex(index)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.template_combo.blockSignals(False)
|
|
|
|
|
|
|
|
|
|
|
|
def load_selected_template(self, index=None):
|
|
|
|
|
|
name = self.template_combo.currentData()
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.prompt_edit.setPlainText(prompts.load_image_studio_template(name, self.prompts_dir))
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("加载模板失败", str(exc))
|
|
|
|
|
|
|
|
|
|
|
|
def create_template(self, checked=False):
|
|
|
|
|
|
name, ok = QInputDialog.getText(self, "新建模板", "模板名称")
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
prompts.save_image_studio_template(name, self.prompt_edit.toPlainText(), self.prompts_dir)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("新建模板失败", str(exc))
|
|
|
|
|
|
return
|
|
|
|
|
|
self.refresh_templates(selected=name)
|
|
|
|
|
|
self._status("AI工场模板已新建", "success")
|
|
|
|
|
|
|
|
|
|
|
|
def rename_template(self, checked=False):
|
|
|
|
|
|
old = self.template_combo.currentData()
|
|
|
|
|
|
if not old:
|
|
|
|
|
|
self._message("未选择模板", "请先选择要重命名的模板。")
|
|
|
|
|
|
return
|
|
|
|
|
|
new, ok = QInputDialog.getText(self, "重命名模板", "新模板名称", text=old)
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
prompts.rename_image_studio_template(old, new, self.prompts_dir)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("重命名模板失败", str(exc))
|
|
|
|
|
|
return
|
|
|
|
|
|
self.refresh_templates(selected=new)
|
|
|
|
|
|
self._status("AI工场模板已重命名", "success")
|
|
|
|
|
|
|
|
|
|
|
|
def save_template(self, checked=False):
|
|
|
|
|
|
name = self.template_combo.currentData()
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
name, ok = QInputDialog.getText(self, "保存模板", "模板名称")
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
prompts.save_image_studio_template(name, self.prompt_edit.toPlainText(), self.prompts_dir)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("保存模板失败", str(exc))
|
|
|
|
|
|
return
|
|
|
|
|
|
self.refresh_templates(selected=name)
|
|
|
|
|
|
self._status("AI工场模板已保存", "success")
|
|
|
|
|
|
|
|
|
|
|
|
def delete_template(self, checked=False):
|
|
|
|
|
|
name = self.template_combo.currentData()
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
self._message("未选择模板", "请先选择要删除的模板。")
|
|
|
|
|
|
return
|
|
|
|
|
|
if not self._confirm("删除模板", f"确定删除模板「{name}」吗?"):
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
prompts.delete_image_studio_template(name, self.prompts_dir)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("删除模板失败", str(exc))
|
|
|
|
|
|
return
|
|
|
|
|
|
self.refresh_templates()
|
|
|
|
|
|
self._status("AI工场模板已删除", "success")
|
|
|
|
|
|
|
|
|
|
|
|
def _save_project_prompt(self):
|
|
|
|
|
|
if self.current_project is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.current_project = image_studio.update_project_prompt(
|
|
|
|
|
|
self.current_project.id,
|
|
|
|
|
|
self.prompt_edit.toPlainText(),
|
|
|
|
|
|
path=self.db_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._status(f"保存AI工场草稿提示词失败:{exc}", "warning")
|
|
|
|
|
|
|
|
|
|
|
|
def start_generation(self, checked=False):
|
2026-07-13 09:38:30 +08:00
|
|
|
|
if self._operation_running:
|
|
|
|
|
|
self._status("AI工场当前任务仍在进行,请等待完成或停止后再生成", "warning")
|
|
|
|
|
|
return
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if self.current_project is None:
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
return
|
|
|
|
|
|
source = self._asset_by_id(self.selected_source_asset_id)
|
|
|
|
|
|
if source is None:
|
|
|
|
|
|
self._message("未选择源图", "请先在照片池单击选择一张源图。")
|
|
|
|
|
|
return
|
|
|
|
|
|
if not str(source.local_path or "").strip() or not os.path.isfile(source.local_path):
|
|
|
|
|
|
self._message("源图不可用", "请先单击蝦皮原主图下载到本地后再生成。")
|
|
|
|
|
|
return
|
|
|
|
|
|
prompt_text = self.prompt_edit.toPlainText().strip()
|
|
|
|
|
|
if not prompt_text:
|
|
|
|
|
|
self._message("提示词不能为空", "请输入完整图片生成提示词。")
|
|
|
|
|
|
return
|
|
|
|
|
|
count = self.count_spin.value()
|
|
|
|
|
|
self.progress_bar.setRange(0, count)
|
|
|
|
|
|
self.progress_bar.setValue(0)
|
|
|
|
|
|
self.log_view.clear()
|
2026-07-11 14:49:39 +08:00
|
|
|
|
self._append_log(f"[AI工场] 本轮生图开始:{count} 张,使用 {self._cmhub_image_model_summary()}")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
worker = ImageStudioGenerateJobsWorker(
|
|
|
|
|
|
self.current_project.id,
|
|
|
|
|
|
source.id,
|
|
|
|
|
|
prompt_text,
|
|
|
|
|
|
count,
|
|
|
|
|
|
job_type=self.job_type_combo.currentData(),
|
|
|
|
|
|
aspect_ratio=self.aspect_combo.currentData(),
|
|
|
|
|
|
db_path=self.db_path,
|
|
|
|
|
|
config=self.config,
|
|
|
|
|
|
cmhub_config_path=self.cmhub_config_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
worker.progress.connect(self._on_generate_progress)
|
|
|
|
|
|
worker.log.connect(self._append_log)
|
|
|
|
|
|
worker.finished.connect(self._on_generation_finished)
|
|
|
|
|
|
worker.failed.connect(self._on_worker_failed)
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self._start_worker(worker, "AI工场生成图片", operation_kind="generate")
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def stop_generation(self, checked=False):
|
|
|
|
|
|
if self._running_worker is not None and hasattr(self._running_worker, "cancel"):
|
|
|
|
|
|
self._running_worker.cancel()
|
|
|
|
|
|
self._append_log("[AI工场] 已请求停止,正在等待安全边界")
|
|
|
|
|
|
self._status("AI工场生成已请求停止", "warning")
|
|
|
|
|
|
|
2026-07-11 14:29:58 +08:00
|
|
|
|
def resume_generation_jobs(self, checked=False):
|
2026-07-13 09:38:30 +08:00
|
|
|
|
if self._operation_running:
|
|
|
|
|
|
self._status("AI工场当前任务仍在进行,请等待完成或停止后再继续查询", "warning")
|
|
|
|
|
|
return
|
2026-07-11 14:29:58 +08:00
|
|
|
|
if self.current_project is None:
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
|
2026-07-11 14:29:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
resumable = image_studio.list_resumable_jobs(
|
|
|
|
|
|
path=self.db_path,
|
|
|
|
|
|
project_id=self.current_project.id,
|
|
|
|
|
|
include_failed_downloads=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not resumable:
|
|
|
|
|
|
self._status("当前项目没有可继续查询的 cmhub 生图任务", "muted")
|
|
|
|
|
|
self._message("没有可继续查询任务", "当前项目没有已提交、生成中或下载失败的 cmhub 生图任务。")
|
|
|
|
|
|
return
|
|
|
|
|
|
self.progress_bar.setRange(0, len(resumable))
|
|
|
|
|
|
self.progress_bar.setValue(0)
|
|
|
|
|
|
self._append_log(f"[AI工场] 继续查询 {len(resumable)} 个已提交任务")
|
|
|
|
|
|
worker = ImageStudioResumeJobsWorker(
|
|
|
|
|
|
project_id=self.current_project.id,
|
|
|
|
|
|
aspect_ratio=self.aspect_combo.currentData(),
|
|
|
|
|
|
db_path=self.db_path,
|
|
|
|
|
|
config=self.config,
|
|
|
|
|
|
cmhub_config_path=self.cmhub_config_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
worker.progress.connect(self._on_generate_progress)
|
|
|
|
|
|
worker.log.connect(self._append_log)
|
|
|
|
|
|
worker.finished.connect(self._on_generation_finished)
|
|
|
|
|
|
worker.failed.connect(self._on_worker_failed)
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self._start_worker(worker, "AI工场继续查询", operation_kind="resume")
|
2026-07-11 14:29:58 +08:00
|
|
|
|
|
2026-07-11 14:26:01 +08:00
|
|
|
|
def export_selections(self, checked=False):
|
2026-07-13 09:38:30 +08:00
|
|
|
|
if self._operation_running:
|
|
|
|
|
|
self._status("AI工场当前任务仍在进行,暂不能导出终选", "warning")
|
|
|
|
|
|
return
|
2026-07-11 14:26:01 +08:00
|
|
|
|
if self.current_project is None:
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
|
2026-07-11 14:26:01 +08:00
|
|
|
|
return
|
|
|
|
|
|
main_count = len(self._selection_asset_ids("main"))
|
|
|
|
|
|
detail_count = len(self._selection_asset_ids("detail"))
|
|
|
|
|
|
if main_count + detail_count <= 0:
|
|
|
|
|
|
self._message("没有可导出图片", "主图和详情图终选都为空。")
|
|
|
|
|
|
return
|
|
|
|
|
|
parent_dir = QFileDialog.getExistingDirectory(self, "选择导出父目录")
|
|
|
|
|
|
if not parent_dir:
|
|
|
|
|
|
return
|
|
|
|
|
|
mode = self._export_existing_mode(parent_dir, main_count, detail_count)
|
|
|
|
|
|
if mode is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
worker = ImageStudioExportWorker(
|
|
|
|
|
|
self.current_project.id,
|
|
|
|
|
|
parent_dir,
|
|
|
|
|
|
existing_mode=mode,
|
|
|
|
|
|
db_path=self.db_path,
|
|
|
|
|
|
config=self.config,
|
|
|
|
|
|
)
|
|
|
|
|
|
worker.log.connect(self._append_log)
|
|
|
|
|
|
worker.finished.connect(self._on_export_finished)
|
|
|
|
|
|
worker.failed.connect(self._on_worker_failed)
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self._start_worker(worker, "AI工场导出终选", operation_kind="export")
|
2026-07-11 14:26:01 +08:00
|
|
|
|
|
|
|
|
|
|
def _export_existing_mode(self, parent_dir, main_count, detail_count):
|
|
|
|
|
|
try:
|
|
|
|
|
|
target = image_studio_export.target_dir_for_project(self.current_project, parent_dir)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("导出目录不可用", str(exc))
|
|
|
|
|
|
return None
|
|
|
|
|
|
if not os.path.exists(target):
|
|
|
|
|
|
return image_studio_export.EXISTING_FAIL
|
|
|
|
|
|
box = QMessageBox(self)
|
|
|
|
|
|
box.setWindowTitle("商品目录已存在")
|
|
|
|
|
|
box.setText(
|
|
|
|
|
|
f"目标目录已存在:{target}\n"
|
|
|
|
|
|
f"本次将导出主图 {main_count} 张、详情图 {detail_count} 张。请选择处理方式。"
|
|
|
|
|
|
)
|
|
|
|
|
|
overwrite_button = box.addButton("覆盖本软件导出的图片", QMessageBox.AcceptRole)
|
|
|
|
|
|
timestamp_button = box.addButton("新建带时间目录", QMessageBox.ActionRole)
|
|
|
|
|
|
box.addButton("取消", QMessageBox.RejectRole)
|
|
|
|
|
|
box.setDefaultButton(timestamp_button)
|
|
|
|
|
|
box.exec()
|
|
|
|
|
|
clicked = box.clickedButton()
|
|
|
|
|
|
if clicked is overwrite_button:
|
|
|
|
|
|
return image_studio_export.EXISTING_OVERWRITE_MANAGED
|
|
|
|
|
|
if clicked is timestamp_button:
|
|
|
|
|
|
return image_studio_export.EXISTING_TIMESTAMP
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _on_export_finished(self, summary):
|
2026-07-11 15:13:09 +08:00
|
|
|
|
if self._handle_finished_error(summary, "导出终选失败"):
|
2026-07-11 14:26:01 +08:00
|
|
|
|
return
|
|
|
|
|
|
self._finish_worker()
|
|
|
|
|
|
target_dir = summary.get("target_dir") or ""
|
|
|
|
|
|
main_count = int(summary.get("main_count") or 0)
|
|
|
|
|
|
detail_count = int(summary.get("detail_count") or 0)
|
|
|
|
|
|
file_count = int(summary.get("file_count") or 0)
|
|
|
|
|
|
self._append_log(f"[AI工场] 导出完成:主图{main_count},详情图{detail_count},文件{file_count}")
|
|
|
|
|
|
self._status(f"AI工场终选已导出:{file_count} 个文件", "success")
|
|
|
|
|
|
self._show_export_success(target_dir, main_count, detail_count, file_count)
|
|
|
|
|
|
|
|
|
|
|
|
def _show_export_success(self, target_dir, main_count, detail_count, file_count):
|
|
|
|
|
|
box = QMessageBox(self)
|
|
|
|
|
|
box.setWindowTitle("导出完成")
|
|
|
|
|
|
box.setText(
|
|
|
|
|
|
f"已导出 {file_count} 个 JPEG 文件。\n"
|
|
|
|
|
|
f"主图 {main_count} 张,详情图 {detail_count} 张。\n"
|
|
|
|
|
|
f"目录:{target_dir}"
|
|
|
|
|
|
)
|
|
|
|
|
|
open_button = box.addButton("打开目录", QMessageBox.AcceptRole)
|
|
|
|
|
|
box.addButton("确定", QMessageBox.RejectRole)
|
|
|
|
|
|
box.setDefaultButton(open_button)
|
|
|
|
|
|
box.exec()
|
|
|
|
|
|
if box.clickedButton() is open_button:
|
|
|
|
|
|
try:
|
|
|
|
|
|
file_manager.open_in_file_manager(target_dir)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._message("打开目录失败", str(exc))
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def _on_generate_progress(self, payload):
|
|
|
|
|
|
total = max(1, int(payload.get("total") or self.progress_bar.maximum() or 1))
|
|
|
|
|
|
done = min(total, int(payload.get("done") or 0))
|
|
|
|
|
|
self.progress_bar.setRange(0, total)
|
|
|
|
|
|
self.progress_bar.setValue(done)
|
|
|
|
|
|
if payload.get("points_balance") is not None:
|
2026-07-11 14:49:39 +08:00
|
|
|
|
text = f"{self._cmhub_image_model_summary()};余额 {payload.get('points_balance')}"
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if payload.get("points_cost") is not None:
|
|
|
|
|
|
text += f",本张扣点 {payload.get('points_cost')}"
|
|
|
|
|
|
self.billing_label.setText(text)
|
|
|
|
|
|
|
|
|
|
|
|
def _on_generation_finished(self, summary):
|
2026-07-11 15:13:09 +08:00
|
|
|
|
if self._handle_finished_error(summary, "AI工场生成失败"):
|
2026-07-11 14:16:11 +08:00
|
|
|
|
return
|
|
|
|
|
|
self._finish_worker()
|
|
|
|
|
|
self.refresh_project_assets()
|
|
|
|
|
|
total = int(summary.get("total") or 0)
|
|
|
|
|
|
success = int(summary.get("success") or 0)
|
|
|
|
|
|
failed = int(summary.get("failed") or 0)
|
|
|
|
|
|
cancelled = int(summary.get("cancelled") or 0)
|
|
|
|
|
|
self._append_log(f"[AI工场] 本轮完成:总数{total},成功{success},失败{failed},停止{cancelled}")
|
|
|
|
|
|
level = "warning" if failed or cancelled else "success"
|
|
|
|
|
|
self._status(f"AI工场生成完成:成功{success},失败{failed},停止{cancelled}", level)
|
|
|
|
|
|
|
|
|
|
|
|
def _on_worker_failed(self, row, error):
|
2026-07-11 15:13:09 +08:00
|
|
|
|
if self._worker_error_handled:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._worker_error_handled = True
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self._finish_worker()
|
|
|
|
|
|
message = diagnostics.redact_log_text(str(error or "未知错误"))
|
|
|
|
|
|
self._append_log(f"[AI工场] 失败:{message}")
|
|
|
|
|
|
self._status(f"AI工场任务失败:{message}", "danger")
|
|
|
|
|
|
self._message("AI工场任务失败", message)
|
|
|
|
|
|
self.refresh_project_assets()
|
|
|
|
|
|
|
2026-07-11 15:13:09 +08:00
|
|
|
|
def _handle_finished_error(self, summary, fallback_message):
|
|
|
|
|
|
if summary.get("ok") is not False:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if not self._worker_error_handled:
|
|
|
|
|
|
self._on_worker_failed(-1, summary.get("error") or fallback_message)
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-07-13 09:38:30 +08:00
|
|
|
|
def _start_worker(self, worker, thread_name, *, operation_kind="exclusive"):
|
|
|
|
|
|
if self._running_thread is not None:
|
|
|
|
|
|
self._status("AI工场上一项任务仍在收尾,请稍候", "warning")
|
|
|
|
|
|
return False
|
2026-07-11 15:13:09 +08:00
|
|
|
|
thread = run_worker(worker, thread_name=thread_name, start=False)
|
|
|
|
|
|
thread.finished.connect(lambda: self._forget_running_thread(thread))
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self._running_worker = worker
|
2026-07-11 15:13:09 +08:00
|
|
|
|
self._running_thread = thread
|
|
|
|
|
|
self._worker_error_handled = False
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self._set_running(True, operation_kind=operation_kind)
|
2026-07-11 15:13:09 +08:00
|
|
|
|
thread.start()
|
2026-07-13 09:38:30 +08:00
|
|
|
|
return True
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def _finish_worker(self):
|
|
|
|
|
|
self._set_running(False)
|
|
|
|
|
|
|
2026-07-11 15:13:09 +08:00
|
|
|
|
def _forget_running_thread(self, thread):
|
|
|
|
|
|
if self._running_thread is thread:
|
|
|
|
|
|
self._running_thread = None
|
|
|
|
|
|
self._running_worker = None
|
|
|
|
|
|
|
2026-07-13 09:38:30 +08:00
|
|
|
|
def _is_generation_operation(self):
|
|
|
|
|
|
return self._operation_running and self._operation_kind in {"generate", "resume"}
|
|
|
|
|
|
|
|
|
|
|
|
def _set_running(self, running, *, operation_kind="exclusive"):
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._operation_running = bool(running)
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self._operation_kind = str(operation_kind or "exclusive") if running else ""
|
|
|
|
|
|
generation_running = self._is_generation_operation()
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.pull_images_button.setEnabled(not running)
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self.account_combo.setEnabled(not running)
|
|
|
|
|
|
self.item_id_edit.setEnabled(not running)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.project_table.setEnabled(not running)
|
2026-07-11 16:47:18 +08:00
|
|
|
|
self.original_grid.setEnabled(not running)
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self.pool_grid.setEnabled(not running or generation_running)
|
2026-07-13 09:54:08 +08:00
|
|
|
|
self.job_grid.setEnabled(not running or generation_running)
|
2026-07-13 09:38:30 +08:00
|
|
|
|
self.main_selection_list.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.detail_selection_list.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.template_combo.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.template_new_button.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.template_rename_button.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.template_save_button.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.template_delete_button.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.prompt_edit.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.job_type_combo.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.count_spin.setEnabled(not running or generation_running)
|
|
|
|
|
|
self.aspect_combo.setEnabled(not running or generation_running)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.start_button.setEnabled(not running)
|
2026-07-11 14:29:58 +08:00
|
|
|
|
self.resume_button.setEnabled(not running)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
self.stop_button.setEnabled(running)
|
2026-07-11 14:26:01 +08:00
|
|
|
|
self.export_button.setEnabled(not running)
|
2026-07-13 09:38:30 +08:00
|
|
|
|
if generation_running:
|
|
|
|
|
|
self.generation_guard_label.setText("本轮任务已固定;此处修改将在下一轮生成时生效。")
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.generation_guard_label.setText("不会在失败后静默切换生成来源。")
|
2026-07-11 16:23:22 +08:00
|
|
|
|
self._update_project_action_buttons()
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def _set_account_combo(self, alias):
|
|
|
|
|
|
index = self.account_combo.findData(alias)
|
|
|
|
|
|
if index >= 0:
|
|
|
|
|
|
self.account_combo.setCurrentIndex(index)
|
|
|
|
|
|
|
|
|
|
|
|
def _list_project_jobs(self, project_id):
|
|
|
|
|
|
conn = db.connect(self.db_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT * FROM image_studio_jobs
|
|
|
|
|
|
WHERE project_id = ?
|
|
|
|
|
|
ORDER BY updated_at DESC, id DESC
|
|
|
|
|
|
""",
|
|
|
|
|
|
(int(project_id),),
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
return [image_studio.ImageStudioJob(**dict(row)) for row in rows]
|
|
|
|
|
|
finally:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_selection_labels(self):
|
|
|
|
|
|
main_count = sum(1 for item in self.selections if item.selection_type == "main")
|
|
|
|
|
|
detail_count = sum(1 for item in self.selections if item.selection_type == "detail")
|
|
|
|
|
|
main_target = getattr(self.current_project, "target_main_count", 9) if self.current_project else 9
|
|
|
|
|
|
detail_target = getattr(self.current_project, "target_detail_count", 12) if self.current_project else 12
|
2026-07-11 14:20:56 +08:00
|
|
|
|
self.main_selection_label.setText(f"主图终选 {main_count}/{main_target}")
|
|
|
|
|
|
self.detail_selection_label.setText(f"详情图终选 {detail_count}/{detail_target}")
|
2026-07-11 17:46:58 +08:00
|
|
|
|
total_count = main_count + detail_count
|
|
|
|
|
|
if total_count:
|
|
|
|
|
|
self.export_summary_label.setText(
|
|
|
|
|
|
f"当前已选 {total_count} 张:主图 {main_count}/{main_target} · 详情 {detail_count}/{detail_target}"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.export_summary_label.setText("当前未选择终选图片")
|
2026-07-11 14:20:56 +08:00
|
|
|
|
self._fill_selection_list(self.main_selection_list, "main")
|
|
|
|
|
|
self._fill_selection_list(self.detail_selection_list, "detail")
|
|
|
|
|
|
|
|
|
|
|
|
def _fill_selection_list(self, widget, selection_type):
|
|
|
|
|
|
widget.clear()
|
2026-07-11 15:41:38 +08:00
|
|
|
|
selected = [item for item in self.selections if item.selection_type == selection_type]
|
|
|
|
|
|
for index, selection in enumerate(selected, start=1):
|
2026-07-11 14:20:56 +08:00
|
|
|
|
asset = self._asset_by_id(selection.asset_id)
|
|
|
|
|
|
if asset is None:
|
2026-07-11 17:46:58 +08:00
|
|
|
|
text = f"{index}\n缺失"
|
2026-07-11 14:20:56 +08:00
|
|
|
|
item = QListWidgetItem(text)
|
|
|
|
|
|
item.setData(Qt.UserRole, int(selection.asset_id))
|
|
|
|
|
|
item.setToolTip("终选引用的照片记录不存在")
|
|
|
|
|
|
item.setForeground(_qcolor(COLOR_DANGER))
|
2026-07-11 17:46:58 +08:00
|
|
|
|
item.setSizeHint(widget.gridSize())
|
2026-07-11 14:20:56 +08:00
|
|
|
|
widget.addItem(item)
|
|
|
|
|
|
continue
|
2026-07-11 17:46:58 +08:00
|
|
|
|
text = str(index)
|
2026-07-11 14:20:56 +08:00
|
|
|
|
item = QListWidgetItem(text)
|
2026-07-11 17:46:58 +08:00
|
|
|
|
item.setIcon(_asset_icon(asset, str(index), size=widget.iconSize()))
|
2026-07-11 14:20:56 +08:00
|
|
|
|
item.setData(Qt.UserRole, int(asset.id))
|
2026-07-11 17:46:58 +08:00
|
|
|
|
item.setSizeHint(widget.gridSize())
|
2026-07-11 14:20:56 +08:00
|
|
|
|
tooltip = _selection_tooltip(selection_type, asset)
|
|
|
|
|
|
item.setToolTip(tooltip)
|
|
|
|
|
|
if _selection_ratio_warning(selection_type, asset):
|
|
|
|
|
|
item.setBackground(QColor("#fff8c5"))
|
|
|
|
|
|
item.setToolTip(tooltip + "\n比例与主图推荐比例不一致,仅提示,不阻止导出。")
|
|
|
|
|
|
if not _asset_is_usable(asset):
|
|
|
|
|
|
item.setForeground(_qcolor(COLOR_MUTED))
|
|
|
|
|
|
item.setToolTip(tooltip + "\n本地文件缺失,不能用于导出。")
|
|
|
|
|
|
widget.addItem(item)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
target = self._selection_target_count(selection_type)
|
|
|
|
|
|
for index in range(len(selected) + 1, target + 1):
|
2026-07-11 17:46:58 +08:00
|
|
|
|
item = QListWidgetItem("")
|
|
|
|
|
|
item.setIcon(QIcon(_placeholder_pixmap(str(index), widget.iconSize(), "#f6f8fa")))
|
2026-07-11 15:41:38 +08:00
|
|
|
|
item.setData(Qt.UserRole, None)
|
2026-07-11 17:46:58 +08:00
|
|
|
|
item.setSizeHint(widget.gridSize())
|
2026-07-11 15:41:38 +08:00
|
|
|
|
item.setToolTip(f"{_selection_label(selection_type)}空位,可从照片池拖入图片。")
|
|
|
|
|
|
item.setForeground(_qcolor(COLOR_MUTED))
|
2026-07-11 17:46:58 +08:00
|
|
|
|
item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsDropEnabled)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
widget.addItem(item)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _asset_from_grid_item(self, item):
|
|
|
|
|
|
data = self._grid_data(item)
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if not data or data.get("type") != "asset":
|
|
|
|
|
|
return None
|
|
|
|
|
|
return self._asset_by_id(data.get("asset_id"))
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _grid_data(self, item):
|
2026-07-11 14:16:11 +08:00
|
|
|
|
if item is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return item.data(Qt.UserRole)
|
|
|
|
|
|
|
|
|
|
|
|
def _asset_by_id(self, asset_id):
|
|
|
|
|
|
if asset_id is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for asset in self.assets:
|
|
|
|
|
|
if int(asset.id) == int(asset_id):
|
|
|
|
|
|
return asset
|
|
|
|
|
|
try:
|
|
|
|
|
|
return image_studio.get_asset(asset_id, path=self.db_path)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _append_log(self, message):
|
|
|
|
|
|
text = diagnostics.redact_log_text(str(message or ""))
|
|
|
|
|
|
self.log_view.appendPlainText(text)
|
|
|
|
|
|
scrollbar = self.log_view.verticalScrollBar()
|
|
|
|
|
|
scrollbar.setValue(scrollbar.maximum())
|
|
|
|
|
|
|
2026-07-11 14:49:39 +08:00
|
|
|
|
def _refresh_model_hint(self):
|
|
|
|
|
|
self.billing_label.setText(self._cmhub_image_model_summary())
|
|
|
|
|
|
|
|
|
|
|
|
def _cmhub_image_model_summary(self):
|
|
|
|
|
|
alias = appconfig.cmhub_config(self.config).get("image_alias", "")
|
|
|
|
|
|
return cmhub_models.configured_alias_summary(alias)
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def _message(self, title, text):
|
|
|
|
|
|
box = QMessageBox(self)
|
|
|
|
|
|
box.setWindowTitle(str(title or "提示"))
|
|
|
|
|
|
box.setText(str(text or ""))
|
|
|
|
|
|
ok_button = box.addButton("确定", QMessageBox.AcceptRole)
|
|
|
|
|
|
box.setDefaultButton(ok_button)
|
|
|
|
|
|
box.exec()
|
|
|
|
|
|
|
|
|
|
|
|
def _confirm(self, title, text):
|
|
|
|
|
|
box = QMessageBox(self)
|
|
|
|
|
|
box.setWindowTitle(str(title or "确认"))
|
|
|
|
|
|
box.setText(str(text or ""))
|
|
|
|
|
|
yes_button = box.addButton("确定", QMessageBox.AcceptRole)
|
|
|
|
|
|
box.addButton("取消", QMessageBox.RejectRole)
|
|
|
|
|
|
box.setDefaultButton(yes_button)
|
|
|
|
|
|
box.exec()
|
|
|
|
|
|
return box.clickedButton() is yes_button
|
|
|
|
|
|
|
|
|
|
|
|
def _status(self, message, level="muted"):
|
|
|
|
|
|
_emit_status(self.status_callback, message, level=level)
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def closeEvent(self, event):
|
2026-07-11 17:28:37 +08:00
|
|
|
|
self._original_downloads_closing = True
|
|
|
|
|
|
self._cancel_original_downloads()
|
2026-07-11 16:47:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
self._thumbnail_loader.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
super().closeEvent(event)
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
|
|
|
|
|
|
def _asset_badge(kind):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"original": "原图",
|
|
|
|
|
|
"generated_main": "主图",
|
|
|
|
|
|
"generated_detail": "详情图",
|
|
|
|
|
|
}.get(str(kind or ""), str(kind or "图片"))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 16:23:22 +08:00
|
|
|
|
def _project_display_name(project):
|
|
|
|
|
|
account_name = str(getattr(project, "account_name", "") or "").strip()
|
|
|
|
|
|
account_alias = str(getattr(project, "account_alias", "") or "").strip()
|
|
|
|
|
|
item_id = str(getattr(project, "item_id", "") or "").strip()
|
|
|
|
|
|
return f"{account_name or account_alias or '未命名店铺'} · {item_id or '未填写商品ID'}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def _asset_status_text(asset):
|
|
|
|
|
|
status = str(getattr(asset, "status", "") or "")
|
|
|
|
|
|
local_path = str(getattr(asset, "local_path", "") or "")
|
|
|
|
|
|
if status == image_studio.ASSET_STATUS_MISSING:
|
|
|
|
|
|
return "文件缺失"
|
|
|
|
|
|
if local_path and os.path.isfile(local_path):
|
|
|
|
|
|
return "可用"
|
|
|
|
|
|
if getattr(asset, "remote_url", None):
|
|
|
|
|
|
return "远程待下载"
|
|
|
|
|
|
return "待生成"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 14:20:56 +08:00
|
|
|
|
def _asset_is_usable(asset):
|
|
|
|
|
|
if asset is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if str(getattr(asset, "status", "") or "") == image_studio.ASSET_STATUS_MISSING:
|
|
|
|
|
|
return False
|
|
|
|
|
|
local_path = str(getattr(asset, "local_path", "") or "")
|
|
|
|
|
|
return bool(local_path and os.path.isfile(local_path))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _asset_icon(asset, fallback_label, size=None, cached_pixmap=None):
|
|
|
|
|
|
return QIcon(
|
|
|
|
|
|
_asset_pixmap(
|
|
|
|
|
|
asset,
|
|
|
|
|
|
size or QSize(86, 86),
|
|
|
|
|
|
fallback_label=fallback_label,
|
|
|
|
|
|
cached_pixmap=cached_pixmap,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-11 15:41:38 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-13 09:54:08 +08:00
|
|
|
|
def _job_icon(status, size=None):
|
2026-07-11 15:41:38 +08:00
|
|
|
|
label = {
|
2026-07-13 09:54:08 +08:00
|
|
|
|
"pending": "…",
|
|
|
|
|
|
"submitted": "…",
|
|
|
|
|
|
"running": "…",
|
|
|
|
|
|
"failed": "!",
|
|
|
|
|
|
"expired": "!",
|
2026-07-11 15:41:38 +08:00
|
|
|
|
"cancelled": "停",
|
2026-07-13 09:54:08 +08:00
|
|
|
|
}.get(str(status or ""), "?")
|
2026-07-11 15:41:38 +08:00
|
|
|
|
color = {
|
|
|
|
|
|
"failed": "#ffebe9",
|
|
|
|
|
|
"expired": "#ffebe9",
|
|
|
|
|
|
"cancelled": "#f6f8fa",
|
|
|
|
|
|
"running": "#fff8c5",
|
|
|
|
|
|
"submitted": "#fff8c5",
|
|
|
|
|
|
"pending": "#f6f8fa",
|
|
|
|
|
|
}.get(str(status or ""), "#f6f8fa")
|
2026-07-13 09:54:08 +08:00
|
|
|
|
return QIcon(_placeholder_pixmap(label, size or QSize(42, 42), color))
|
2026-07-11 15:41:38 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 16:47:18 +08:00
|
|
|
|
def _asset_pixmap(asset, size, fallback_label=None, cached_pixmap=None):
|
|
|
|
|
|
if cached_pixmap is not None and not cached_pixmap.isNull():
|
|
|
|
|
|
scaled = cached_pixmap.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
|
|
|
|
canvas = _placeholder_pixmap("", size, "#f6f8fa")
|
|
|
|
|
|
painter = QPainter(canvas)
|
|
|
|
|
|
painter.drawPixmap(
|
|
|
|
|
|
(size.width() - scaled.width()) // 2,
|
|
|
|
|
|
(size.height() - scaled.height()) // 2,
|
|
|
|
|
|
scaled,
|
|
|
|
|
|
)
|
|
|
|
|
|
painter.end()
|
|
|
|
|
|
return canvas
|
2026-07-11 15:41:38 +08:00
|
|
|
|
path = str(getattr(asset, "local_path", "") or "")
|
|
|
|
|
|
if path and os.path.isfile(path):
|
|
|
|
|
|
image = QImage(path)
|
|
|
|
|
|
if not image.isNull():
|
|
|
|
|
|
scaled = QPixmap.fromImage(image).scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
|
|
|
|
canvas = _placeholder_pixmap("", size, "#f6f8fa")
|
|
|
|
|
|
painter = QPainter(canvas)
|
|
|
|
|
|
painter.drawPixmap(
|
|
|
|
|
|
(size.width() - scaled.width()) // 2,
|
|
|
|
|
|
(size.height() - scaled.height()) // 2,
|
|
|
|
|
|
scaled,
|
|
|
|
|
|
)
|
|
|
|
|
|
painter.end()
|
|
|
|
|
|
return canvas
|
|
|
|
|
|
label = str(fallback_label or _asset_badge(getattr(asset, "kind", "")) or "图")[:2]
|
|
|
|
|
|
color = {
|
|
|
|
|
|
"original": "#dce9f7",
|
|
|
|
|
|
"generated_main": "#ddf4ff",
|
|
|
|
|
|
"generated_detail": "#dafbe1",
|
|
|
|
|
|
}.get(str(getattr(asset, "kind", "") or ""), "#f6f8fa")
|
|
|
|
|
|
if str(getattr(asset, "status", "") or "") == image_studio.ASSET_STATUS_MISSING:
|
|
|
|
|
|
color = "#ffebe9"
|
|
|
|
|
|
return _placeholder_pixmap(label, size, color)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _placeholder_pixmap(label, size, color):
|
|
|
|
|
|
pixmap = QPixmap(size)
|
|
|
|
|
|
pixmap.fill(QColor(color))
|
|
|
|
|
|
painter = QPainter(pixmap)
|
|
|
|
|
|
painter.setPen(QColor("#d0d7de"))
|
|
|
|
|
|
painter.drawRect(0, 0, size.width() - 1, size.height() - 1)
|
|
|
|
|
|
if label:
|
|
|
|
|
|
painter.setPen(QColor("#57606a"))
|
|
|
|
|
|
painter.drawText(pixmap.rect(), Qt.AlignCenter, str(label))
|
|
|
|
|
|
painter.end()
|
|
|
|
|
|
return pixmap
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 14:16:11 +08:00
|
|
|
|
def _source_text(asset, assets):
|
|
|
|
|
|
parent_id = getattr(asset, "parent_asset_id", None)
|
|
|
|
|
|
if not parent_id:
|
|
|
|
|
|
return "原始来源"
|
|
|
|
|
|
for item in assets:
|
|
|
|
|
|
if int(item.id) == int(parent_id):
|
|
|
|
|
|
return f"{_asset_badge(item.kind)} #{item.id}"
|
|
|
|
|
|
return f"源图 #{parent_id}"
|
2026-07-11 14:20:56 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _selection_label(selection_type):
|
|
|
|
|
|
return "详情图终选" if selection_type == "detail" else "主图终选"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clamp_insert_index(value, length):
|
|
|
|
|
|
try:
|
|
|
|
|
|
index = int(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
index = length
|
|
|
|
|
|
return max(0, min(index, int(length)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _selection_ratio_warning(selection_type, asset):
|
|
|
|
|
|
if selection_type != "main":
|
|
|
|
|
|
return False
|
|
|
|
|
|
ratio = str(getattr(asset, "aspect_ratio", "") or "").strip()
|
|
|
|
|
|
return bool(ratio and ratio != "1:1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _selection_tooltip(selection_type, asset):
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"{_selection_label(selection_type)}:{_asset_badge(getattr(asset, 'kind', ''))} "
|
|
|
|
|
|
f"#{getattr(asset, 'id', '')},比例 {getattr(asset, 'aspect_ratio', None) or '未知'}"
|
|
|
|
|
|
)
|
2026-07-11 14:29:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _job_status_text(job, labels):
|
|
|
|
|
|
parts = [labels.get(job.status, job.status)]
|
2026-07-13 09:54:08 +08:00
|
|
|
|
recovery = _job_recovery_summary(job)
|
|
|
|
|
|
if recovery:
|
|
|
|
|
|
parts.append(recovery)
|
|
|
|
|
|
error = _job_error_summary(job)
|
|
|
|
|
|
if error:
|
|
|
|
|
|
parts.append(error)
|
|
|
|
|
|
billing = _job_billing_text(job)
|
|
|
|
|
|
if billing:
|
|
|
|
|
|
parts.append(billing)
|
|
|
|
|
|
return ",".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _job_billing_text(job):
|
|
|
|
|
|
parts = []
|
2026-07-11 14:29:58 +08:00
|
|
|
|
if job.points_cost is not None:
|
|
|
|
|
|
parts.append(f"扣点{job.points_cost}")
|
|
|
|
|
|
if job.points_balance is not None:
|
|
|
|
|
|
parts.append(f"余额{job.points_balance}")
|
|
|
|
|
|
if job.call_id:
|
|
|
|
|
|
parts.append(f"call_id={job.call_id}")
|
|
|
|
|
|
return ",".join(parts)
|
2026-07-13 09:54:08 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _job_recovery_summary(job):
|
|
|
|
|
|
if getattr(job, "recovery_action", "") == image_studio.JOB_RECOVERY_RESUME:
|
|
|
|
|
|
return "可继续查询,不会重复扣点"
|
|
|
|
|
|
if job.status == "pending":
|
|
|
|
|
|
return "等待本轮提交"
|
|
|
|
|
|
if job.status == "submitted":
|
|
|
|
|
|
return "等待任务结果"
|
|
|
|
|
|
if job.status == "running":
|
|
|
|
|
|
return "正在生成,可稍后继续查询"
|
|
|
|
|
|
return "需要重新生成,可能再次扣点"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _job_error_summary(job):
|
|
|
|
|
|
raw = " ".join(str(getattr(job, "error", "") or "").split())
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
text = diagnostics.redact_log_text(raw)
|
|
|
|
|
|
lowered = text.lower()
|
|
|
|
|
|
if (
|
|
|
|
|
|
"http://" in lowered
|
|
|
|
|
|
or "https://" in lowered
|
|
|
|
|
|
or "/api/" in lowered
|
|
|
|
|
|
or "traceback" in lowered
|
|
|
|
|
|
or any(char.isascii() and char.isalpha() for char in text)
|
|
|
|
|
|
):
|
|
|
|
|
|
return "任务未完成,请按恢复方式处理"
|
|
|
|
|
|
return text[:24] + ("…" if len(text) > 24 else "")
|