feat(ai-studio): add final selection trays

This commit is contained in:
chengma
2026-07-11 14:20:56 +08:00
parent 5451579073
commit 5754e4831a
4 changed files with 369 additions and 14 deletions
+298 -9
View File
@@ -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 '未知'}"
)
+6 -3
View File
@@ -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 接入,不自动上传或修改蝦皮。
## 流程导航
+7 -2
View File
@@ -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,已通过。
+58
View File
@@ -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 = []