Files
cmshoppe/app/gui/tabs/image_studio.py
T

2310 lines
96 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tab 6: AI image studio UI."""
from __future__ import annotations
import os
from PySide6.QtCore import QObject, QMimeData, QSize, Signal
from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem, QSizePolicy
from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, image_studio_images, prompts
from .. import file_manager
from ..widgets import *
from ..workers import (
ImageStudioDownloadOriginalWorker as _RealImageStudioDownloadOriginalWorker,
)
from ..workers import ImageStudioExportWorker as _RealImageStudioExportWorker
from ..workers import ImageStudioGenerateJobsWorker as _RealImageStudioGenerateJobsWorker
from ..workers import ImageStudioPullImagesWorker as _RealImageStudioPullImagesWorker
from ..workers import ImageStudioResumeJobsWorker as _RealImageStudioResumeJobsWorker
ASSET_MIME_TYPE = "application/x-cmshopee-image-studio-asset"
ORIGINAL_DOWNLOAD_CONCURRENCY = 2
_ORIGINAL_DOWNLOAD_THREAD_REFS = {}
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,
)
def ImageStudioExportWorker(*args, **kwargs):
return _call_package_attr(
"ImageStudioExportWorker",
_RealImageStudioExportWorker,
*args,
**kwargs,
)
def ImageStudioResumeJobsWorker(*args, **kwargs):
return _call_package_attr(
"ImageStudioResumeJobsWorker",
_RealImageStudioResumeJobsWorker,
*args,
**kwargs,
)
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
class _ThumbnailSignals(QObject):
"""Deliver executor callbacks to the GUI thread without exposing remote URLs."""
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):
super().__init__(parent)
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
)
self.setDefaultDropAction(Qt.CopyAction)
def mimeData(self, items):
mime = QMimeData()
if not self._draggable_assets or not items:
return mime
data = items[0].data(Qt.UserRole) or {}
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)
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)
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)
if asset_id is None:
return
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)
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):
return row
return None
class ImageStudioPreviewDialog(QDialog):
"""Simple large image preview used by original and pool tables."""
def __init__(self, asset, parent=None):
super().__init__(parent)
self.asset = asset
self.setWindowTitle(self._title_for_asset(asset))
layout = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setWidgetResizable(False)
image_label = QLabel()
image_label.setAlignment(Qt.AlignCenter)
path = str(getattr(asset, "local_path", "") or "")
image = QImage(path) if path and os.path.isfile(path) else QImage()
if image.isNull():
image_label.setText("图片尚未下载或读取失败")
image_label.setMinimumSize(420, 260)
else:
image_label.setPixmap(QPixmap.fromImage(image))
image_label.resize(image.size())
self.setWindowTitle(
f"{self._title_for_asset(asset)} · {image.width()}x{image.height()}"
)
scroll.setWidget(image_label)
layout.addWidget(scroll, 1)
buttons = QHBoxLayout()
buttons.addStretch(1)
close_button = QPushButton("关闭")
close_button.clicked.connect(self.reject)
buttons.addWidget(close_button)
layout.addLayout(buttons)
self.resize(720, 520)
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."""
PROJECT_COLUMNS = ["店铺", "商品ID", "更新时间"]
JOB_STATUS_LABELS = {
"pending": "排队中",
"submitted": "已提交",
"running": "生成中",
"succeeded": "成功",
"failed": "失败",
"expired": "已过期",
"cancelled": "已停止",
}
def __init__(
self,
parent=None,
db_path=None,
config=None,
config_path=None,
status_callback=None,
prompts_dir=None,
thumbnail_loader=None,
):
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
self._operation_running = False
self._worker_error_handled = False
self._download_open_after = {}
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
self._original_downloads = {}
self._original_download_queue = []
self._original_download_states = {}
self._original_downloads_closing = False
self._thumbnail_signals = _ThumbnailSignals(self)
self._thumbnail_signals.loaded.connect(self._on_thumbnail_loaded)
self._thumbnail_signals.failed.connect(self._on_thumbnail_failed)
self._build_ui()
self._connect_signals()
self._refresh_model_hint()
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)
root.addWidget(self._build_top_bar(), 0)
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)
self.final_panel = self._build_final_panel()
root.addWidget(self.final_panel, 0)
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)
self.current_project_label = QLabel("未选择商品")
self.current_project_label.setObjectName("imageStudioCurrentProjectLabel")
self.current_project_label.setMinimumWidth(220)
self.autosave_label = QLabel("选择店铺并输入商品ID后拉取")
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")
self.delete_project_button = QPushButton("删除项目")
self.delete_project_button.setObjectName("imageStudioDeleteProjectButton")
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)
layout.addWidget(self.pull_images_button)
layout.addWidget(self.autosave_label)
layout.addStretch(1)
layout.addWidget(self.open_folder_button)
layout.addWidget(self.delete_project_button)
return panel
def _build_project_panel(self):
panel = QWidget()
panel.setObjectName("imageStudioProjectPanel")
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
title = QLabel("商品列表")
title.setObjectName("imageStudioSectionTitle")
hint = QLabel("点击切换项目,项目会自动保存")
hint.setObjectName("imageStudioMutedLabel")
hint.setWordWrap(True)
layout.addWidget(title)
layout.addWidget(hint)
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)
self.project_table.setMinimumWidth(150)
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)
self.workspace_empty_label = QLabel("选择店铺并输入商品ID后,点击「拉取蝦皮主图」开始。")
self.workspace_empty_label.setObjectName("imageStudioWorkspaceEmptyLabel")
self.workspace_empty_label.setWordWrap(True)
layout.addWidget(self.workspace_empty_label)
original_header = QHBoxLayout()
original_title = QLabel("蝦皮原主图")
original_title.setObjectName("imageStudioSectionTitle")
original_header.addWidget(original_title)
self.original_download_label = QLabel("")
self.original_download_label.setObjectName("imageStudioOriginalDownloadLabel")
self.original_download_label.setMinimumWidth(156)
original_header.addWidget(self.original_download_label)
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)
self.original_grid = ImageStudioThumbnailGrid(parent=self)
self.original_grid.setObjectName("imageStudioOriginalGrid")
self.original_grid.setIconSize(QSize(48, 48))
self.original_grid.setGridSize(QSize(58, 78))
self.original_grid.setMinimumHeight(94)
self.original_grid.setMaximumHeight(110)
self.original_grid.setContextMenuPolicy(Qt.CustomContextMenu)
layout.addWidget(self.original_grid, 0)
pool_header = QHBoxLayout()
pool_title = QLabel("照片池")
pool_title.setObjectName("imageStudioSectionTitle")
pool_header.addWidget(pool_title)
pool_header.addStretch(1)
self.source_label = QLabel("源图:未选择")
self.source_label.setObjectName("imageStudioSourceLabel")
pool_header.addWidget(self.source_label)
layout.addLayout(pool_header)
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)
return panel
def _build_generation_panel(self):
panel = QWidget()
panel.setObjectName("imageStudioGenerationPanel")
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
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)
source_preview_tooltip = "当前源图。请在照片池单击选择源图,双击可查看大图。"
self.source_preview_label.setToolTip(source_preview_tooltip)
source_row.addWidget(self.source_preview_label, 0)
template_layout = QVBoxLayout()
template_layout.setSpacing(6)
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")
template_label = QLabel("提示词模板")
template_label.setObjectName("imageStudioMutedLabel")
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)
self.prompt_edit = QPlainTextEdit()
self.prompt_edit.setObjectName("imageStudioPromptEdit")
self.prompt_edit.setPlaceholderText("输入完整图片生成提示词")
self.prompt_edit.setMinimumHeight(210)
layout.addWidget(self.prompt_edit, 3)
prompt_hint = QLabel("程序原样提交完整提示词,不自动拆分,也不自动追加动作词。")
prompt_hint.setObjectName("imageStudioMutedLabel")
prompt_hint.setWordWrap(True)
layout.addWidget(prompt_hint)
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)
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)
self.billing_label = QLabel("cmhub 托管默认档:扣点以返回结果为准")
self.billing_label.setObjectName("imageStudioBillingLabel")
self.billing_label.setWordWrap(True)
layout.addWidget(self.billing_label)
self.generation_guard_label = QLabel("不会在失败后静默切换生成来源。")
self.generation_guard_label.setObjectName("imageStudioMutedLabel")
self.generation_guard_label.setWordWrap(True)
layout.addWidget(self.generation_guard_label)
action_layout = QHBoxLayout()
self.start_button = QPushButton("生成 4 张主图")
self.start_button.setObjectName("imageStudioStartButton")
self.resume_button = QPushButton("继续查询任务")
self.resume_button.setObjectName("imageStudioResumeButton")
self.stop_button = QPushButton("停止")
self.stop_button.setObjectName("imageStudioStopButton")
self.stop_button.setEnabled(False)
action_layout.addWidget(self.start_button)
action_layout.addWidget(self.resume_button)
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")
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()
final_title = QLabel("终选与排序")
final_title.setObjectName("imageStudioSectionTitle")
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)
self.main_selection_label = QLabel("主图终选 0/9")
self.main_selection_label.setObjectName("imageStudioMainSelectionLabel")
self.main_selection_label.setFixedWidth(64)
self.main_selection_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.main_selection_list = ImageStudioSelectionList("main", self)
self.main_selection_list.setObjectName("imageStudioMainSelectionList")
self.main_selection_list.setIconSize(QSize(48, 48))
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)
self.detail_selection_label = QLabel("详情图终选 0/12")
self.detail_selection_label.setObjectName("imageStudioDetailSelectionLabel")
self.detail_selection_label.setFixedWidth(64)
self.detail_selection_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.detail_selection_list = ImageStudioSelectionList("detail", self)
self.detail_selection_list.setObjectName("imageStudioDetailSelectionList")
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)
action_panel = QWidget()
action_layout = QVBoxLayout(action_panel)
action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.setSpacing(3)
self.export_summary_label = QLabel("当前未选择终选图片")
self.export_summary_label.setObjectName("imageStudioExportSummaryLabel")
self.export_summary_label.setWordWrap(True)
self.export_button = QPushButton("导出到文件夹")
self.export_button.setObjectName("imageStudioExportButton")
self.export_hint_label = QLabel("可部分导出;文件名按终选顺序连续,目标目录存在时可覆盖或新建时间目录。")
self.export_hint_label.setObjectName("imageStudioExportHintLabel")
self.export_hint_label.setWordWrap(True)
action_layout.addWidget(self.export_summary_label)
action_layout.addWidget(self.export_hint_label)
action_layout.addStretch(1)
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)
return panel
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;
}
#imageStudioMainSelectionLabel {
color: #0969da;
font-weight: 600;
}
#imageStudioDetailSelectionLabel {
color: #1a7f37;
font-weight: 600;
}
#imageStudioExportSummaryLabel {
color: #1a7f37;
font-weight: 600;
}
#imageStudioCurrentProjectLabel {
color: #24292f;
font-weight: 600;
}
#imageStudioOriginalDownloadLabel {
color: #0969da;
}
#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;
}
#imageStudioDeleteProjectButton {
color: #b42318;
}
QListWidget#imageStudioOriginalGrid,
QListWidget#imageStudioPoolGrid,
QListWidget#imageStudioMainSelectionList,
QListWidget#imageStudioDetailSelectionList {
border: 1px solid #d8dee4;
border-radius: 5px;
background: #fbfcfe;
gridline-color: #eaeef2;
selection-background-color: #ddf4ff;
selection-color: #24292f;
}
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;
}
"""
)
def _connect_signals(self):
self.pull_images_button.clicked.connect(self.pull_main_images)
self.open_folder_button.clicked.connect(self.open_project_folder)
self.delete_project_button.clicked.connect(self.delete_current_project)
self.project_table.itemSelectionChanged.connect(self._on_project_selection_changed)
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)
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)
self.job_type_combo.currentIndexChanged.connect(self._update_generation_action_text)
self.count_spin.valueChanged.connect(self._update_generation_action_text)
self.start_button.clicked.connect(self.start_generation)
self.resume_button.clicked.connect(self.resume_generation_jobs)
self.stop_button.clicked.connect(self.stop_generation)
self.export_button.clicked.connect(self.export_selections)
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()
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:
self._select_project(self.current_project.id, quiet=True)
elif self.projects:
self._select_project(self.projects[0].id)
else:
self._clear_current_project()
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)
self.project_table.setRowHeight(row, 44)
self.project_table.resizeColumnsToContents()
def pull_main_images(self, checked=False):
alias = str(self.account_combo.currentData() or "").strip()
item_id = self.item_id_edit.text().strip()
if not alias or not item_id:
self._message("信息未填写完整", "请先选择店铺并输入商品ID,再拉取蝦皮主图。")
return
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
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)
self._start_worker(worker, "AI工场拉取蝦皮主图")
self._append_log("[AI工场] 拉取蝦皮主图开始")
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("")
def _on_pull_finished(self, summary):
if self._handle_finished_error(summary, "拉取蝦皮主图失败"):
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:
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
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")
def delete_current_project(self, checked=False):
project = self.current_project
if project is None:
self._message("未选择商品", "请先从商品列表选择要删除的项目。")
return
if self._has_original_download_activity(project.id):
self._message(
"暂不能删除项目",
"该商品还有蝦皮原主图正在下载或等待下载,请等待完成后再删除。",
)
return
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")
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
previous_project_id = getattr(self.current_project, "id", None)
if previous_project_id != project.id:
self._reset_thumbnail_view(project.id)
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)
self._refresh_project_summary()
if not quiet:
self._status(f"当前AI工场商品:{_project_display_name(project)}", "muted")
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)
self._refresh_project_summary()
self._fill_original_grid()
self._fill_pool_grid()
self._refresh_selection_labels()
self._refresh_source_label()
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()
for row, asset in enumerate(originals):
order = asset.source_order or row + 1
item = QListWidgetItem(f"主图 {order}\n{self._original_card_state(asset)}")
item.setData(Qt.UserRole, {"type": "asset", "asset_id": int(asset.id)})
item.setIcon(
_asset_icon(
asset,
"原",
size=QSize(48, 48),
cached_pixmap=self._thumbnail_pixmaps.get(int(asset.id)),
)
)
item.setSizeHint(QSize(58, 78))
tooltip = "单击下载并加入照片池,双击查看大图。缩略图仅用于预览,不保存原图。"
if asset.status == image_studio.ASSET_STATUS_MISSING and str(asset.local_path or "").strip():
tooltip += "\n该图片不在当前蝦皮主图列表中,本地历史文件仍已保留。"
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
item.setText(f"主图 {order}\n{self._original_card_state(asset)}")
self._refresh_original_download_label()
def _fill_pool_grid(self):
rows = []
for asset in self.assets:
if asset.kind not in {"original", "generated_main", "generated_detail"} or not _asset_is_usable(asset):
continue
rows.append(("asset", asset))
for job in self.jobs:
if job.status in {"pending", "submitted", "running", "failed", "expired", "cancelled"}:
rows.append(("job", job))
self.pool_grid.clear()
for row_type, obj in rows:
if row_type == "asset":
draggable = _asset_is_usable(obj)
data = {"type": "asset", "asset_id": int(obj.id), "draggable": draggable}
text = f"{_asset_badge(obj.kind)} #{obj.id}\n{obj.aspect_ratio or '比例未知'}"
tooltip = "单击设为源图,双击查看大图,可拖入终选槽。"
else:
data = {"type": "job", "job_id": int(obj.id)}
text = f"任务\n{_job_status_text(obj, self.JOB_STATUS_LABELS)}"
tooltip = "生图任务已保存,可继续查询。"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, data)
item.setSizeHint(QSize(108, 124))
item.setToolTip(tooltip)
if row_type == "asset":
item.setIcon(_asset_icon(obj, _asset_badge(obj.kind), size=QSize(86, 86)))
if obj.id == self.selected_source_asset_id:
item.setBackground(QColor("#eaf2ff"))
else:
item.setIcon(_job_icon(obj.status))
if obj.status in {"failed", "expired"}:
item.setForeground(_qcolor(COLOR_DANGER))
elif obj.status in {"pending", "submitted", "running"}:
item.setForeground(_qcolor(COLOR_WARNING))
self.pool_grid.addItem(item)
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)
def _on_original_clicked(self, item):
asset = self._asset_from_grid_item(item)
if asset is not None:
self._ensure_original_in_pool(asset, open_after=False)
def _on_original_double_clicked(self, item):
asset = self._asset_from_grid_item(item)
if asset is not None:
self._ensure_original_in_pool(asset, open_after=True)
def _on_pool_clicked(self, item):
data = self._grid_data(item)
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)
def _on_pool_double_clicked(self, item):
data = self._grid_data(item)
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)
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)
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
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:
return
self.refresh_project_assets()
asset = summary.get("asset")
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")
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()
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("源图:未选择")
self.source_preview_label.setPixmap(QPixmap())
self.source_preview_label.setText("未选择源图")
return
self.source_label.setText(f"源图:{_asset_badge(asset.kind)} #{asset.id}")
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:
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()
return
self.current_project_label.setText(_project_display_name(self.current_project))
self.current_project_label.setToolTip(f"账号别名:{self.current_project.account_alias}")
self.autosave_label.setText("项目已自动保存")
self.workspace_empty_label.setText(
f"当前商品:{_project_display_name(self.current_project)}。按顺序拉取蝦皮主图、选择源图、生成并拖入终选。"
)
self._update_project_action_buttons()
def _clear_current_project(self):
self._reset_thumbnail_view(None)
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)
self._fill_original_grid()
self._fill_pool_grid()
self._refresh_selection_labels()
self._refresh_source_label()
self._refresh_project_summary()
def _update_project_action_buttons(self):
enabled = self.current_project is not None and not self._operation_running
self.open_folder_button.setEnabled(enabled)
self.delete_project_button.setEnabled(
enabled
and not self._has_original_download_activity(
getattr(self.current_project, "id", None)
)
)
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
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}")
def _open_preview(self, asset):
dialog = ImageStudioPreviewDialog(asset, self)
dialog.exec()
def _show_pool_context_menu(self, position):
item = self.pool_grid.itemAt(position)
data = self._grid_data(item)
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)
action = menu.exec(self.pool_grid.viewport().mapToGlobal(position))
if action is remove_action and not referenced:
self.remove_asset(asset_id)
def remove_asset(self, asset_id):
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):
if self.current_project is None:
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
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()
self._append_log(f"[AI工场] 本轮生图开始:{count} 张,使用 {self._cmhub_image_model_summary()}")
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)
self._start_worker(worker, "AI工场生成图片")
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")
def resume_generation_jobs(self, checked=False):
if self.current_project is None:
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
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)
self._start_worker(worker, "AI工场继续查询")
def export_selections(self, checked=False):
if self.current_project is None:
self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。")
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)
self._start_worker(worker, "AI工场导出终选")
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):
if self._handle_finished_error(summary, "导出终选失败"):
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))
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:
text = f"{self._cmhub_image_model_summary()};余额 {payload.get('points_balance')}"
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):
if self._handle_finished_error(summary, "AI工场生成失败"):
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):
if self._worker_error_handled:
return
self._worker_error_handled = True
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()
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
def _start_worker(self, worker, thread_name):
thread = run_worker(worker, thread_name=thread_name, start=False)
thread.finished.connect(lambda: self._forget_running_thread(thread))
self._running_worker = worker
self._running_thread = thread
self._worker_error_handled = False
self._set_running(True)
thread.start()
def _finish_worker(self):
self._set_running(False)
def _forget_running_thread(self, thread):
if self._running_thread is thread:
self._running_thread = None
self._running_worker = None
def _set_running(self, running):
self._operation_running = bool(running)
self.pull_images_button.setEnabled(not running)
self.project_table.setEnabled(not running)
self.original_grid.setEnabled(not running)
self.pool_grid.setEnabled(not running)
self.main_selection_list.setEnabled(not running)
self.detail_selection_list.setEnabled(not running)
self.template_combo.setEnabled(not running)
self.template_new_button.setEnabled(not running)
self.template_rename_button.setEnabled(not running)
self.template_save_button.setEnabled(not running)
self.template_delete_button.setEnabled(not running)
self.prompt_edit.setEnabled(not running)
self.job_type_combo.setEnabled(not running)
self.count_spin.setEnabled(not running)
self.aspect_combo.setEnabled(not running)
self.start_button.setEnabled(not running)
self.resume_button.setEnabled(not running)
self.stop_button.setEnabled(running)
self.export_button.setEnabled(not running)
self._update_project_action_buttons()
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
self.main_selection_label.setText(f"主图终选 {main_count}/{main_target}")
self.detail_selection_label.setText(f"详情图终选 {detail_count}/{detail_target}")
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("当前未选择终选图片")
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()
selected = [item for item in self.selections if item.selection_type == selection_type]
for index, selection in enumerate(selected, start=1):
asset = self._asset_by_id(selection.asset_id)
if asset is None:
text = f"{index}\n缺失"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, int(selection.asset_id))
item.setToolTip("终选引用的照片记录不存在")
item.setForeground(_qcolor(COLOR_DANGER))
item.setSizeHint(widget.gridSize())
widget.addItem(item)
continue
text = str(index)
item = QListWidgetItem(text)
item.setIcon(_asset_icon(asset, str(index), size=widget.iconSize()))
item.setData(Qt.UserRole, int(asset.id))
item.setSizeHint(widget.gridSize())
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)
target = self._selection_target_count(selection_type)
for index in range(len(selected) + 1, target + 1):
item = QListWidgetItem("")
item.setIcon(QIcon(_placeholder_pixmap(str(index), widget.iconSize(), "#f6f8fa")))
item.setData(Qt.UserRole, None)
item.setSizeHint(widget.gridSize())
item.setToolTip(f"{_selection_label(selection_type)}空位,可从照片池拖入图片。")
item.setForeground(_qcolor(COLOR_MUTED))
item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsDropEnabled)
widget.addItem(item)
def _asset_from_grid_item(self, item):
data = self._grid_data(item)
if not data or data.get("type") != "asset":
return None
return self._asset_by_id(data.get("asset_id"))
def _grid_data(self, item):
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())
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)
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)
def closeEvent(self, event):
self._original_downloads_closing = True
self._cancel_original_downloads()
try:
self._thumbnail_loader.close()
except Exception:
pass
super().closeEvent(event)
def _asset_badge(kind):
return {
"original": "原图",
"generated_main": "主图",
"generated_detail": "详情图",
}.get(str(kind or ""), str(kind or "图片"))
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'}"
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 "待生成"
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))
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,
)
)
def _job_icon(status):
label = {
"pending": "排",
"submitted": "提",
"running": "生",
"failed": "败",
"expired": "过",
"cancelled": "停",
}.get(str(status or ""), "任")
color = {
"failed": "#ffebe9",
"expired": "#ffebe9",
"cancelled": "#f6f8fa",
"running": "#fff8c5",
"submitted": "#fff8c5",
"pending": "#f6f8fa",
}.get(str(status or ""), "#f6f8fa")
return QIcon(_placeholder_pixmap(label, QSize(86, 86), color))
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
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
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}"
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 '未知'}"
)
def _job_status_text(job, labels):
parts = [labels.get(job.status, job.status)]
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)