From c80c6e67e9a005d2d4a64bab6d3c6e377f86f06e Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 16 Jul 2026 09:22:33 +0800 Subject: [PATCH] feat(product-suite): add independent category rows --- app/gui/tabs/product_suite.py | 225 ++++++++++++++++++++------------ docs/tasks/T-634.md | 7 +- tests/test_product_suite_gui.py | 157 +++++++++++++++++++++- 3 files changed, 303 insertions(+), 86 deletions(-) diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index fe1b4e2..170b769 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -57,6 +57,11 @@ ORIGINAL_DOWNLOAD_CONCURRENCY = 2 ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1 _PRODUCT_SUITE_THREAD_REFS = {} _URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE) +FIXED_CATEGORY_HELPERS = { + "白底图": "白底主图,多角度呈现商品细节", + "场景图": "生活化场景展示商品使用方式", + "卖点图": "突出核心卖点和差异化优势", +} def _asset_usable(asset): @@ -392,12 +397,116 @@ class ProductOriginalList(QListWidget): super().leaveEvent(event) -class CategoryChipButton(QPushButton): - doubleClicked = Signal() +class SuiteCategoryRow(QFrame): + countChangeRequested = Signal(str, int) + renameRequested = Signal(str) + deleteRequested = Signal(str) - def mouseDoubleClickEvent(self, event): - self.doubleClicked.emit() - event.accept() + def __init__(self, name, count, *, helper="", custom=False, parent=None): + super().__init__(parent) + self.category_name = str(name) + self._controls_enabled = True + self.setObjectName("suiteCategoryRow") + self.setProperty("categoryName", self.category_name) + self.setStyleSheet( + "QFrame#suiteCategoryRow { border: none; border-bottom: 1px solid #eaeef2; }" + ) + + layout = QGridLayout(self) + layout.setContentsMargins(6, 5, 4, 6) + layout.setHorizontalSpacing(8) + layout.setVerticalSpacing(2) + layout.setColumnStretch(0, 1) + + self.name_label = QLabel(self.category_name) + self.name_label.setObjectName("suiteCategoryNameLabel") + self.name_label.setStyleSheet("font-weight: 600; color: #24292f;") + layout.addWidget(self.name_label, 0, 0) + + controls_widget = QWidget() + controls = QHBoxLayout(controls_widget) + controls.setContentsMargins(0, 0, 0, 0) + controls.setSpacing(4) + + self.minus_button = QToolButton() + self.minus_button.setObjectName("suiteCategoryMinusButton") + self.minus_button.setText("−") + self.minus_button.setToolTip("减少%s数量" % self.category_name) + self.minus_button.setAccessibleName("减少%s数量" % self.category_name) + self.minus_button.setMinimumSize(28, 28) + self.minus_button.clicked.connect( + lambda checked=False: self.countChangeRequested.emit(self.category_name, -1) + ) + controls.addWidget(self.minus_button) + + self.count_label = QLabel() + self.count_label.setObjectName("suiteCategoryCountLabel") + self.count_label.setAlignment(Qt.AlignCenter) + self.count_label.setMinimumWidth(28) + controls.addWidget(self.count_label) + + self.plus_button = QToolButton() + self.plus_button.setObjectName("suiteCategoryPlusButton") + self.plus_button.setText("+") + self.plus_button.setToolTip("增加%s数量" % self.category_name) + self.plus_button.setAccessibleName("增加%s数量" % self.category_name) + self.plus_button.setMinimumSize(28, 28) + self.plus_button.clicked.connect( + lambda checked=False: self.countChangeRequested.emit(self.category_name, 1) + ) + controls.addWidget(self.plus_button) + + self.rename_button = None + self.delete_button = None + if custom: + self.rename_button = QToolButton() + self.rename_button.setObjectName("suiteCategoryRenameButton") + self.rename_button.setText("改名") + self.rename_button.setToolTip("重命名%s分类" % self.category_name) + self.rename_button.setAccessibleName("重命名%s分类" % self.category_name) + self.rename_button.setMinimumSize(48, 28) + self.rename_button.clicked.connect( + lambda checked=False: self.renameRequested.emit(self.category_name) + ) + controls.addWidget(self.rename_button) + + self.delete_button = QToolButton() + self.delete_button.setObjectName("suiteCategoryDeleteButton") + self.delete_button.setText("×") + self.delete_button.setToolTip("删除%s分类" % self.category_name) + self.delete_button.setAccessibleName("删除%s分类" % self.category_name) + self.delete_button.setMinimumSize(28, 28) + self.delete_button.clicked.connect( + lambda checked=False: self.deleteRequested.emit(self.category_name) + ) + controls.addWidget(self.delete_button) + + layout.addWidget(controls_widget, 0, 1, 2 if helper else 1, 1, Qt.AlignTop) + self.helper_label = None + if helper: + self.helper_label = QLabel(str(helper)) + self.helper_label.setObjectName("suiteCategoryHelperLabel") + self.helper_label.setStyleSheet("color: #6b7280;") + self.helper_label.setWordWrap(True) + layout.addWidget(self.helper_label, 1, 0) + self.set_count(count) + + def set_count(self, count): + normalized = max(0, int(count or 0)) + self.count_label.setText(str(normalized)) + self.minus_button.setEnabled(self._controls_enabled and normalized > 0) + + def count(self): + return int(self.count_label.text() or 0) + + def set_controls_enabled(self, enabled): + self._controls_enabled = bool(enabled) + self.minus_button.setEnabled(self._controls_enabled and self.count() > 0) + self.plus_button.setEnabled(self._controls_enabled) + if self.rename_button is not None: + self.rename_button.setEnabled(self._controls_enabled) + if self.delete_button is not None: + self.delete_button.setEnabled(self._controls_enabled) class SuiteResultCard(QFrame): @@ -511,8 +620,6 @@ class SuiteTaskState: ai_prompt_snapshot: str = "" pending_ai_text: str = "" undo_records: list = field(default_factory=list) - active_category: str = "白底图" - def generation_running(self): return self.worker is not None @@ -848,8 +955,10 @@ class ProductSuiteTab(QWidget): layout.addLayout(title_row) self.category_grid = QGridLayout() self.category_grid.setContentsMargins(0, 0, 0, 0) - self.category_grid.setHorizontalSpacing(4) - self.category_grid.setVerticalSpacing(5) + self.category_grid.setHorizontalSpacing(0) + self.category_grid.setVerticalSpacing(3) + self.category_grid.setColumnStretch(0, 1) + self.category_rows = {} layout.addLayout(self.category_grid) self.add_category_button = QPushButton("添加自定义分类") self.add_category_button.setObjectName("suiteAddCategoryButton") @@ -1650,81 +1759,24 @@ class ProductSuiteTab(QWidget): state.settings = settings categories = settings["categories"] ordered = product_suite.category_order(settings) - if state.active_category not in ordered: - state.active_category = ordered[0] + self.category_rows = {} for index, name in enumerate(ordered): - name_button = CategoryChipButton("%s %s" % (name, categories.get(name, 0))) - name_button.setCheckable(True) - name_button.setChecked(name == state.active_category) - name_button.setMinimumHeight(30) - name_button.setStyleSheet( - "QPushButton { text-align: center; padding: 3px 7px; }" - "QPushButton:checked { background: #eaf2ff; border-color: #2f6fed; color: #2459c4; }" + custom = name not in product_suite.FIXED_CATEGORIES + row = SuiteCategoryRow( + name, + categories.get(name, 0), + helper=FIXED_CATEGORY_HELPERS.get(name, ""), + custom=custom, ) - name_button.clicked.connect( - lambda checked=False, category=name: self.select_category(category) - ) - if name not in product_suite.FIXED_CATEGORIES: - name_button.setToolTip("单击展开数量;双击重命名") - name_button.doubleClicked.connect( - lambda old_name=name: self.rename_custom_category(old_name) - ) - else: - name_button.setToolTip("单击展开数量") - self.category_grid.addWidget(name_button, index // 3, index % 3) - - detail_row = (len(ordered) + 2) // 3 - active_name = state.active_category - detail = QFrame() - detail.setObjectName("suiteCategoryCounter") - detail_layout = QHBoxLayout(detail) - detail_layout.setContentsMargins(6, 3, 6, 3) - active_label = QLabel(active_name) - active_label.setStyleSheet("font-weight: 600;") - detail_layout.addWidget(active_label) - detail_layout.addStretch(1) - minus = QToolButton() - minus.setText("−") - minus.setToolTip("减少%s数量" % active_name) - minus.setAccessibleName("减少%s数量" % active_name) - minus.setMinimumSize(28, 28) - minus.clicked.connect( - lambda checked=False, category=active_name: self.change_category_count(category, -1) - ) - detail_layout.addWidget(minus) - count_label = QLabel(str(categories.get(active_name, 0))) - count_label.setAlignment(Qt.AlignCenter) - count_label.setMinimumWidth(28) - detail_layout.addWidget(count_label) - plus = QToolButton() - plus.setText("+") - plus.setToolTip("增加%s数量" % active_name) - plus.setAccessibleName("增加%s数量" % active_name) - plus.setMinimumSize(28, 28) - plus.clicked.connect( - lambda checked=False, category=active_name: self.change_category_count(category, 1) - ) - detail_layout.addWidget(plus) - if active_name not in product_suite.FIXED_CATEGORIES: - remove = QToolButton() - remove.setText("×") - remove.setToolTip("删除%s分类" % active_name) - remove.setAccessibleName("删除%s分类" % active_name) - remove.setMinimumSize(28, 28) - remove.clicked.connect( - lambda checked=False, category=active_name: self.delete_custom_category(category) - ) - detail_layout.addWidget(remove) - self.category_grid.addWidget(detail, detail_row, 0, 1, 3) + row.countChangeRequested.connect(self.change_category_count) + if custom: + row.renameRequested.connect(self.rename_custom_category) + row.deleteRequested.connect(self.delete_custom_category) + row.set_controls_enabled(not state.generation_running()) + self.category_rows[name] = row + self.category_grid.addWidget(row, index, 0) self._refresh_totals(state) - def select_category(self, name): - state = self._displayed_state - if state is None: - return - state.active_category = str(name) - self._rebuild_categories(state) - def change_category_count(self, name, delta): state = self._displayed_state if state is None: @@ -1735,7 +1787,12 @@ class ProductSuiteTab(QWidget): settings["categories"] = categories state.settings = settings self._persist_state(state) - self._rebuild_categories(state) + row = self.category_rows.get(name) if state is self._displayed_state else None + if row is None: + self._rebuild_categories(state) + else: + row.set_count(categories[name]) + self._refresh_totals(state) def add_custom_category(self, checked=False): if self._displayed_state is None: @@ -1761,7 +1818,6 @@ class ProductSuiteTab(QWidget): settings["categories"] = categories settings["custom_category_order"] = list(settings["custom_category_order"]) + [name] state.settings = product_suite.normalize_suite_settings(settings) - state.active_category = name self._persist_state(state) self.custom_category_edit.hide() self.add_category_button.show() @@ -1798,7 +1854,6 @@ class ProductSuiteTab(QWidget): for value in settings["custom_category_order"] ] state.settings = product_suite.normalize_suite_settings(settings) - state.active_category = name self._persist_state(state) self._rebuild_categories(state) @@ -1814,7 +1869,6 @@ class ProductSuiteTab(QWidget): value for value in settings["custom_category_order"] if value != name ] state.settings = product_suite.normalize_suite_settings(settings) - state.active_category = product_suite.FIXED_CATEGORIES[0] self._persist_state(state) self._rebuild_categories(state) @@ -2064,8 +2118,11 @@ class ProductSuiteTab(QWidget): self.per_image_checkbox, self.prompt_edit, self.add_category_button, + self.custom_category_edit, ): widget.setEnabled(not generation_running) + for row in self.category_rows.values(): + row.set_controls_enabled(not generation_running) self.generate_button.setEnabled(True) if generation_running: self.generate_button.setText("停止生成") diff --git a/docs/tasks/T-634.md b/docs/tasks/T-634.md index 1e84c57..67f2eab 100644 --- a/docs/tasks/T-634.md +++ b/docs/tasks/T-634.md @@ -1,7 +1,7 @@ --- id: T-634 title: 商品套图结构配置改为纵向独立计数行 -status: TODO +status: DONE phase: 7 deps: [T-622, T-631] created: 2026-07-14 @@ -95,3 +95,8 @@ git diff --check - 不修改数据库、API、CDP 或其他标签页。 ## 执行记录 +- 2026-07-16:新增 `SuiteCategoryRow`,三个默认分类改为各占一行,准确显示固定用途说明与独立的减少/数量/增加控件;自定义分类同样独占一行并显示改名、删除操作。 +- 2026-07-16:移除 `CategoryChipButton`、`active_category`、`select_category` 和共享详情计数器。普通数量变化只更新目标行、合计和生成按钮;新增、改名、删除时重建分类行,数量下限为 0 且减少按钮在 0 时禁用。 +- 2026-07-16:生成期间统一禁用各分类步进器及自定义分类编辑;保留原有分类顺序、默认 `1/2/2`、项目设置持久化和生成 job 展开语义。 +- 2026-07-16:补充纵向布局、固定说明、独立计数、0 下限、生成中禁用、自定义分类增删改、任务切换和项目重载测试;`1180x760` 离屏截图确认三行说明与控件无重叠,页面继续使用既有外层滚动区。 +- 验证:当前工作区 `tests.test_product_suite_gui` 11 项、`tests.test_product_suite` 4 项通过,ruff、compileall、`git diff --check` 通过;基于 T-610 提交创建隔离 worktree 后,全仓 ruff、compileall、501 项 unittest 和 `git diff --check` 全部通过。 diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 1daec15..8c912d8 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -1,6 +1,7 @@ import os import sys import unittest +from unittest import mock os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") sys.path.insert(0, os.path.dirname(__file__)) @@ -166,7 +167,161 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): tab._commit_custom_category() self.assertTrue(tab.custom_category_edit.isHidden()) self.assertIn("尺寸图", tab._displayed_state.settings["categories"]) - self.assertEqual("尺寸图", tab._displayed_state.active_category) + self.assertEqual(1, tab.category_rows["尺寸图"].count()) + + self.assert_removed(temp_dir) + + def test_category_rows_are_vertical_with_helpers_and_independent_counters(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + tab.resize(1180, 760) + tab.show() + self.app.processEvents() + + expected = { + "白底图": (1, "白底主图,多角度呈现商品细节"), + "场景图": (2, "生活化场景展示商品使用方式"), + "卖点图": (2, "突出核心卖点和差异化优势"), + } + self.assertEqual(list(expected), list(tab.category_rows)) + for row_index, (name, (count, helper)) in enumerate(expected.items()): + row = tab.category_rows[name] + layout_index = tab.category_grid.indexOf(row) + self.assertEqual( + (row_index, 0, 1, 1), + tab.category_grid.getItemPosition(layout_index), + ) + self.assertEqual(name, row.name_label.text()) + self.assertEqual(count, row.count()) + self.assertEqual(helper, row.helper_label.text()) + self.assertIsNone(row.rename_button) + self.assertIsNone(row.delete_button) + + scene_row = tab.category_rows["场景图"] + scene_row.plus_button.click() + self.assertIs(scene_row, tab.category_rows["场景图"]) + self.assertEqual(3, scene_row.count()) + self.assertEqual(1, tab.category_rows["白底图"].count()) + self.assertEqual(2, tab.category_rows["卖点图"].count()) + self.assertEqual("合计 6 张", tab.category_total_label.text()) + self.assertEqual("生成套图(6)", tab.generate_button.text()) + + white_row = tab.category_rows["白底图"] + white_row.minus_button.click() + self.assertEqual(0, white_row.count()) + self.assertFalse(white_row.minus_button.isEnabled()) + white_row.minus_button.click() + self.assertEqual(0, tab._displayed_state.settings["categories"]["白底图"]) + self.assertEqual("合计 5 张", tab.category_total_label.text()) + + tab._displayed_state.worker = object() + tab._apply_running_state(tab._displayed_state) + self.assertTrue( + all( + not row.plus_button.isEnabled() + for row in tab.category_rows.values() + ) + ) + tab._displayed_state.worker = None + tab._apply_running_state(tab._displayed_state) + + self.assert_removed(temp_dir) + + def test_custom_category_row_preserves_count_and_order_when_renamed(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + + fixed_counts = { + name: tab.category_rows[name].count() + for name in ("白底图", "场景图", "卖点图") + } + tab.add_custom_category() + tab.custom_category_edit.setText("尺寸图") + tab._commit_custom_category() + custom_row = tab.category_rows["尺寸图"] + self.assertEqual(1, custom_row.count()) + self.assertIsNone(custom_row.helper_label) + self.assertIsNotNone(custom_row.rename_button) + self.assertIsNotNone(custom_row.delete_button) + + custom_row.plus_button.click() + self.assertEqual(2, custom_row.count()) + with mock.patch( + "app.gui.tabs.product_suite.QInputDialog.getText", + return_value=("规格图", True), + ): + custom_row.rename_button.click() + self.assertNotIn("尺寸图", tab.category_rows) + self.assertEqual(2, tab.category_rows["规格图"].count()) + self.assertEqual( + ["规格图"], + tab._displayed_state.settings["custom_category_order"], + ) + + tab.category_rows["规格图"].delete_button.click() + self.assertNotIn("规格图", tab.category_rows) + self.assertEqual([], tab._displayed_state.settings["custom_category_order"]) + self.assertEqual( + fixed_counts, + { + name: tab.category_rows[name].count() + for name in ("白底图", "场景图", "卖点图") + }, + ) + + self.assert_removed(temp_dir) + + def test_category_counts_restore_after_task_switch_and_project_reload(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + account = accounts.create_account( + "主店", + "alias-a", + debug_port=9222, + config=config, + ) + project = image_studio.create_or_get_project( + account, + item_id="51100639510", + path=config["db_path"], + ) + first = ProductSuiteTab(config=config, db_path=config["db_path"]) + state = first._displayed_state + state.account_alias = "alias-a" + state.item_id = "51100639510" + state.project_id = project.id + first.change_category_count("场景图", 1) + first.add_custom_category() + first.custom_category_edit.setText("细节图") + first._commit_custom_category() + first.category_rows["细节图"].plus_button.click() + + first.add_task(inherit=False) + self.assertEqual(2, first.category_rows["场景图"].count()) + first.task_tabs.setCurrentIndex(0) + self.app.processEvents() + self.assertEqual(3, first.category_rows["场景图"].count()) + self.assertEqual(2, first.category_rows["细节图"].count()) + first.close() + first.deleteLater() + self.app.processEvents() + + second = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(second.close) + reloaded = second._displayed_state + reloaded.account_alias = "alias-a" + reloaded.item_id = "51100639510" + second._bind_project(reloaded, load_existing=True) + self.assertEqual(3, second.category_rows["场景图"].count()) + self.assertEqual(2, second.category_rows["细节图"].count()) + self.assertEqual( + ["细节图"], + reloaded.settings["custom_category_order"], + ) self.assert_removed(temp_dir)