diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index 31c3f48..cce3f66 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -7,7 +7,7 @@ import re import time from dataclasses import dataclass, field -from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QSize, Qt, QTimer, Signal +from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QRect, QSize, Qt, QTimer, Signal from PySide6.QtGui import QColor, QIcon, QImage, QImageReader, QKeySequence, QPainter, QPixmap from PySide6.QtWidgets import ( QApplication, @@ -32,6 +32,9 @@ from PySide6.QtWidgets import ( QScrollArea, QSizePolicy, QSplitter, + QStyle, + QStyledItemDelegate, + QStyleOptionButton, QTabBar, QToolButton, QVBoxLayout, @@ -51,6 +54,7 @@ from ..workers import ( ORIGINAL_DOWNLOAD_CONCURRENCY = 2 +ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1 _PRODUCT_SUITE_THREAD_REFS = {} _URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE) @@ -143,11 +147,48 @@ class ProductSuitePreviewDialog(QDialog): ) +class ProductOriginalDelegate(QStyledItemDelegate): + CHECK_HIT_SIZE = 26 + + @classmethod + def checkbox_hit_rect(cls, item_rect): + return QRect( + item_rect.left() + 2, + item_rect.top() + 2, + cls.CHECK_HIT_SIZE, + cls.CHECK_HIT_SIZE, + ) + + def paint(self, painter, option, index): + super().paint(painter, option, index) + if index.data(Qt.UserRole) is None: + return + checked = index.data(ORIGINAL_CHECK_STATE_ROLE) == Qt.Checked + style = QApplication.style() + width = style.pixelMetric(QStyle.PM_IndicatorWidth) + height = style.pixelMetric(QStyle.PM_IndicatorHeight) + hit_rect = self.checkbox_hit_rect(option.rect) + checkbox = QStyleOptionButton() + checkbox.rect = QRect( + hit_rect.center().x() - width // 2, + hit_rect.center().y() - height // 2, + width, + height, + ) + checkbox.state = QStyle.State_Enabled if option.state & QStyle.State_Enabled else QStyle.State_None + checkbox.state |= QStyle.State_On if checked else QStyle.State_Off + style.drawControl(QStyle.CE_CheckBox, checkbox, painter) + + class ProductOriginalList(QListWidget): + MAX_VISIBLE_ASSETS = 16 + filesDropped = Signal(list) clipboardImage = Signal(bytes) orderChanged = Signal(list) deleteRequested = Signal(int) + deleteAssetsRequested = Signal(list) + checkedAssetsChanged = Signal(list) def __init__(self, parent=None): super().__init__(parent) @@ -159,12 +200,16 @@ class ProductOriginalList(QListWidget): self.setIconSize(QSize(82, 64)) self.setGridSize(QSize(112, 98)) self.setSpacing(4) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setAcceptDrops(True) self.setDragEnabled(True) self.setDropIndicatorShown(True) self.setDragDropMode(QListWidget.InternalMove) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._show_menu) + self.itemChanged.connect(lambda item: self.checkedAssetsChanged.emit(self.checked_asset_ids())) + self.setItemDelegate(ProductOriginalDelegate(self)) self.setMouseTracking(True) self.itemEntered.connect(self._show_hover_remove) self._hovered_asset_id = None @@ -198,6 +243,36 @@ class ProductOriginalList(QListWidget): super().dropEvent(event) self.orderChanged.emit(self.asset_ids()) + def resizeEvent(self, event): + super().resizeEvent(event) + self.update_content_height() + + def mousePressEvent(self, event): + position = event.position().toPoint() + item = self.itemAt(position) + if ( + event.button() == Qt.LeftButton + and item is not None + and item.data(Qt.UserRole) is not None + and ProductOriginalDelegate.checkbox_hit_rect(self.visualItemRect(item)).contains(position) + ): + self._set_item_checked(item, not self._item_checked(item)) + event.accept() + return + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + position = event.position().toPoint() + item = self.itemAt(position) + if ( + item is not None + and item.data(Qt.UserRole) is not None + and ProductOriginalDelegate.checkbox_hit_rect(self.visualItemRect(item)).contains(position) + ): + event.accept() + return + super().mouseDoubleClickEvent(event) + def keyPressEvent(self, event): if event.matches(QKeySequence.Paste): image = QApplication.clipboard().image() @@ -210,10 +285,19 @@ class ProductOriginalList(QListWidget): self.clipboardImage.emit(bytes(payload)) return if event.key() in {Qt.Key_Delete, Qt.Key_Backspace}: + checked_ids = self.checked_asset_ids() + if checked_ids: + self.deleteAssetsRequested.emit(checked_ids) + return item = self.currentItem() if item is not None and item.data(Qt.UserRole) is not None: self.deleteRequested.emit(int(item.data(Qt.UserRole))) return + if event.key() == Qt.Key_Space: + item = self.currentItem() + if item is not None and item.data(Qt.UserRole) is not None: + self._set_item_checked(item, not self._item_checked(item)) + return super().keyPressEvent(event) def asset_ids(self): @@ -223,14 +307,102 @@ class ProductOriginalList(QListWidget): if self.item(row).data(Qt.UserRole) is not None ] + def checked_asset_ids(self): + return [ + int(self.item(row).data(Qt.UserRole)) + for row in range(self.count()) + if self.item(row).data(Qt.UserRole) is not None + and self._item_checked(self.item(row)) + ] + + def set_checked_asset_ids(self, asset_ids): + checked_ids = {int(asset_id) for asset_id in asset_ids or []} + previous = self.blockSignals(True) + try: + for row in range(self.count()): + item = self.item(row) + asset_id = item.data(Qt.UserRole) + if asset_id is not None: + item.setData( + ORIGINAL_CHECK_STATE_ROLE, + Qt.Checked if int(asset_id) in checked_ids else Qt.Unchecked, + ) + finally: + self.blockSignals(previous) + self.viewport().update() + self.checkedAssetsChanged.emit(self.checked_asset_ids()) + + def select_all_assets(self): + self.set_checked_asset_ids(self.asset_ids()) + + def invert_asset_checks(self): + checked = set(self.checked_asset_ids()) + self.set_checked_asset_ids([asset_id for asset_id in self.asset_ids() if asset_id not in checked]) + + def clear_checks(self): + self.set_checked_asset_ids([]) + + def content_column_count(self): + step = max(1, self.gridSize().width() + self.spacing()) + available = max(1, self.viewport().width() - self.spacing()) + return max(1, (available + self.spacing()) // step) + + def content_row_count(self): + count = min(self.MAX_VISIBLE_ASSETS, max(1, self.count())) + columns = self.content_column_count() + return max(1, (count + columns - 1) // columns) + + def update_content_height(self): + rows = self.content_row_count() + target = ( + rows * self.gridSize().height() + + (rows + 1) * self.spacing() + + 2 * self.frameWidth() + ) + if target != self.height(): + self.setFixedHeight(target) + def _show_menu(self, position): item = self.itemAt(position) if item is None or item.data(Qt.UserRole) is None: return menu = QMenu(self) - remove_action = menu.addAction("删除图片") - if menu.exec(self.viewport().mapToGlobal(position)) is remove_action: - self.deleteRequested.emit(int(item.data(Qt.UserRole))) + actions = [] + for label, asset_ids in self.context_delete_options(int(item.data(Qt.UserRole))): + actions.append((menu.addAction(label), asset_ids)) + selected = menu.exec(self.viewport().mapToGlobal(position)) + for action, asset_ids in actions: + if selected is action: + if len(asset_ids) == 1 and asset_ids[0] not in self.checked_asset_ids(): + self.deleteRequested.emit(asset_ids[0]) + else: + self.deleteAssetsRequested.emit(asset_ids) + return + + def context_delete_options(self, clicked_asset_id): + clicked_asset_id = int(clicked_asset_id) + checked_ids = self.checked_asset_ids() + if clicked_asset_id in checked_ids: + if len(checked_ids) > 1: + return [("删除选中的%d张图片…" % len(checked_ids), checked_ids)] + return [("删除选中图片…", checked_ids)] + options = [("删除这张图片…", [clicked_asset_id])] + if checked_ids: + label = ( + "删除选中的%d张图片…" % len(checked_ids) + if len(checked_ids) > 1 + else "删除选中图片…" + ) + options.append((label, checked_ids)) + return options + + @staticmethod + def _item_checked(item): + return item.data(ORIGINAL_CHECK_STATE_ROLE) == Qt.Checked + + @staticmethod + def _set_item_checked(item, checked): + item.setData(ORIGINAL_CHECK_STATE_ROLE, Qt.Checked if checked else Qt.Unchecked) def _show_hover_remove(self, item): value = item.data(Qt.UserRole) @@ -407,6 +579,7 @@ class ProductSuiteTab(QWidget): self._next_key = 1 self._next_serial = 1 self._displayed_state = None + self._original_list_context = None self._loading = False self._result_refresh_pending = False @@ -569,9 +742,26 @@ class ProductSuiteTab(QWidget): self.original_count_label.setStyleSheet("color: #6b7280;") title_row.addWidget(self.original_count_label) title_row.addStretch(1) + self.original_selected_label = QLabel("已选 0 张") + self.original_selected_label.setObjectName("suiteOriginalSelectedLabel") + self.original_selected_label.setStyleSheet("color: #6b7280;") + title_row.addWidget(self.original_selected_label) + self.select_all_originals_button = QToolButton() + self.select_all_originals_button.setObjectName("suiteSelectAllOriginalsButton") + self.select_all_originals_button.setText("全选") + self.select_all_originals_button.setToolTip("选择当前商品的全部原图") + self.select_all_originals_button.setAccessibleName("全选商品原图") + self.select_all_originals_button.setMinimumSize(48, 28) + title_row.addWidget(self.select_all_originals_button) + self.invert_originals_button = QToolButton() + self.invert_originals_button.setObjectName("suiteInvertOriginalsButton") + self.invert_originals_button.setText("反选") + self.invert_originals_button.setToolTip("反转当前商品原图的勾选状态") + self.invert_originals_button.setAccessibleName("反选商品原图") + self.invert_originals_button.setMinimumSize(48, 28) + title_row.addWidget(self.invert_originals_button) layout.addLayout(title_row) self.original_list = ProductOriginalList() - self.original_list.setFixedHeight(210) layout.addWidget(self.original_list) return frame @@ -772,8 +962,16 @@ class ProductSuiteTab(QWidget): self.original_list.clipboardImage.connect(self.import_clipboard_image) self.original_list.orderChanged.connect(self.reorder_originals) self.original_list.deleteRequested.connect(self.delete_original) + self.original_list.deleteAssetsRequested.connect(self.delete_originals) + self.original_list.checkedAssetsChanged.connect(self._on_original_checks_changed) self.original_list.itemClicked.connect(self._on_original_clicked) self.original_list.itemDoubleClicked.connect(self._preview_original) + self.select_all_originals_button.clicked.connect( + lambda checked=False: self.original_list.select_all_assets() + ) + self.invert_originals_button.clicked.connect( + lambda checked=False: self.original_list.invert_asset_checks() + ) for combo in ( self.platform_combo, self.country_combo, @@ -1132,31 +1330,61 @@ class ProductSuiteTab(QWidget): thread.start() return thread - def _refresh_originals(self, state): + def _refresh_originals(self, state, *, preserve_checks=True): + context = (state.key, state.project_id) if state is not None else None + checked_ids = ( + set(self.original_list.checked_asset_ids()) + if preserve_checks and context == self._original_list_context + else set() + ) + previous = self.original_list.blockSignals(True) self.original_list.clear() assets = self._original_assets(state, include_missing=False) - for index, asset in enumerate(assets, 1): - label = "主图" if index == 1 else "参考%d" % (index - 1) - item = QListWidgetItem(label) - item.setData(Qt.UserRole, int(asset.id)) - if _asset_usable(asset): - item.setIcon(QIcon(_image_pixmap(asset.local_path, QSize(82, 64)))) - item.setToolTip("%s,双击预览;拖动可调整顺序" % label) - else: - item.setIcon(QIcon(_placeholder_pixmap("待下载", QSize(82, 64)))) - item.setToolTip("%s尚未下载,单击后在后台拉取" % label) - self.original_list.addItem(item) - for index in range(len(assets) + 1, 7): - label = "主图" if index == 1 else "参考%d" % (index - 1) - item = QListWidgetItem(label) - item.setData(Qt.UserRole, None) - item.setIcon(QIcon(_placeholder_pixmap("添加", QSize(82, 64)))) - item.setToolTip("点击添加%s" % label) - item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) - self.original_list.addItem(item) + try: + for index, asset in enumerate(assets, 1): + label = "主图" if index == 1 else "参考%d" % (index - 1) + item = QListWidgetItem(label) + item.setData(Qt.UserRole, int(asset.id)) + item.setData( + ORIGINAL_CHECK_STATE_ROLE, + Qt.Checked if int(asset.id) in checked_ids else Qt.Unchecked, + ) + if _asset_usable(asset): + item.setIcon(QIcon(_image_pixmap(asset.local_path, QSize(82, 64)))) + item.setToolTip("%s,勾选可批量删除;双击预览;拖动可调整顺序" % label) + else: + item.setIcon(QIcon(_placeholder_pixmap("待下载", QSize(82, 64)))) + item.setToolTip("%s尚未下载;勾选可批量删除,单击缩略图后台拉取" % label) + self.original_list.addItem(item) + for index in range(len(assets) + 1, 7): + label = "主图" if index == 1 else "参考%d" % (index - 1) + item = QListWidgetItem(label) + item.setData(Qt.UserRole, None) + item.setIcon(QIcon(_placeholder_pixmap("添加", QSize(82, 64)))) + item.setToolTip("点击添加%s" % label) + item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.original_list.addItem(item) + finally: + self.original_list.blockSignals(previous) + self._original_list_context = context + self.original_list.update_content_height() + self.original_list.viewport().update() self.original_count_label.setText("%d/16" % len(assets)) + self._refresh_original_selection_controls() self._refresh_totals(state) + def _on_original_checks_changed(self, asset_ids): + self._refresh_original_selection_controls() + + def _refresh_original_selection_controls(self): + checked_count = len(self.original_list.checked_asset_ids()) + asset_count = len(self.original_list.asset_ids()) + self.original_selected_label.setText("已选 %d 张" % checked_count) + state = self._displayed_state + enabled = bool(asset_count) and state is not None and not state.generation_running() + self.select_all_originals_button.setEnabled(enabled) + self.invert_originals_button.setEnabled(enabled) + def _original_assets(self, state, *, include_missing=False): if state is None or state.project_id is None: return [] @@ -1241,19 +1469,52 @@ class ProductSuiteTab(QWidget): self._refresh_originals(state) def delete_original(self, asset_id): + self.delete_originals([asset_id]) + + def delete_originals(self, asset_ids): state = self._displayed_state - if state is None or state.generation_running(): + if state is None or state.project_id is None: + self._status("当前商品没有可删除的原图", "warning") + return + if state.generation_running(): self._status("生成中不能删除当前任务的商品原图", "warning") return - if not self._confirm("删除商品原图", "确认从当前商品的原图列表移除这张图片吗?"): + normalized_ids = [] + seen = set() + for value in asset_ids or []: + asset_id = int(value) + if asset_id not in seen: + seen.add(asset_id) + normalized_ids.append(asset_id) + if not normalized_ids: + self._status("请先勾选要删除的商品原图", "warning") + return + downloading = set(state.download_queue) | set(state.downloads) + if downloading.intersection(normalized_ids): + self._message("暂不能删除商品原图", "选中的图片仍在后台下载,请等待下载结束后再删除。") + return + visible_ids = [int(asset.id) for asset in self._original_assets(state, include_missing=False)] + message = ( + "确认从当前商品原图列表移除选中的%d张图片吗?\n\n" + "此操作不会删除蝦皮线上图片,也不会删除本地源文件。" + "以后重新拉取蝦皮主图时,线上仍存在的图片可能重新出现。" + % len(normalized_ids) + ) + if visible_ids and visible_ids[0] in normalized_ids: + message += "\n\n选中内容包含当前主图,删除后下一张图片将成为主图。" + if not self._confirm("删除商品原图", message, destructive=True): return try: - image_studio.remove_asset_if_unused(asset_id, path=self.db_path) + image_studio.remove_original_assets_if_unused( + state.project_id, + normalized_ids, + path=self.db_path, + ) except Exception as exc: self._message("不能删除商品原图", _user_error(exc)) return - self._refresh_originals(state) - self._status("商品原图已移除", "success") + self._refresh_originals(state, preserve_checks=False) + self._status("已移除%d张商品原图" % len(normalized_ids), "success") def reorder_originals(self, visible_ids): state = self._displayed_state @@ -1828,6 +2089,7 @@ class ProductSuiteTab(QWidget): self.item_id_edit.setEnabled(not generation_running and state.pull_worker is None) self.add_images_button.setEnabled(not generation_running and state.import_worker is None) self.original_list.setEnabled(not generation_running) + self._refresh_original_selection_controls() for widget in ( self.platform_combo, self.country_combo, diff --git a/app/image_studio.py b/app/image_studio.py index 2927492..8faaa3c 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -575,6 +575,84 @@ def remove_asset_if_unused(asset_id, path=None, conn=None): return asset +def remove_original_assets_if_unused(project_id, asset_ids, path=None, conn=None): + """Atomically remove unreferenced original assets from one project. + + Local files are intentionally retained. If any requested asset is invalid or + referenced, no asset row is removed. + """ + + project_id = int(project_id) + ordered_ids = [] + seen = set() + for value in asset_ids or []: + asset_id = int(value) + if asset_id not in seen: + seen.add(asset_id) + ordered_ids.append(asset_id) + if not ordered_ids: + raise db.DbError("请选择要删除的商品原图") + + placeholders = ",".join("?" for _ in ordered_ids) + with _connection(conn, path) as database: + with database: + project = get_project(project_id, conn=database) + if project is None: + raise db.DbError("商品套图项目不存在或已删除") + rows = database.execute( + f""" + SELECT * FROM image_studio_assets + WHERE project_id = ? AND kind = ? AND id IN ({placeholders}) + """, + [project_id, ASSET_KIND_ORIGINAL, *ordered_ids], + ).fetchall() + by_id = {int(row["id"]): row for row in rows} + if set(by_id) != set(ordered_ids): + raise db.DbError("选中的商品原图不存在或不属于当前项目") + + referenced_ids = [ + asset_id + for asset_id in ordered_ids + if asset_reference_counts(asset_id, conn=database)["total"] + ] + if referenced_ids: + raise db.DbError("选中的图片正在被生成任务或终选引用,不能移除") + + database.execute( + f""" + DELETE FROM image_studio_assets + WHERE project_id = ? AND kind = ? AND id IN ({placeholders}) + """, + [project_id, ASSET_KIND_ORIGINAL, *ordered_ids], + ) + remaining_rows = database.execute( + """ + SELECT id FROM image_studio_assets + WHERE project_id = ? AND kind = ? + ORDER BY source_order, id + """, + (project_id, ASSET_KIND_ORIGINAL), + ).fetchall() + now = _now() + for source_order, row in enumerate(remaining_rows, 1): + database.execute( + """ + UPDATE image_studio_assets + SET source_order = ?, updated_at = ? + WHERE id = ? AND project_id = ? AND kind = ? + """, + ( + source_order, + now, + int(row["id"]), + project_id, + ASSET_KIND_ORIGINAL, + ), + ) + + return [_row_to_dataclass(by_id[asset_id], ImageStudioAsset) for asset_id in ordered_ids] + + def sync_original_asset_urls(project_id, image_urls, path=None, conn=None, max_assets=16): """Store the read-only Shopee main image URL snapshot as remote-only assets.""" diff --git a/docs/04-architecture.md b/docs/04-architecture.md index cccc142..9cc619a 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -431,7 +431,7 @@ data/images///__new. # AI 生成的新 - 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。⑤仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。 - ⑥商品套图固定使用⑤保存的 cmhub 生图 alias;平台、国家地区、输出语言、比例、分类、商品ID、参考图序号和卖点文本由 `product_suite.build_suite_prompt()` 组成每个 job 的完整提示词。比例同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,最终进入 cmhub 请求与输出资产元数据。 -- `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。 +- `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。⑥原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 - 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。 提示词管理: diff --git a/docs/api.md b/docs/api.md index fb221bc..28980e6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -373,6 +373,7 @@ update_project_suite_settings(project_id, settings, path=None) -> ImageStudioPro sync_original_asset_urls(project_id, image_urls, path=None) -> list[ImageStudioAsset] list_assets(project_id, kind=None, include_missing=True, path=None) -> list[ImageStudioAsset] reorder_original_assets(project_id, asset_ids, path=None) -> list[ImageStudioAsset] +remove_original_assets_if_unused(project_id, asset_ids, path=None) -> list[ImageStudioAsset] create_job(project_id, source_asset_id=None, job_type="main", prompt="", ...) -> ImageStudioJob list_jobs(project_id, statuses=None, path=None) -> list[ImageStudioJob] list_resumable_jobs(project_id=None, include_failed_downloads=False, path=None) -> list[ImageStudioJob] @@ -408,6 +409,7 @@ export_project_selection(project_id, parent_dir, existing_mode="fail", path=None - `image_studio_projects.suite_settings_json` 保存平台/国家/语言/比例/逐图主图/分类数量;`draft_prompt` 保存商品卖点。有效原图上限16张,missing 历史不占名额。 - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 +- `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 - cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。商品套图把平台/国家/语言/比例等上下文写入每个 job prompt,并把比例实参传到 cmhub;界面不展示 Provider URL、OpenAI Key 或上游接口路径。 - `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。 - 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。 diff --git a/docs/routes.md b/docs/routes.md index 6a17569..f549529 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -186,7 +186,8 @@ ┌ 套图任务1 │ 套图任务2 │ + ───────────────────────────────────┐ │ [历史生成][打开结果文件夹][添加图片] 账号[▼] 商品ID[____][拉取主图] │ ├ 左侧配置(滚动)────────────┬ 右侧生成结果 ─────────────────────┤ -│ 商品原图:主图/参考1..5/添加 │ 共N张·成功M张 │ +│ 商品原图 N/16 已选N张[全选][反选]│ 共N张·成功M张 │ +│ 主图/参考图按宽度换行自然展开 │ │ │ 平台 站点 语言 比例(同行) │ [结果卡][结果卡][失败卡·重试] │ │ 每张上传图分别作为主图生成 │ │ │ 商品卖点与要求 [AI帮写/取消] │ │ @@ -198,7 +199,8 @@ - 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。 - 二级套图任务标签使用独立紧凑样式,不继承主模块 Tab 的大尺寸点击区。上下文栏左侧集中「历史生成 / 打开结果文件夹 / 添加图片」,右侧集中账号、商品 ID 和拉取入口;常见 11~13 位商品 ID 不得裁切,长账号可通过 tooltip 查看完整名称。 - 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`。 -- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;支持文件选择、外部拖入、剪贴板粘贴和列表内排序。历史失效远程图不占有效名额;第1张是主参考图。 +- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序。历史失效远程图不占有效名额;第1张是主参考图。 +- 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。 - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。 - 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 diff --git a/docs/tasks/T-631.md b/docs/tasks/T-631.md index c618e55..7065491 100644 --- a/docs/tasks/T-631.md +++ b/docs/tasks/T-631.md @@ -3,7 +3,7 @@ id: T-631 title: 商品套图原图自适应展示与批量选择删除 phase: 7 deps: [T-627] -status: TODO +status: DONE created: 2026-07-14 --- @@ -95,3 +95,14 @@ created: 2026-07-14 - 不修改 cmhub 生图提交、轮询、下载、并发、重试或计费逻辑,不修改生成结果/历史结果删除语义。 - 不把原图勾选状态持久化,不新增 SQLite 字段;只扩展现有图片资产服务的事务级批量操作。 - 不修改①~⑤模块 UI,不修改主窗口全局样式。 + +## 执行记录 + +- 2026-07-14 完成。 +- ⑥商品原图列表已关闭横向/纵向内部滚动条,按当前可用宽度、固定缩略图尺寸和最多16张原图动态计算行数与高度;最少6个展示位保持不变,图片区自然向下展开并由外层 `suiteConfigScroll` 统一滚动。 +- 每张真实原图通过自定义 delegate 显示独立复选框,添加占位图不参与选择。标题栏新增「已选 N 张 / 全选 / 反选」;普通刷新和拖拽排序按 `asset_id` 保留勾选,任务切换及删除成功后清空。复选框点击不会触发下载或预览,缩略图单击、双击、拖拽和悬停单张删除保持原语义。 +- 右键菜单和 Delete/Backspace 已统一支持单张或勾选集合删除。确认框显示准确数量,包含主图变化、本地源文件保留、蝦皮线上图片不删除及重新拉取可能恢复的说明;生成中或任一选中图片仍在后台下载时整批阻断。 +- `app/image_studio.py` 新增 `remove_original_assets_if_unused()`:一次校验项目、原图类型、job/终选引用,在同一个 SQLite 事务中删除资产行并连续重排 `source_order`;任一 ID 无效或被引用时整批不变,本地文件始终保留。 +- 已同步 `docs/04-architecture.md`、`docs/api.md` 与 `docs/routes.md`。新增 GUI 和服务层测试,覆盖 0/6/7/16 张及宽度变化、选择状态、任务切换、点击/键盘/右键路由、运行与下载阻断、跨项目/非原图/不存在/job/终选引用的事务回滚。 +- 离屏界面检查通过:1180×760 下16张图片为3列6行,内部滚动范围为0、外层滚动正常;960×640 的 Windows 125% / 150% 缩放模拟下标题栏操作不重叠,复选框不遮挡缩略图文字或右上角单张删除入口。 +- 当前工作区全量测试仅有任务开始前已存在的默认封面提示词重命名导致3个旧 `papa1` 断言失败;在只包含 T-631 暂存内容的干净验证工作树中运行 `py -3.10 -m unittest discover -s tests`,479项全部通过。`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 全部通过。 diff --git a/tests/test_image_studio.py b/tests/test_image_studio.py index 946afdb..1d83172 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -335,6 +335,139 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_remove_original_assets_is_atomic_and_reorders_remaining_assets(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + project = image_studio.create_or_get_project( + account_alias="alias", + account_slug="alias_slug", + item_id="51100639510", + path=db_path, + ) + local_paths = [] + assets = [] + for index in range(1, 5): + local_path = os.path.join(temp_dir, "original-%d.png" % index) + with open(local_path, "wb") as fh: + fh.write(b"image-%d" % index) + local_paths.append(local_path) + assets.append( + image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=local_path, + source_order=index * 10, + path=db_path, + ) + ) + + removed = image_studio.remove_original_assets_if_unused( + project.id, + [assets[0].id, assets[2].id, assets[0].id], + path=db_path, + ) + + self.assertEqual([assets[0].id, assets[2].id], [asset.id for asset in removed]) + remaining = image_studio.list_assets( + project.id, + kind=image_studio.ASSET_KIND_ORIGINAL, + path=db_path, + ) + self.assertEqual([assets[1].id, assets[3].id], [asset.id for asset in remaining]) + self.assertEqual([1, 2], [asset.source_order for asset in remaining]) + self.assertTrue(all(os.path.isfile(path) for path in local_paths)) + + self.assert_removed(temp_dir) + + def test_remove_original_assets_rejects_invalid_or_referenced_batch_without_partial_delete(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + project = image_studio.create_or_get_project( + account_alias="alias", + account_slug="alias_slug", + item_id="51100639510", + path=db_path, + ) + other_project = image_studio.create_or_get_project( + account_alias="alias", + account_slug="alias_slug", + item_id="51100639511", + path=db_path, + ) + free_asset = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + source_order=1, + path=db_path, + ) + referenced_asset = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + source_order=2, + path=db_path, + ) + selected_asset = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + source_order=3, + path=db_path, + ) + generated_asset = image_studio.add_asset( + project.id, + "generated", + path=db_path, + ) + foreign_asset = image_studio.add_asset( + other_project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=db_path, + ) + image_studio.create_job( + project.id, + source_asset_id=referenced_asset.id, + path=db_path, + ) + image_studio.replace_selections( + project.id, + "main", + [selected_asset.id], + path=db_path, + ) + + with self.assertRaisesRegex(db.DbError, "引用"): + image_studio.remove_original_assets_if_unused( + project.id, + [free_asset.id, referenced_asset.id], + path=db_path, + ) + self.assertIsNotNone(image_studio.get_asset(free_asset.id, path=db_path)) + self.assertIsNotNone(image_studio.get_asset(referenced_asset.id, path=db_path)) + + with self.assertRaisesRegex(db.DbError, "引用"): + image_studio.remove_original_assets_if_unused( + project.id, + [free_asset.id, selected_asset.id], + path=db_path, + ) + self.assertIsNotNone(image_studio.get_asset(free_asset.id, path=db_path)) + self.assertIsNotNone(image_studio.get_asset(selected_asset.id, path=db_path)) + + for invalid_id in (generated_asset.id, foreign_asset.id, 999999): + with self.assertRaisesRegex(db.DbError, "不属于当前项目"): + image_studio.remove_original_assets_if_unused( + project.id, + [free_asset.id, invalid_id], + path=db_path, + ) + self.assertIsNotNone(image_studio.get_asset(free_asset.id, path=db_path)) + + with self.assertRaisesRegex(db.DbError, "请选择"): + image_studio.remove_original_assets_if_unused(project.id, [], path=db_path) + + self.assert_removed(temp_dir) + def test_sync_original_asset_urls_is_idempotent_and_marks_missing(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 462bac1..1daec15 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -13,10 +13,18 @@ from app import gui if gui.QT_IMPORT_ERROR is not None: raise unittest.SkipTest("PySide6 未安装") -from PySide6.QtGui import QImage -from PySide6.QtWidgets import QApplication, QLabel, QPushButton +from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon, QImage, QPixmap +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton -from app.gui.tabs.product_suite import ProductSuiteTab, SuiteResultCard +from app.gui.tabs.product_suite import ( + ORIGINAL_CHECK_STATE_ROLE, + ProductOriginalDelegate, + ProductOriginalList, + ProductSuiteTab, + SuiteResultCard, +) class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): @@ -46,6 +54,33 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): image.fill(0xFF336699) self.assertTrue(image.save(path)) + def _create_project_with_assets(self, temp_dir, config, count, item_id="51100639510"): + account = accounts.create_account( + "主店", + "alias-a", + debug_port=9222, + config=config, + ) + project = image_studio.create_or_get_project( + account, + item_id=item_id, + path=config["db_path"], + ) + assets = [] + for index in range(count): + source_path = os.path.join(temp_dir, "%s-%02d.png" % (item_id, index + 1)) + self._write_image(source_path) + assets.append( + image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=source_path, + source_order=index + 1, + path=config["db_path"], + ) + ) + return project, assets + def test_tab_builds_suite_controls_without_old_detail_workspace(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) @@ -276,6 +311,205 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_original_list_expands_without_internal_scrollbars(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, assets = self._create_project_with_assets(temp_dir, config, 0) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = "51100639510" + state.project_id = project.id + tab.resize(1180, 760) + tab.show() + tab._load_state(state) + self.app.processEvents() + + self.assertEqual(Qt.ScrollBarAlwaysOff, tab.original_list.horizontalScrollBarPolicy()) + self.assertEqual(Qt.ScrollBarAlwaysOff, tab.original_list.verticalScrollBarPolicy()) + self.assertEqual(6, tab.original_list.count()) + self.assertEqual(2, tab.original_list.content_row_count()) + empty_height = tab.original_list.height() + + for target_count in (6, 7, 16): + for index in range(len(assets), target_count): + source_path = os.path.join(temp_dir, "added-%02d.png" % (index + 1)) + self._write_image(source_path) + assets.append( + image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=source_path, + source_order=index + 1, + path=config["db_path"], + ) + ) + tab._refresh_originals(state) + self.app.processEvents() + self.assertEqual(target_count, len(tab.original_list.asset_ids())) + expected_rows = (max(6, target_count) + 2) // 3 + self.assertEqual(expected_rows, tab.original_list.content_row_count()) + if target_count == 6: + self.assertEqual(empty_height, tab.original_list.height()) + + wide_height = tab.original_list.height() + tab.original_list.setFixedWidth(250) + self.app.processEvents() + self.assertEqual(2, tab.original_list.content_column_count()) + self.assertEqual(8, tab.original_list.content_row_count()) + self.assertGreater(tab.original_list.height(), wide_height) + + self.assert_removed(temp_dir) + + def test_original_checks_preserve_on_refresh_and_clear_on_task_switch(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, assets = self._create_project_with_assets(temp_dir, config, 2) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = "51100639510" + state.project_id = project.id + tab._load_state(state) + + self.assertEqual(6, tab.original_list.count()) + self.assertTrue(tab.select_all_originals_button.isEnabled()) + self.assertTrue(tab.invert_originals_button.isEnabled()) + for row in range(2): + self.assertIsNotNone(tab.original_list.item(row).data(ORIGINAL_CHECK_STATE_ROLE)) + for row in range(2, 6): + self.assertIsNone(tab.original_list.item(row).data(ORIGINAL_CHECK_STATE_ROLE)) + + tab.select_all_originals_button.click() + self.assertEqual([asset.id for asset in assets], tab.original_list.checked_asset_ids()) + self.assertEqual("已选 2 张", tab.original_selected_label.text()) + tab.invert_originals_button.click() + self.assertEqual([], tab.original_list.checked_asset_ids()) + + tab.original_list.set_checked_asset_ids([assets[1].id]) + tab._refresh_originals(state) + self.assertEqual([assets[1].id], tab.original_list.checked_asset_ids()) + tab.reorder_originals([assets[1].id, assets[0].id]) + self.assertEqual([assets[1].id], tab.original_list.checked_asset_ids()) + + tab.add_task(inherit=True) + self.assertEqual([], tab.original_list.checked_asset_ids()) + self.assertFalse(tab.select_all_originals_button.isEnabled()) + tab.task_tabs.setCurrentIndex(0) + self.assertEqual([], tab.original_list.checked_asset_ids()) + self.assertEqual("已选 0 张", tab.original_selected_label.text()) + + self.assert_removed(temp_dir) + + def test_original_checkbox_click_and_keyboard_delete_keep_actions_separate(self): + original_list = ProductOriginalList() + self.addCleanup(original_list.close) + original_list.setFixedSize(240, 210) + for asset_id in (11, 12): + item = QListWidgetItem("原图%d" % asset_id) + item.setData(Qt.UserRole, asset_id) + item.setData(ORIGINAL_CHECK_STATE_ROLE, Qt.Unchecked) + pixmap = QPixmap(82, 64) + pixmap.fill(0xFF336699) + item.setIcon(QIcon(pixmap)) + original_list.addItem(item) + original_list.show() + self.app.processEvents() + + clicked = [] + double_clicked = [] + batch_deleted = [] + single_deleted = [] + original_list.itemClicked.connect(lambda item: clicked.append(item.data(Qt.UserRole))) + original_list.itemDoubleClicked.connect( + lambda item: double_clicked.append(item.data(Qt.UserRole)) + ) + original_list.deleteAssetsRequested.connect(lambda ids: batch_deleted.append(ids)) + original_list.deleteRequested.connect(lambda asset_id: single_deleted.append(asset_id)) + + first_rect = original_list.visualItemRect(original_list.item(0)) + check_point = ProductOriginalDelegate.checkbox_hit_rect(first_rect).center() + QTest.mouseClick(original_list.viewport(), Qt.LeftButton, pos=check_point) + self.assertEqual([11], original_list.checked_asset_ids()) + self.assertEqual([], clicked) + QTest.mouseDClick(original_list.viewport(), Qt.LeftButton, pos=check_point) + self.assertEqual([], double_clicked) + + QTest.mouseClick(original_list.viewport(), Qt.LeftButton, pos=first_rect.center()) + self.assertEqual([11], clicked) + original_list.set_checked_asset_ids([11, 12]) + self.assertEqual( + [("删除选中的2张图片…", [11, 12])], + original_list.context_delete_options(11), + ) + original_list.set_checked_asset_ids([12]) + self.assertEqual( + [("删除这张图片…", [11]), ("删除选中图片…", [12])], + original_list.context_delete_options(11), + ) + + original_list.setFocus() + original_list.set_checked_asset_ids([11, 12]) + QTest.keyClick(original_list, Qt.Key_Delete) + self.assertEqual([[11, 12]], batch_deleted) + original_list.clear_checks() + original_list.setCurrentRow(1) + QTest.keyClick(original_list, Qt.Key_Backspace) + self.assertEqual([12], single_deleted) + + def test_batch_delete_confirms_main_image_and_blocks_running_or_downloading(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, assets = self._create_project_with_assets(temp_dir, config, 3) + statuses = [] + tab = ProductSuiteTab( + config=config, + db_path=config["db_path"], + status_callback=lambda message, level=None: statuses.append((message, level)), + ) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = "51100639510" + state.project_id = project.id + tab._load_state(state) + + confirmations = [] + tab._confirm = lambda title, message, **kwargs: confirmations.append( + (title, message, kwargs) + ) or True + tab.original_list.set_checked_asset_ids([assets[0].id, assets[1].id]) + tab.delete_originals(tab.original_list.checked_asset_ids()) + + self.assertEqual([assets[2].id], tab.original_list.asset_ids()) + self.assertEqual([], tab.original_list.checked_asset_ids()) + self.assertIn("选中的2张", confirmations[0][1]) + self.assertIn("下一张图片将成为主图", confirmations[0][1]) + self.assertIn("不会删除蝦皮线上图片", confirmations[0][1]) + self.assertEqual(("已移除2张商品原图", "success"), statuses[-1]) + + messages = [] + tab._message = lambda title, message, **kwargs: messages.append((title, message)) + state.download_queue = [assets[2].id] + tab.delete_originals([assets[2].id]) + self.assertIn("仍在后台下载", messages[-1][1]) + self.assertIsNotNone(image_studio.get_asset(assets[2].id, path=config["db_path"])) + + class RunningWorker: + def cancel(self): + return None + + state.download_queue = [] + state.worker = RunningWorker() + tab.delete_originals([assets[2].id]) + self.assertEqual(("生成中不能删除当前任务的商品原图", "warning"), statuses[-1]) + self.assertIsNotNone(image_studio.get_asset(assets[2].id, path=config["db_path"])) + state.worker = None + + self.assert_removed(temp_dir) + if __name__ == "__main__": unittest.main()