From e4292f8a0e2c51fbe39b0e9ca7d28c2b35f59515 Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 11 Jul 2026 16:47:18 +0800 Subject: [PATCH] feat(ai-studio): add thumbnail workspace grids --- app/gui/tabs/image_studio.py | 458 +++++++++++++++++++++++------------ docs/tasks/T-607.md | 34 ++- tests/test_gui.py | 143 ++++++++++- 3 files changed, 467 insertions(+), 168 deletions(-) diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index f1b1302..cc256e4 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -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.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 = QListWidgetItem(f"主图 {order}\n{self._original_thumbnail_state(asset)}") + item.setData(Qt.UserRole, {"type": "asset", "asset_id": int(asset.id)}) + 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 "")) - 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: + 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")) - 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) diff --git a/docs/tasks/T-607.md b/docs/tasks/T-607.md index 954503e..c36c12b 100644 --- a/docs/tasks/T-607.md +++ b/docs/tasks/T-607.md @@ -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` diff --git a/tests/test_gui.py b/tests/test_gui.py index 8bb00f4..ffbd1e8 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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])