feat(ai-studio): add thumbnail workspace grids

This commit is contained in:
chengma
2026-07-11 16:47:18 +08:00
parent 82e7e1bf14
commit e4292f8a0e
3 changed files with 467 additions and 168 deletions
+304 -150
View File
@@ -4,10 +4,10 @@ from __future__ import annotations
import os
from PySide6.QtCore import QMimeData, QSize
from PySide6.QtWidgets import QListWidget, QListWidgetItem
from PySide6.QtCore import QObject, QMimeData, QSize, Signal
from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem
from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, prompts
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 (
@@ -83,24 +83,38 @@ def _mime_asset_id(mime_data):
return None
class ImageStudioPoolTable(QTableWidget):
"""Photo pool table that can drag available asset IDs into final trays."""
class _ThumbnailSignals(QObject):
"""Deliver executor callbacks to the GUI thread without exposing remote URLs."""
def __init__(self, parent=None):
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.setDragEnabled(True)
self.setDragDropMode(QAbstractItemView.DragOnly)
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 items:
if not self._draggable_assets or not items:
return mime
row = items[0].row()
item = self.item(row, 0)
if item is None:
return mime
data = item.data(Qt.UserRole) or {}
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"))
@@ -239,8 +253,6 @@ class ImageStudioTab(QWidget):
"""Sixth tab: project-based AI image studio."""
PROJECT_COLUMNS = ["店铺", "商品ID", "更新时间"]
ORIGINAL_COLUMNS = ["序号", "状态", "远程地址"]
POOL_COLUMNS = ["类型", "比例", "状态", "来源", "本地文件"]
JOB_STATUS_LABELS = {
"pending": "排队中",
"submitted": "已提交",
@@ -259,6 +271,7 @@ class ImageStudioTab(QWidget):
config_path=None,
status_callback=None,
prompts_dir=None,
thumbnail_loader=None,
):
super().__init__(parent)
self.setObjectName("imageStudioTab")
@@ -280,6 +293,14 @@ class ImageStudioTab(QWidget):
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._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()
@@ -397,17 +418,14 @@ class ImageStudioTab(QWidget):
original_header.addWidget(self.original_hint_label)
layout.addLayout(original_header)
self.original_table = QTableWidget(0, len(self.ORIGINAL_COLUMNS))
self.original_table.setObjectName("imageStudioOriginalTable")
self.original_table.setHorizontalHeaderLabels(self.ORIGINAL_COLUMNS)
self.original_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.original_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.original_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.original_table.horizontalHeader().setStretchLastSection(True)
self.original_table.verticalHeader().setVisible(False)
self.original_table.setIconSize(QSize(72, 72))
self.original_table.setAlternatingRowColors(False)
layout.addWidget(self.original_table, 1)
self.original_grid = ImageStudioThumbnailGrid(parent=self)
self.original_grid.setObjectName("imageStudioOriginalGrid")
self.original_grid.setIconSize(QSize(62, 62))
self.original_grid.setGridSize(QSize(78, 92))
self.original_grid.setMinimumHeight(174)
self.original_grid.setMaximumHeight(202)
self.original_grid.setContextMenuPolicy(Qt.CustomContextMenu)
layout.addWidget(self.original_grid, 0)
pool_header = QHBoxLayout()
pool_title = QLabel("照片池")
@@ -419,19 +437,12 @@ class ImageStudioTab(QWidget):
pool_header.addWidget(self.source_label)
layout.addLayout(pool_header)
self.pool_table = ImageStudioPoolTable()
self.pool_table.setColumnCount(len(self.POOL_COLUMNS))
self.pool_table.setObjectName("imageStudioPoolTable")
self.pool_table.setHorizontalHeaderLabels(self.POOL_COLUMNS)
self.pool_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.pool_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.pool_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.pool_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.pool_table.horizontalHeader().setStretchLastSection(True)
self.pool_table.verticalHeader().setVisible(False)
self.pool_table.setIconSize(QSize(86, 86))
self.pool_table.setAlternatingRowColors(False)
layout.addWidget(self.pool_table, 2)
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):
@@ -450,14 +461,11 @@ class ImageStudioTab(QWidget):
self.source_preview_label.setObjectName("imageStudioSourcePreview")
self.source_preview_label.setAlignment(Qt.AlignCenter)
self.source_preview_label.setMinimumSize(86, 86)
source_note = QLabel("当前源图\n单击照片池图片设为源图,双击查看大图")
source_note.setObjectName("imageStudioMutedLabel")
source_note.setWordWrap(True)
source_preview_tooltip = "当前源图。请在照片池单击选择源图,双击可查看大图。"
self.source_preview_label.setToolTip(source_preview_tooltip)
source_row.addWidget(self.source_preview_label, 0)
source_row.addWidget(source_note, 1)
layout.addLayout(source_row)
template_layout = QGridLayout()
template_layout = QVBoxLayout()
template_layout.setSpacing(6)
self.template_combo = QComboBox()
self.template_combo.setObjectName("imageStudioTemplateCombo")
@@ -471,25 +479,32 @@ class ImageStudioTab(QWidget):
self.template_delete_button.setObjectName("imageStudioTemplateDeleteButton")
template_label = QLabel("提示词模板")
template_label.setObjectName("imageStudioMutedLabel")
template_layout.addWidget(template_label, 0, 0, 1, 3)
template_layout.addWidget(self.template_combo, 1, 0, 1, 3)
template_layout.addWidget(self.template_new_button, 2, 0)
template_layout.addWidget(self.template_rename_button, 2, 1)
template_layout.addWidget(self.template_save_button, 2, 2)
template_layout.addWidget(self.template_delete_button, 3, 2)
layout.addLayout(template_layout)
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(160)
layout.addWidget(self.prompt_edit, 2)
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)
form = QFormLayout()
self.job_type_combo = QComboBox()
self.job_type_combo.setObjectName("imageStudioJobTypeCombo")
self.job_type_combo.addItem("主图", "main")
@@ -502,10 +517,18 @@ class ImageStudioTab(QWidget):
self.aspect_combo.setObjectName("imageStudioAspectCombo")
for value in ("1:1", "3:4", "4:3", "9:16", "16:9"):
self.aspect_combo.addItem(value, value)
form.addRow("类型", self.job_type_combo)
form.addRow("数量", self.count_spin)
form.addRow("比例", self.aspect_combo)
layout.addLayout(form)
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")
@@ -638,8 +661,8 @@ class ImageStudioTab(QWidget):
#imageStudioDeleteProjectButton {
color: #b42318;
}
QTableWidget#imageStudioOriginalTable,
QTableWidget#imageStudioPoolTable,
QListWidget#imageStudioOriginalGrid,
QListWidget#imageStudioPoolGrid,
QListWidget#imageStudioMainSelectionList,
QListWidget#imageStudioDetailSelectionList {
border: 1px solid #d8dee4;
@@ -657,11 +680,12 @@ class ImageStudioTab(QWidget):
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_table.cellClicked.connect(self._on_original_clicked)
self.original_table.cellDoubleClicked.connect(self._on_original_double_clicked)
self.pool_table.cellClicked.connect(self._on_pool_clicked)
self.pool_table.cellDoubleClicked.connect(self._on_pool_double_clicked)
self.pool_table.customContextMenuRequested.connect(self._show_pool_context_menu)
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)
@@ -821,6 +845,9 @@ class ImageStudioTab(QWidget):
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)
@@ -854,88 +881,77 @@ class ImageStudioTab(QWidget):
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_table()
self._fill_pool_table()
self._fill_original_grid()
self._fill_pool_grid()
self._refresh_selection_labels()
self._refresh_source_label()
def _fill_original_table(self):
originals = [asset for asset in self.assets if asset.kind == image_studio.ASSET_KIND_ORIGINAL]
self.original_table.setRowCount(len(originals))
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
values = [
f"主图 #{order}",
_asset_status_text(asset),
asset.remote_url or "",
]
for column, value in enumerate(values):
item = QTableWidgetItem(str(value or ""))
item = QListWidgetItem(f"主图 {order}\n{self._original_thumbnail_state(asset)}")
item.setData(Qt.UserRole, {"type": "asset", "asset_id": int(asset.id)})
if column == 0:
item.setIcon(_asset_icon(asset, "原"))
item.setToolTip("单击下载并加入照片池,双击查看大图")
self.original_table.setItem(row, column, item)
self.original_table.setRowHeight(row, 84)
if originals:
self.original_table.setColumnWidth(0, 132)
self.original_table.setColumnWidth(1, 96)
self.original_table.resizeColumnsToContents()
item.setIcon(
_asset_icon(
asset,
"原",
size=QSize(62, 62),
cached_pixmap=self._thumbnail_pixmaps.get(int(asset.id)),
)
)
item.setSizeHint(QSize(78, 92))
tooltip = "单击下载并加入照片池,双击查看大图。缩略图仅用于预览,不保存原图。"
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_thumbnail_state(asset)}")
def _fill_pool_table(self):
def _fill_pool_grid(self):
rows = []
for asset in self.assets:
if asset.status == image_studio.ASSET_STATUS_MISSING:
continue
if asset.kind not in {"original", "generated_main", "generated_detail"}:
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_table.setRowCount(len(rows))
for row, (row_type, obj) in enumerate(rows):
self.pool_grid.clear()
for row_type, obj in rows:
if row_type == "asset":
draggable = _asset_is_usable(obj)
values = [
f"{_asset_badge(obj.kind)} #{obj.id}",
obj.aspect_ratio or "未知",
_asset_status_text(obj),
_source_text(obj, self.assets),
obj.local_path or "",
]
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:
values = [
"任务",
"-",
_job_status_text(obj, self.JOB_STATUS_LABELS),
f"源图 #{obj.source_asset_id or '-'}",
obj.error or "",
]
data = {"type": "job", "job_id": int(obj.id)}
for column, value in enumerate(values):
item = QTableWidgetItem(str(value or ""))
text = f"任务\n{_job_status_text(obj, self.JOB_STATUS_LABELS)}"
tooltip = "生图任务已保存,可继续查询。"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, data)
if row_type == "asset" and column == 0:
item.setIcon(_asset_icon(obj, _asset_badge(obj.kind)))
item.setToolTip("单击设为源图,双击查看大图,可拖入终选槽。")
if row_type == "job" and column == 0:
item.setIcon(_job_icon(obj.status))
item.setToolTip("cmhub 生图任务已保存,可继续查询。")
if row_type == "asset" and obj.id == self.selected_source_asset_id:
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"))
if row_type == "job" and obj.status in {"failed", "expired"}:
else:
item.setIcon(_job_icon(obj.status))
if obj.status in {"failed", "expired"}:
item.setForeground(_qcolor(COLOR_DANGER))
elif row_type == "job" and obj.status in {"pending", "submitted", "running"}:
elif obj.status in {"pending", "submitted", "running"}:
item.setForeground(_qcolor(COLOR_WARNING))
self.pool_table.setItem(row, column, item)
self.pool_table.setRowHeight(row, 104)
if rows:
self.pool_table.setColumnWidth(0, 150)
self.pool_table.setColumnWidth(1, 68)
self.pool_table.setColumnWidth(2, 170)
self.pool_table.resizeColumnsToContents()
self.pool_grid.addItem(item)
def _selection_asset_ids(self, selection_type):
return [
@@ -1008,32 +1024,43 @@ class ImageStudioTab(QWidget):
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, row, column):
asset = self._asset_from_table_row(self.original_table, row)
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, row, column):
asset = self._asset_from_table_row(self.original_table, row)
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, row, column):
data = self._row_data(self.pool_table, row)
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, row, column):
data = self._row_data(self.pool_table, row)
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)
@@ -1097,6 +1124,7 @@ class ImageStudioTab(QWidget):
self._update_project_action_buttons()
def _clear_current_project(self):
self._reset_thumbnail_view(None)
self.current_project = None
self.assets = []
self.jobs = []
@@ -1108,8 +1136,8 @@ class ImageStudioTab(QWidget):
self.prompt_edit.clear()
finally:
self.prompt_edit.blockSignals(False)
self._fill_original_table()
self._fill_pool_table()
self._fill_original_grid()
self._fill_pool_grid()
self._refresh_selection_labels()
self._refresh_source_label()
self._refresh_project_summary()
@@ -1119,6 +1147,110 @@ class ImageStudioTab(QWidget):
self.open_folder_button.setEnabled(enabled)
self.delete_project_button.setEnabled(enabled)
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 "图片"
@@ -1129,8 +1261,8 @@ class ImageStudioTab(QWidget):
dialog.exec()
def _show_pool_context_menu(self, position):
row = self.pool_table.rowAt(position.y())
data = self._row_data(self.pool_table, row)
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")
@@ -1144,7 +1276,7 @@ class ImageStudioTab(QWidget):
"移除照片" if not referenced else "移除照片(已被任务或终选引用)"
)
remove_action.setEnabled(not referenced)
action = menu.exec(self.pool_table.viewport().mapToGlobal(position))
action = menu.exec(self.pool_grid.viewport().mapToGlobal(position))
if action is remove_action and not referenced:
self.remove_asset(asset_id)
@@ -1472,8 +1604,8 @@ class ImageStudioTab(QWidget):
self._operation_running = bool(running)
self.pull_images_button.setEnabled(not running)
self.project_table.setEnabled(not running)
self.original_table.setEnabled(not running)
self.pool_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)
@@ -1557,16 +1689,13 @@ class ImageStudioTab(QWidget):
item.setFlags(Qt.NoItemFlags)
widget.addItem(item)
def _asset_from_table_row(self, table, row):
data = self._row_data(table, row)
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 _row_data(self, table, row):
if row < 0 or row >= table.rowCount():
return None
item = table.item(row, 0)
def _grid_data(self, item):
if item is None:
return None
return item.data(Qt.UserRole)
@@ -1616,6 +1745,13 @@ class ImageStudioTab(QWidget):
def _status(self, message, level="muted"):
_emit_status(self.status_callback, message, level=level)
def closeEvent(self, event):
try:
self._thumbnail_loader.close()
except Exception:
pass
super().closeEvent(event)
def _asset_badge(kind):
return {
@@ -1653,8 +1789,15 @@ def _asset_is_usable(asset):
return bool(local_path and os.path.isfile(local_path))
def _asset_icon(asset, fallback_label):
return QIcon(_asset_pixmap(asset, QSize(86, 86), fallback_label=fallback_label))
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):
@@ -1677,7 +1820,18 @@ def _job_icon(status):
return QIcon(_placeholder_pixmap(label, QSize(86, 86), color))
def _asset_pixmap(asset, size, fallback_label=None):
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)
+31 -3
View File
@@ -1,9 +1,9 @@
---
id: T-607
title: AI工场原主图与照片池缩略图网格优化
title: AI工场图片网格与生成设置布局优化
phase: 7
deps: [T-606]
status: TODO
status: DONE
created: 2026-07-11
---
@@ -52,6 +52,13 @@ T-605 已为 AI工场补了缩略图图标和状态,但「蝦皮原主图」
- 预览缓存按当前 tab 生命周期或当前项目管理,项目切换/关闭时释放不再引用的 `QPixmap`,避免长时间使用导致内存无界增长。
- 禁止使用嵌套卡片制造视觉层级;原图区/照片池是面板,单张图片才是卡片。
### 5. 收紧生成设置布局
- 去掉源图预览框右侧的说明文字;未选择源图时由预览框空状态和 tooltip 表达,避免右栏出现重复说明。
- 源图预览框右侧第一行放「提示词模板」标签和模板下拉框;第二行把「新建」「重命名」「保存」「删除」四个按钮等宽并排。
- 释放出的垂直空间分配给完整提示词输入框;提示词说明保留在输入框下方。
- 「类型」「数量」「比例」改为提示词输入框下方的同一行紧凑控件组,保留现有下拉/数字输入语义和生成按钮动态文案。
## 验收要点
- 拉取有效商品后,原图区按原图顺序展示最多 9 张真实缩略图;缩略图加载不冻结 GUI。
@@ -62,6 +69,7 @@ T-605 已为 AI工场补了缩略图图标和状态,但「蝦皮原主图」
- 照片池的选源图、双击预览、右键移除、拖入终选、终选排序、生成、停止、继续查询和导出均不回归。
- 项目切换、删除后切换到其他项目、后台任务完成后,不显示旧项目缩略图或错误的源图高亮。
- 不改 CDP 拉图/Chrome/登录流程,不因缩略图预览批量保存九张完整原图,不暴露远程 URL 或敏感信息到用户日志。
- 生成设置区不再出现源图右侧说明文字;模板选择与四个模板操作按钮按两行排列,提示词输入框面积增大,类型/数量/比例在同一行。
## 测试要求
@@ -100,4 +108,24 @@ git diff --check
## 执行记录
- 待执行。
- 2026-07-11:已完成。
- 代码:
- `app/gui/tabs/image_studio.py`:原图区和照片池由字段表格改为固定尺寸的 `QListWidget` 图片网格。原图区按 `source_order` 展示主图;照片池只展示已落盘可用的原图/生成图和未完成或失败任务卡片,不再重复显示只有远程 URL 的原图。
- `app/gui/tabs/image_studio.py`:接入既有 `image_studio_images.ThumbnailLoader`。远程缩略图使用受限后台线程池和内存缓存,结果通过 Qt signal 回到 GUI 线程;不保存完整原图、不在日志中输出远程 URL。单张失败只显示中文卡片状态,并可右键「重新加载缩略图」。
- `app/gui/tabs/image_studio.py`:项目切换、软删除和窗口关闭会取消不再需要的缩略图回调并释放 GUI 缓存;原图区点击下载、照片池选源图/预览/右键移除/拖入终选、任务续查均保留。
- `app/gui/tabs/image_studio.py`:生成设置去掉源图右侧说明文字;模板标签与下拉在源图右侧第一行,四个模板操作按钮同列第二行;提示词输入框扩展,类型/数量/比例调整为其下方同一行。
- 测试:
- `tests/test_gui.py`:更新 AI工场构建测试,覆盖网格替换、缩略图仅内存预览、原图落盘后才进入照片池、单张缩略图失败重试、9 张原图顺序、固定网格尺寸、模板按钮同排和三项生成参数同排。
- `tests/test_gui.py`:更新已提交 cmhub 任务卡片断言,确认照片池保留可继续查询的任务状态和扣点信息。
- `tests/test_image_studio_images.py`:既有缩略图下载、内存缓存、取消和不落盘原图测试继续通过。
- 验证:
- 主工作区定向验证通过:
- `py -3.10 -m unittest tests.test_gui.GuiTests.test_image_studio_tab_builds_project_pool_and_template_controls tests.test_gui.GuiTests.test_image_studio_thumbnail_grids_keep_order_and_hide_remote_pool_duplicates tests.test_gui.GuiTests.test_image_studio_tab_shows_resume_and_job_billing_status tests.test_gui.GuiTests.test_image_studio_final_selection_order_and_guards tests.test_image_studio_images`
- `python -m ruff check app tests main.py`
- `py -3.10 -m compileall app main.py`
- `git diff --check -- app/gui/tabs/image_studio.py tests/test_gui.py docs/tasks/T-607.md`
- 主工作区存在未提交默认提示词文件改动;为隔离无关改动,在临时干净 worktree `D:\chengma\cmshopee-t607-verify` 应用本任务 diff 后通过:
- `python -m ruff check app tests main.py`
- `py -3.10 -m compileall app main.py`
- `py -3.10 -m unittest discover -s tests`(381 tests)
- `git diff --check`
+130 -13
View File
@@ -17,7 +17,7 @@ from app import accounts, ai, appconfig, db, image_paths, image_studio, prompts,
if gui.QT_IMPORT_ERROR is not None:
raise unittest.SkipTest("PySide6 未安装")
from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect
from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect, QSize
from PySide6.QtGui import QImage, QKeyEvent, QTextCursor
from PySide6.QtWidgets import QApplication, QComboBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
@@ -85,6 +85,31 @@ class FakeGenerateWorker:
FakeGenerateWorker.instances.append(self)
class FakeThumbnailLoader:
def __init__(self):
self.submissions = []
self.cancelled = []
self.closed = False
def submit(self, key, url, on_success=None, on_error=None):
self.submissions.append((key, url, on_success, on_error))
return None
def cancel(self, key=None):
self.cancelled.append(key)
def close(self):
self.closed = True
def succeed(self, index, image_bytes):
_, _, on_success, _ = self.submissions[index]
on_success(SimpleNamespace(image_bytes=image_bytes))
def fail(self, index):
key, _, _, on_error = self.submissions[index]
on_error(key, RuntimeError("缩略图请求失败"))
class GuiTests(TempDirMixin, unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -496,11 +521,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
prompts_dir = os.path.join(temp_dir, "prompts", "image_studio")
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
prompts.save_image_studio_template("工场模板", "完整提示词", prompts_dir)
thumbnail_loader = FakeThumbnailLoader()
tab = ImageStudioTab(
config=cfg,
db_path=cfg["db_path"],
prompts_dir=prompts_dir,
thumbnail_loader=thumbnail_loader,
)
self.addCleanup(tab.close)
@@ -541,6 +568,36 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.template_combo.setCurrentIndex(template_index)
tab.load_selected_template()
self.assertEqual("完整提示词", tab.prompt_edit.toPlainText())
tab.resize(1280, 820)
tab.show()
self.app.processEvents()
self.assertLessEqual(
max(
button.y()
for button in (
tab.template_new_button,
tab.template_rename_button,
tab.template_save_button,
tab.template_delete_button,
)
)
- min(
button.y()
for button in (
tab.template_new_button,
tab.template_rename_button,
tab.template_save_button,
tab.template_delete_button,
)
),
1,
)
self.assertLessEqual(
max(tab.job_type_combo.y(), tab.count_spin.y(), tab.aspect_combo.y())
- min(tab.job_type_combo.y(), tab.count_spin.y(), tab.aspect_combo.y()),
1,
)
self.assertNotIn("当前源图\n单击照片池图片设为源图,双击查看大图", label_texts)
project = image_studio.create_or_get_project(
account,
@@ -570,15 +627,75 @@ class GuiTests(TempDirMixin, unittest.TestCase):
)[0]
tab.refresh_project_assets()
self.assertEqual(1, tab.original_table.rowCount())
self.assertEqual("主图 #1", tab.original_table.item(0, 0).text())
self.assertFalse(tab.original_table.item(0, 0).icon().isNull())
self.assertEqual("远程待下载", tab.original_table.item(0, 1).text())
self.assertEqual(1, tab.pool_table.rowCount())
self.assertEqual(f"原图 #{original.id}", tab.pool_table.item(0, 0).text())
self.assertFalse(tab.pool_table.item(0, 0).icon().isNull())
self.assertEqual("远程待下载", tab.pool_table.item(0, 2).text())
self.assertEqual(original.id, tab.pool_table.item(0, 0).data(gui.Qt.UserRole)["asset_id"])
self.assertEqual(1, tab.original_grid.count())
self.assertIn("主图 1", tab.original_grid.item(0).text())
self.assertIn("加载中", tab.original_grid.item(0).text())
self.assertFalse(tab.original_grid.item(0).icon().isNull())
self.assertEqual(1, len(thumbnail_loader.submissions))
self.assertEqual(0, tab.pool_grid.count())
thumbnail_loader.fail(0)
self.app.processEvents()
self.assertIn("加载失败", tab.original_grid.item(0).text())
self.assertIn("右键重新加载", tab.original_grid.item(0).toolTip())
tab._retry_original_thumbnail(original)
self.assertEqual(2, len(thumbnail_loader.submissions))
self.assertIn("加载中", tab.original_grid.item(0).text())
thumbnail_path = self.write_test_image(os.path.join(temp_dir, "thumbnail.png"))
with open(thumbnail_path, "rb") as image_file:
thumbnail_loader.succeed(1, image_file.read())
self.app.processEvents()
self.assertIn("可预览", tab.original_grid.item(0).text())
self.assertIsNone(image_studio.get_asset(original.id, path=cfg["db_path"]).local_path)
local_original_path = self.write_test_image(os.path.join(temp_dir, "original.png"))
image_studio.update_asset_local_path(original.id, local_original_path, path=cfg["db_path"])
tab.refresh_project_assets()
self.assertEqual(1, tab.pool_grid.count())
pool_item = tab.pool_grid.item(0)
self.assertIn("原图", pool_item.text())
self.assertEqual(original.id, pool_item.data(gui.Qt.UserRole)["asset_id"])
self.assert_removed(temp_dir)
def test_image_studio_thumbnail_grids_keep_order_and_hide_remote_pool_duplicates(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
db.init_db(cfg["db_path"])
project = image_studio.create_or_get_project(
account_alias="alias-a",
account_slug="alias_a",
item_id="51100639510",
path=cfg["db_path"],
)
image_studio.sync_original_asset_urls(
project.id,
[
{"index": index, "src": f"https://susercontent.com/main-{index}.jpg"}
for index in range(1, 10)
],
path=cfg["db_path"],
)
thumbnail_loader = FakeThumbnailLoader()
tab = ImageStudioTab(
config=cfg,
db_path=cfg["db_path"],
thumbnail_loader=thumbnail_loader,
)
self.addCleanup(tab.close)
tab._select_project(project.id)
self.assertEqual(9, tab.original_grid.count())
self.assertEqual(
[f"主图 {index}" for index in range(1, 10)],
[tab.original_grid.item(row).text().split("\n", 1)[0] for row in range(9)],
)
self.assertEqual(9, len(thumbnail_loader.submissions))
self.assertEqual(0, tab.pool_grid.count())
self.assertEqual(QSize(78, 92), tab.original_grid.gridSize())
self.assertEqual(QSize(108, 124), tab.pool_grid.gridSize())
self.assertGreaterEqual(tab.prompt_edit.minimumHeight(), 210)
self.assert_removed(temp_dir)
@@ -749,9 +866,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("继续查询任务", tab.resume_button.text())
statuses = [
tab.pool_table.item(row, 2).text()
for row in range(tab.pool_table.rowCount())
if tab.pool_table.item(row, 0).text() == "任务"
tab.pool_grid.item(row).text()
for row in range(tab.pool_grid.count())
if tab.pool_grid.item(row).data(gui.Qt.UserRole)["type"] == "job"
]
self.assertEqual(1, len(statuses))
self.assertIn("已提交", statuses[0])