diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index fb3a85c..f8146a0 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -4,6 +4,9 @@ from __future__ import annotations import os +from PySide6.QtCore import QMimeData +from PySide6.QtWidgets import QListWidget, QListWidgetItem + from ... import accounts, appconfig, db, image_studio, prompts from .. import file_manager from ..widgets import * @@ -14,6 +17,9 @@ from ..workers import ImageStudioGenerateJobsWorker as _RealImageStudioGenerateJ from ..workers import ImageStudioPullImagesWorker as _RealImageStudioPullImagesWorker +ASSET_MIME_TYPE = "application/x-cmshopee-image-studio-asset" + + def ImageStudioPullImagesWorker(*args, **kwargs): return _call_package_attr( "ImageStudioPullImagesWorker", @@ -41,6 +47,132 @@ def ImageStudioGenerateJobsWorker(*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 ImageStudioPoolTable(QTableWidget): + """Photo pool table that can drag available asset IDs into final trays.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setDragEnabled(True) + self.setDragDropMode(QAbstractItemView.DragOnly) + self.setDefaultDropAction(Qt.CopyAction) + + def mimeData(self, items): + mime = QMimeData() + if 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 {} + 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.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) + 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) + if item is not None and int(item.data(Qt.UserRole)) == int(asset_id): + return row + return None + + class ImageStudioPreviewDialog(QDialog): """Simple large image preview used by original and pool tables.""" @@ -220,7 +352,8 @@ class ImageStudioTab(QWidget): pool_header.addWidget(self.source_label) layout.addLayout(pool_header) - self.pool_table = QTableWidget(0, len(self.POOL_COLUMNS)) + 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) @@ -314,13 +447,30 @@ class ImageStudioTab(QWidget): panel.setObjectName("imageStudioFinalPanel") layout = QHBoxLayout(panel) layout.setContentsMargins(0, 0, 0, 0) - self.main_selection_label = QLabel("主图终选 0/9(拖放排序将在 T-592 接入)") + layout.setSpacing(10) + main_panel = QWidget() + main_layout = QVBoxLayout(main_panel) + main_layout.setContentsMargins(0, 0, 0, 0) + self.main_selection_label = QLabel("主图终选 0/9") self.main_selection_label.setObjectName("imageStudioMainSelectionLabel") - self.detail_selection_label = QLabel("详情图终选 0/12(拖放排序将在 T-592 接入)") + self.main_selection_list = ImageStudioSelectionList("main", self) + self.main_selection_list.setObjectName("imageStudioMainSelectionList") + self.main_selection_list.setMinimumHeight(96) + main_layout.addWidget(self.main_selection_label) + main_layout.addWidget(self.main_selection_list) + + detail_panel = QWidget() + detail_layout = QVBoxLayout(detail_panel) + detail_layout.setContentsMargins(0, 0, 0, 0) + self.detail_selection_label = QLabel("详情图终选 0/12") self.detail_selection_label.setObjectName("imageStudioDetailSelectionLabel") - layout.addWidget(self.main_selection_label) - layout.addWidget(self.detail_selection_label) - layout.addStretch(1) + self.detail_selection_list = ImageStudioSelectionList("detail", self) + self.detail_selection_list.setObjectName("imageStudioDetailSelectionList") + self.detail_selection_list.setMinimumHeight(96) + detail_layout.addWidget(self.detail_selection_label) + detail_layout.addWidget(self.detail_selection_list) + layout.addWidget(main_panel, 1) + layout.addWidget(detail_panel, 1) return panel def _connect_signals(self): @@ -544,6 +694,7 @@ class ImageStudioTab(QWidget): self.pool_table.setRowCount(len(rows)) for row, (row_type, obj) in enumerate(rows): if row_type == "asset": + draggable = _asset_is_usable(obj) values = [ _asset_badge(obj.kind), obj.aspect_ratio or "未知", @@ -551,7 +702,7 @@ class ImageStudioTab(QWidget): _source_text(obj, self.assets), obj.local_path or "", ] - data = {"type": "asset", "asset_id": int(obj.id)} + data = {"type": "asset", "asset_id": int(obj.id), "draggable": draggable} else: values = [ "任务", @@ -567,6 +718,77 @@ class ImageStudioTab(QWidget): self.pool_table.setItem(row, column, item) self.pool_table.resizeColumnsToContents() + 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, row, column): asset = self._asset_from_table_row(self.original_table, row) if asset is not None: @@ -861,6 +1083,8 @@ class ImageStudioTab(QWidget): self.project_table.setEnabled(not running) self.original_table.setEnabled(not running) self.pool_table.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) @@ -898,8 +1122,38 @@ class ImageStudioTab(QWidget): 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}(拖放排序将在 T-592 接入)") - self.detail_selection_label.setText(f"详情图终选 {detail_count}/{detail_target}(拖放排序将在 T-592 接入)") + self.main_selection_label.setText(f"主图终选 {main_count}/{main_target}") + self.detail_selection_label.setText(f"详情图终选 {detail_count}/{detail_target}") + 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() + for index, selection in enumerate( + [item for item in self.selections if item.selection_type == selection_type], + start=1, + ): + asset = self._asset_by_id(selection.asset_id) + if asset is None: + text = f"{index}. 缺失照片 #{selection.asset_id}" + item = QListWidgetItem(text) + item.setData(Qt.UserRole, int(selection.asset_id)) + item.setToolTip("终选引用的照片记录不存在") + item.setForeground(_qcolor(COLOR_DANGER)) + widget.addItem(item) + continue + text = f"{index}. {_asset_badge(asset.kind)} #{asset.id} · {asset.aspect_ratio or '未知比例'}" + item = QListWidgetItem(text) + item.setData(Qt.UserRole, int(asset.id)) + 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) def _asset_from_table_row(self, table, row): data = self._row_data(table, row) @@ -974,6 +1228,15 @@ def _asset_status_text(asset): 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 _source_text(asset, assets): parent_id = getattr(asset, "parent_asset_id", None) if not parent_id: @@ -982,3 +1245,29 @@ def _source_text(asset, 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 '未知'}" + ) diff --git a/docs/routes.md b/docs/routes.md index 0c67fdb..bbb2045 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -186,7 +186,7 @@ │ [完整提示词输入框] │ │ 类型[主图▼] 数量[4] 比例[1:1▼] cmhub扣点/余额提示 │ │ [开始生成][停止] 进度条 运行日志 │ -│ 底部:主图终选 / 详情图终选占位(T-592 接拖放排序) │ +│ 底部:主图终选 / 详情图终选(拖入、插入、重排、移出) │ └───────────────────────────────────────────────────────────────┘ ``` @@ -196,8 +196,11 @@ - 照片池展示原图、生成主图、生成详情图和在途/失败任务状态;单击可用图片设为源图,双击打开大图;右键移除只删除未被任务或终选引用的照片池记录,不删除本地图片文件。 - 右侧只有一个完整提示词框;模板目录固定为 `data/prompts/image_studio/`,与②标题/封面模板隔离。界面不显示“主提示词 / 每张动作词”。 - 生图固定走 cmhub 托管模型,使用⑤设置里的 cmhub Base URL/API Key/生图别名和图片并发;界面只显示扣点、余额、进度、失败,不展示自定义 Provider、API Key、生成来源选择或“导入本地图片”入口。 -- 拉主图、下载原图、生图 submit/poll/download 均通过 worker 执行,主线程只刷新 UI;运行中禁用项目切换、模板编辑、源图选择和生成设置,停止为协作式停止。 -- 本小节只覆盖 T-591 第一版:终选拖放排序由 T-592 接入,导出 JPEG 由 T-593 接入,故底部终选盘当前是只读占位。 +- 底部终选盘分为主图和详情图两列;照片池中已下载/已生成且本地文件可用的图片可拖入终选,落到已有位置时按插入顺延,同一类别内同一照片只能出现一次,主图和详情图之间允许复用同一照片。 +- 终选列表内可拖动重排,Delete 或右键「移出终选」只移出终选,不删除照片池资产或本地文件;拖放/移出失败时刷新回 SQLite 中的持久化顺序。 +- 主图推荐 1:1;比例不匹配只用黄色轻提示和 tooltip 提醒,不硬拦。文件缺失或尚未下载的照片不能拖入终选。 +- 拉主图、下载原图、生图 submit/poll/download 均通过 worker 执行,主线程只刷新 UI;运行中禁用项目切换、模板编辑、源图选择、终选拖放和生成设置,停止为协作式停止。 +- 本小节当前覆盖 T-591/T-592:导出 JPEG 由 T-593 接入,不自动上传或修改蝦皮。 ## 流程导航 diff --git a/docs/tasks/T-592.md b/docs/tasks/T-592.md index 6a91420..cc93e50 100644 --- a/docs/tasks/T-592.md +++ b/docs/tasks/T-592.md @@ -3,7 +3,7 @@ id: T-592 title: AI工场主图/详情图终选拖放、插入排序与比例轻提示 phase: 7 deps: [T-591] -status: TODO +status: DONE created: 2026-07-11 --- @@ -36,4 +36,9 @@ created: 2026-07-11 ## 执行记录 -(完成后记录拖放模型、事务和 GUI 测试。) +- 2026-07-11:完成 AI工场主图/详情图终选盘。 + - `ImageStudioTab` 底部终选占位升级为主图/详情图两个可拖放列表;照片池可用图片提供 asset_id 拖拽,终选列表支持外部拖入、落点插入顺延、列表内重排、Delete/右键移出。 + - 写库统一走 `image_studio.replace_selections()`;同类别重复拖入、满额、文件未下载/缺失会中文提示并拒绝;主图/详情图跨类别复用允许。 + - 主图非 1:1 比例显示黄色轻提示和 tooltip,不阻止加入;运行中禁用终选列表,避免生成/下载时状态并发修改。 + - 更新 `docs/routes.md` 的 ⑥ AI工场底部终选说明,明确 T-593 才做导出。 + - 验证:当前主工作区仍有无关默认提示词脏文件会影响全量 unittest;已在干净 worktree 仅套用 T-592 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`(363 tests)和 `git diff --check`,全部通过。主工作区也运行了 T-592 相关 targeted unittest,已通过。 diff --git a/tests/test_gui.py b/tests/test_gui.py index ef82623..06929f9 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -542,6 +542,64 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_image_studio_final_selection_order_and_guards(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"], + ) + first_path = self.write_test_image(os.path.join(temp_dir, "first.jpg")) + second_path = self.write_test_image(os.path.join(temp_dir, "second.jpg")) + first = image_studio.add_asset( + project.id, + "generated_main", + local_path=first_path, + aspect_ratio="1:1", + path=cfg["db_path"], + ) + second = image_studio.add_asset( + project.id, + "generated_main", + local_path=second_path, + aspect_ratio="3:4", + path=cfg["db_path"], + ) + tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) + self.addCleanup(tab.close) + messages = [] + tab._message = lambda title, text: messages.append((title, text)) + tab._select_project(project.id) + + self.assertTrue(tab.add_asset_to_selection("main", first.id)) + self.assertTrue(tab.add_asset_to_selection("main", second.id, insert_index=0)) + main = image_studio.list_selections(project.id, "main", path=cfg["db_path"]) + self.assertEqual([second.id, first.id], [selection.asset_id for selection in main]) + self.assertEqual([second.id, first.id], [ + tab.main_selection_list.item(row).data(gui.Qt.UserRole) + for row in range(tab.main_selection_list.count()) + ]) + self.assertEqual("#fff8c5", tab.main_selection_list.item(0).background().color().name()) + + self.assertFalse(tab.add_asset_to_selection("main", first.id)) + self.assertIn("不能重复加入", messages[-1][0]) + self.assertTrue(tab.add_asset_to_selection("detail", first.id)) + detail = image_studio.list_selections(project.id, "detail", path=cfg["db_path"]) + self.assertEqual([first.id], [selection.asset_id for selection in detail]) + + self.assertTrue(tab.move_selection_asset("main", 1, 0)) + main = image_studio.list_selections(project.id, "main", path=cfg["db_path"]) + self.assertEqual([first.id, second.id], [selection.asset_id for selection in main]) + + self.assertTrue(tab.remove_asset_from_selection("main", first.id)) + main = image_studio.list_selections(project.id, "main", path=cfg["db_path"]) + self.assertEqual([second.id], [selection.asset_id for selection in main]) + + self.assert_removed(temp_dir) + def test_startup_update_gate_forced_blocks_and_opens_download(self): boxes = []