diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index 1d7785a..4af6ee9 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -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): diff --git a/docs/tasks/T-609.md b/docs/tasks/T-609.md index a9fc363..5f9bb33 100644 --- a/docs/tasks/T-609.md +++ b/docs/tasks/T-609.md @@ -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`。主工作区另有未提交默认提示词文件变更,未纳入本任务修改。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 0f0e757..cc64e92 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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)