feat(ai-studio): streamline final selection rails

This commit is contained in:
chengma
2026-07-11 17:46:58 +08:00
parent dd88d12cf4
commit c46fe0e543
3 changed files with 161 additions and 37 deletions
+92 -31
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import os
from PySide6.QtCore import QObject, QMimeData, QSize, Signal
from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem
from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem, QSizePolicy
from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, image_studio_images, prompts
from .. import file_manager
@@ -136,6 +136,16 @@ class ImageStudioSelectionList(QListWidget):
self.setDragDropMode(QAbstractItemView.DragDrop)
self.setSelectionMode(QAbstractItemView.SingleSelection)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.setViewMode(QListView.IconMode)
self.setFlow(QListView.LeftToRight)
self.setWrapping(False)
self.setResizeMode(QListView.Fixed)
self.setMovement(QListView.Static)
self.setSpacing(4)
self.setUniformItemSizes(True)
self.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.customContextMenuRequested.connect(self._show_context_menu)
def mimeData(self, items):
@@ -332,7 +342,8 @@ class ImageStudioTab(QWidget):
self.main_splitter.setStretchFactor(2, 2)
self.main_splitter.setSizes([150, 640, 360])
root.addWidget(self.main_splitter, 1)
root.addWidget(self._build_final_panel(), 0)
self.final_panel = self._build_final_panel()
root.addWidget(self.final_panel, 0)
self._apply_workspace_style()
self._update_generation_action_text()
@@ -579,51 +590,68 @@ class ImageStudioTab(QWidget):
def _build_final_panel(self):
panel = QWidget()
panel.setObjectName("imageStudioFinalPanel")
layout = QHBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
main_panel = QWidget()
main_layout = QVBoxLayout(main_panel)
main_layout.setContentsMargins(0, 0, 0, 0)
panel.setMinimumHeight(150)
panel.setMaximumHeight(170)
panel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
layout = QGridLayout(panel)
layout.setContentsMargins(10, 8, 10, 8)
layout.setHorizontalSpacing(8)
layout.setVerticalSpacing(4)
header_layout = QHBoxLayout()
final_title = QLabel("终选与排序")
final_title.setObjectName("imageStudioSectionTitle")
main_layout.addWidget(final_title)
header_hint = QLabel("从照片池拖入,可在同一行拖动排序,双击查看大图")
header_hint.setObjectName("imageStudioMutedLabel")
header_layout.addWidget(final_title)
header_layout.addWidget(header_hint)
header_layout.addStretch(1)
layout.addLayout(header_layout, 0, 0, 1, 2)
self.main_selection_label = QLabel("主图终选 0/9")
self.main_selection_label.setObjectName("imageStudioMainSelectionLabel")
self.main_selection_label.setFixedWidth(64)
self.main_selection_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.main_selection_list = ImageStudioSelectionList("main", self)
self.main_selection_list.setObjectName("imageStudioMainSelectionList")
self.main_selection_list.setIconSize(QSize(48, 48))
self.main_selection_list.setMinimumHeight(96)
main_layout.addWidget(self.main_selection_label)
main_layout.addWidget(self.main_selection_list)
self.main_selection_list.setGridSize(QSize(60, 62))
self.main_selection_list.setFixedHeight(66)
layout.addWidget(self.main_selection_label, 1, 0)
layout.addWidget(self.main_selection_list, 1, 1)
detail_panel = QWidget()
detail_layout = QVBoxLayout(detail_panel)
detail_layout.setContentsMargins(0, 0, 0, 0)
detail_layout.addWidget(QLabel(" "))
self.detail_selection_label = QLabel("详情图终选 0/12")
self.detail_selection_label.setObjectName("imageStudioDetailSelectionLabel")
self.detail_selection_label.setFixedWidth(64)
self.detail_selection_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.detail_selection_list = ImageStudioSelectionList("detail", self)
self.detail_selection_list.setObjectName("imageStudioDetailSelectionList")
self.detail_selection_list.setIconSize(QSize(42, 42))
self.detail_selection_list.setMinimumHeight(96)
detail_layout.addWidget(self.detail_selection_label)
detail_layout.addWidget(self.detail_selection_list)
self.detail_selection_list.setIconSize(QSize(38, 38))
self.detail_selection_list.setGridSize(QSize(50, 52))
self.detail_selection_list.setFixedHeight(56)
layout.addWidget(self.detail_selection_label, 2, 0)
layout.addWidget(self.detail_selection_list, 2, 1)
action_panel = QWidget()
action_layout = QVBoxLayout(action_panel)
action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.addWidget(QLabel(" "))
action_layout.setSpacing(3)
self.export_summary_label = QLabel("当前未选择终选图片")
self.export_summary_label.setObjectName("imageStudioExportSummaryLabel")
self.export_summary_label.setWordWrap(True)
self.export_button = QPushButton("导出到文件夹")
self.export_button.setObjectName("imageStudioExportButton")
self.export_hint_label = QLabel("可部分导出;文件名按终选顺序连续,目标目录存在时可覆盖或新建时间目录。")
self.export_hint_label.setObjectName("imageStudioExportHintLabel")
self.export_hint_label.setWordWrap(True)
action_layout.addWidget(self.export_button)
action_layout.addWidget(self.export_summary_label)
action_layout.addWidget(self.export_hint_label)
action_layout.addStretch(1)
layout.addWidget(main_panel, 1)
layout.addWidget(detail_panel, 1)
layout.addWidget(action_panel, 0)
action_layout.addWidget(self.export_button)
action_panel.setMinimumWidth(248)
action_panel.setMaximumWidth(280)
layout.addWidget(action_panel, 1, 2, 2, 1)
layout.setColumnStretch(1, 1)
return panel
def _apply_workspace_style(self):
@@ -652,6 +680,18 @@ class ImageStudioTab(QWidget):
#imageStudioExportHintLabel {
color: #6e7781;
}
#imageStudioMainSelectionLabel {
color: #0969da;
font-weight: 600;
}
#imageStudioDetailSelectionLabel {
color: #1a7f37;
font-weight: 600;
}
#imageStudioExportSummaryLabel {
color: #1a7f37;
font-weight: 600;
}
#imageStudioCurrentProjectLabel {
color: #24292f;
font-weight: 600;
@@ -686,6 +726,17 @@ class ImageStudioTab(QWidget):
selection-background-color: #ddf4ff;
selection-color: #24292f;
}
QListWidget#imageStudioMainSelectionList::item,
QListWidget#imageStudioDetailSelectionList::item {
border: 1px dashed #bfc8d3;
border-radius: 4px;
padding: 0;
}
QListWidget#imageStudioMainSelectionList::item:selected,
QListWidget#imageStudioDetailSelectionList::item:selected {
border: 2px solid #0969da;
background: #eaf2ff;
}
"""
)
@@ -1979,6 +2030,13 @@ class ImageStudioTab(QWidget):
detail_target = getattr(self.current_project, "target_detail_count", 12) if self.current_project else 12
self.main_selection_label.setText(f"主图终选 {main_count}/{main_target}")
self.detail_selection_label.setText(f"详情图终选 {detail_count}/{detail_target}")
total_count = main_count + detail_count
if total_count:
self.export_summary_label.setText(
f"当前已选 {total_count} 张:主图 {main_count}/{main_target} · 详情 {detail_count}/{detail_target}"
)
else:
self.export_summary_label.setText("当前未选择终选图片")
self._fill_selection_list(self.main_selection_list, "main")
self._fill_selection_list(self.detail_selection_list, "detail")
@@ -1988,17 +2046,19 @@ class ImageStudioTab(QWidget):
for index, selection in enumerate(selected, start=1):
asset = self._asset_by_id(selection.asset_id)
if asset is None:
text = f"{index}. 缺失照片 #{selection.asset_id}"
text = f"{index}\n缺失"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, int(selection.asset_id))
item.setToolTip("终选引用的照片记录不存在")
item.setForeground(_qcolor(COLOR_DANGER))
item.setSizeHint(widget.gridSize())
widget.addItem(item)
continue
text = f"{index}. {_asset_badge(asset.kind)} #{asset.id} · {asset.aspect_ratio or '未知比例'}"
text = str(index)
item = QListWidgetItem(text)
item.setIcon(_asset_icon(asset, str(index)))
item.setIcon(_asset_icon(asset, str(index), size=widget.iconSize()))
item.setData(Qt.UserRole, int(asset.id))
item.setSizeHint(widget.gridSize())
tooltip = _selection_tooltip(selection_type, asset)
item.setToolTip(tooltip)
if _selection_ratio_warning(selection_type, asset):
@@ -2010,12 +2070,13 @@ class ImageStudioTab(QWidget):
widget.addItem(item)
target = self._selection_target_count(selection_type)
for index in range(len(selected) + 1, target + 1):
item = QListWidgetItem(f"{index}. 空位")
item.setIcon(QIcon(_placeholder_pixmap("空", QSize(48, 48), "#f6f8fa")))
item = QListWidgetItem("")
item.setIcon(QIcon(_placeholder_pixmap(str(index), widget.iconSize(), "#f6f8fa")))
item.setData(Qt.UserRole, None)
item.setSizeHint(widget.gridSize())
item.setToolTip(f"{_selection_label(selection_type)}空位,可从照片池拖入图片。")
item.setForeground(_qcolor(COLOR_MUTED))
item.setFlags(Qt.NoItemFlags)
item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsDropEnabled)
widget.addItem(item)
def _asset_from_grid_item(self, item):
+5 -2
View File
@@ -3,7 +3,7 @@ id: T-609
title: AI工场终选区改为主图与详情图双单行轨道
phase: 7
deps: [T-608]
status: TODO
status: DONE
created: 2026-07-11
---
@@ -84,4 +84,7 @@ git diff --check
## 执行记录
- 待执行。
- 2026-07-11:将 AI工场终选区改为标题行 + 主图单行轨道 + 详情图单行轨道;右侧导出状态和导出按钮跨两条轨道,终选区高度限制为 150~170px,释放的高度由上方工作区获得。
- 2026-07-11:终选列表明确使用图标模式、从左到右、不换行、固定 grid 和独立横向滚动;主图 9 槽使用 48px 缩略图,详情图 12 槽使用 38px 缩略图。空位保留可落入语义并以虚线槽稳定显示。
- 2026-07-11:保留拖入、插入顺延、同轨重排、Delete 移出、双击预览、SQLite 顺序持久化和导出逻辑;新增动态终选计数提示与 GUI 布局回归测试。
- 2026-07-11:人工截图检查 1280×820 下双轨道、导出区与照片池无重叠。干净 worktree 验证通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(387 项)、`git diff --check`。主工作区另有未提交默认提示词文件变更,未纳入本任务修改。
+64 -4
View File
@@ -17,9 +17,18 @@ 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, QSize
from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect, QSize, Qt
from PySide6.QtGui import QImage, QKeyEvent, QTextCursor
from PySide6.QtWidgets import QApplication, QComboBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QLineEdit,
QListView,
QPlainTextEdit,
QProgressBar,
QTableView,
)
from app.gui import (
AccountDialog,
@@ -813,7 +822,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
if tab.main_selection_list.item(row).data(gui.Qt.UserRole) is not None
])
self.assertEqual(9, tab.main_selection_list.count())
self.assertIn("空位", tab.main_selection_list.item(2).text())
self.assertIn("空位", tab.main_selection_list.item(2).toolTip())
self.assertEqual("#fff8c5", tab.main_selection_list.item(0).background().color().name())
self.assertFalse(tab.add_asset_to_selection("main", first.id))
@@ -822,7 +831,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
detail = image_studio.list_selections(project.id, "detail", path=cfg["db_path"])
self.assertEqual([first.id], [selection.asset_id for selection in detail])
self.assertEqual(12, tab.detail_selection_list.count())
self.assertIn("空位", tab.detail_selection_list.item(1).text())
self.assertIn("空位", tab.detail_selection_list.item(1).toolTip())
self.assertTrue(tab.move_selection_asset("main", 1, 0))
main = image_studio.list_selections(project.id, "main", path=cfg["db_path"])
@@ -834,6 +843,57 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_image_studio_final_selection_uses_compact_single_row_rails(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"],
)
tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"])
self.addCleanup(tab.close)
tab._select_project(project.id)
tab.resize(1280, 820)
tab.show()
self.app.processEvents()
self.assertGreaterEqual(tab.final_panel.minimumHeight(), 150)
self.assertLessEqual(tab.final_panel.maximumHeight(), 170)
self.assertLess(tab.main_selection_list.y(), tab.detail_selection_list.y())
self.assertEqual(QListView.IconMode, tab.main_selection_list.viewMode())
self.assertEqual(QListView.IconMode, tab.detail_selection_list.viewMode())
self.assertEqual(QListView.LeftToRight, tab.main_selection_list.flow())
self.assertEqual(QListView.LeftToRight, tab.detail_selection_list.flow())
self.assertFalse(tab.main_selection_list.isWrapping())
self.assertFalse(tab.detail_selection_list.isWrapping())
self.assertEqual(QSize(60, 62), tab.main_selection_list.gridSize())
self.assertEqual(QSize(50, 52), tab.detail_selection_list.gridSize())
self.assertEqual(9, tab.main_selection_list.count())
self.assertEqual(12, tab.detail_selection_list.count())
self.assertEqual(
QAbstractItemView.ScrollPerPixel,
tab.main_selection_list.horizontalScrollMode(),
)
self.assertEqual(Qt.ScrollBarAlwaysOff, tab.detail_selection_list.verticalScrollBarPolicy())
self.assertEqual(Qt.ScrollBarAsNeeded, tab.detail_selection_list.horizontalScrollBarPolicy())
self.assertTrue(tab.export_button.isEnabled())
self.assertIn("当前未选择终选图片", tab.export_summary_label.text())
tab.detail_selection_list.setFixedWidth(240)
self.app.processEvents()
self.assertGreater(tab.detail_selection_list.horizontalScrollBar().maximum(), 0)
tab._set_running(True)
self.assertFalse(tab.main_selection_list.isEnabled())
self.assertFalse(tab.detail_selection_list.isEnabled())
self.assertFalse(tab.export_button.isEnabled())
tab._set_running(False)
self.assert_removed(temp_dir)
def test_image_studio_tab_shows_resume_and_job_billing_status(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)