diff --git a/app/db.py b/app/db.py index 39db811..576f7b6 100644 --- a/app/db.py +++ b/app/db.py @@ -299,6 +299,7 @@ CREATE TABLE IF NOT EXISTS image_studio_projects ( target_main_count INTEGER NOT NULL DEFAULT 9, target_detail_count INTEGER NOT NULL DEFAULT 12, draft_prompt TEXT, + suite_settings_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -478,6 +479,7 @@ def init_db(path=None, conn=None) -> None: _ensure_batch_delete_columns(database) _ensure_task_image_task_columns(database) _ensure_task_cover_reset_columns(database) + _ensure_image_studio_project_suite_columns(database) _ensure_image_studio_job_recovery_columns(database) @@ -505,6 +507,18 @@ def _ensure_task_cover_reset_columns(database): database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT") +def _ensure_image_studio_project_suite_columns(database): + columns = { + row["name"] + for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall() + } + if "suite_settings_json" not in columns: + database.execute( + "ALTER TABLE image_studio_projects " + "ADD COLUMN suite_settings_json TEXT NOT NULL DEFAULT '{}'" + ) + + def _ensure_image_studio_job_recovery_columns(database): columns = { row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall() diff --git a/app/gui/__init__.py b/app/gui/__init__.py index 0c4bf01..37ac343 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -29,6 +29,9 @@ if QT_IMPORT_ERROR is None: ImageStudioGenerateJobsWorker, ImageStudioPullImagesWorker, ImageStudioResumeJobsWorker, + ProductSuiteAiWriteWorker, + ProductSuiteGenerateWorker, + ProductSuiteImportImagesWorker, WriteBackWorker, ) from .tabs.accounts import AccountDialog, AccountsTab @@ -36,6 +39,7 @@ if QT_IMPORT_ERROR is None: from .tabs.collect import CollectTab from .tabs.generate import GenerateTab from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab + from .tabs.product_suite import ProductSuitePreviewDialog, ProductSuiteTab from .tabs.settings import SettingsTab from .main_window import MainWindow from .update_dialog import ForcedUpdateDialog, UpdatePreparationWorker diff --git a/app/gui/main_window.py b/app/gui/main_window.py index ae6b780..6c48439 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -7,7 +7,7 @@ from .tabs.accounts import AccountsTab from .tabs.apply import ApplyTab from .tabs.collect import CollectTab from .tabs.generate import GenerateTab -from .tabs.image_studio import ImageStudioTab +from .tabs.product_suite import ProductSuiteTab from .tabs.settings import SettingsTab from .widgets import * @@ -166,13 +166,12 @@ class MainWindow(QMainWindow): ai_models_path=self.ai_models_path, status_callback=self.show_status, ) - if title == "⑥ AI工场": - return ImageStudioTab( + if title == "⑥ 商品套图": + return ProductSuiteTab( db_path=self.db_path, config=self.config, config_path=self.config_path, status_callback=self.show_status, - prompts_dir=appconfig.image_studio_prompts_dir(self.config), ) raise ValueError(f"未知主界面模块:{title}") diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py new file mode 100644 index 0000000..6b0fbb4 --- /dev/null +++ b/app/gui/tabs/product_suite.py @@ -0,0 +1,1988 @@ +"""Tab 6: project-bound product suite generation workspace.""" + +from __future__ import annotations + +import os +import re +import time +from dataclasses import dataclass, field + +from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QSize, Qt, QTimer, Signal +from PySide6.QtGui import QColor, QIcon, QImage, QImageReader, QKeySequence, QPainter, QPixmap +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDialog, + QFileDialog, + QFrame, + QGridLayout, + QHBoxLayout, + QInputDialog, + QLabel, + QLineEdit, + QListView, + QListWidget, + QListWidgetItem, + QMenu, + QMessageBox, + QPlainTextEdit, + QProgressBar, + QPushButton, + QScrollArea, + QSplitter, + QTabBar, + QToolButton, + QVBoxLayout, + QWidget, +) + +from ... import accounts, appconfig, diagnostics, image_studio, image_studio_images, product_suite +from .. import file_manager +from ..widgets import COLOR_DANGER, _emit_status, run_worker +from ..workers import ( + ImageStudioDownloadOriginalWorker, + ImageStudioPullImagesWorker, + ProductSuiteAiWriteWorker, + ProductSuiteGenerateWorker, + ProductSuiteImportImagesWorker, +) + + +ORIGINAL_DOWNLOAD_CONCURRENCY = 2 +_PRODUCT_SUITE_THREAD_REFS = {} +_URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE) + + +def _asset_usable(asset): + path = str(getattr(asset, "local_path", "") or "") + return ( + getattr(asset, "status", "") != image_studio.ASSET_STATUS_MISSING + and bool(path) + and os.path.isfile(path) + ) + + +def _placeholder_pixmap(text, size, color="#f3f4f6"): + pixmap = QPixmap(size) + pixmap.fill(QColor(color)) + painter = QPainter(pixmap) + painter.setPen(QColor("#6b7280")) + painter.drawText(pixmap.rect(), Qt.AlignCenter | Qt.TextWordWrap, str(text or "")) + painter.end() + return pixmap + + +def _image_pixmap(path, size, fallback="暂无图片"): + image = QImage() + if path and os.path.isfile(path): + reader = QImageReader(str(path)) + reader.setAutoTransform(True) + source_size = reader.size() + if source_size.isValid(): + source_size.scale(size, Qt.KeepAspectRatio) + reader.setScaledSize(source_size) + image = reader.read() + if image.isNull(): + return _placeholder_pixmap(fallback, size) + scaled = QPixmap.fromImage(image).scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + canvas = QPixmap(size) + canvas.fill(QColor("#f7f8fa")) + painter = QPainter(canvas) + painter.drawPixmap( + max(0, (size.width() - scaled.width()) // 2), + max(0, (size.height() - scaled.height()) // 2), + scaled, + ) + painter.end() + return canvas + + +def _user_error(error): + text = diagnostics.redact_log_text(str(error or "未知错误")) + text = _URL_RE.sub("[链接已隐藏]", text).replace("\r", " ").replace("\n", " ").strip() + return text if len(text) <= 90 else text[:87] + "..." + + +class ProductSuitePreviewDialog(QDialog): + """Responsive preview for product originals and generated assets.""" + + def __init__(self, path, title="图片预览", parent=None): + super().__init__(parent) + self._source = QPixmap(str(path or "")) + self.setWindowTitle(str(title or "图片预览")) + layout = QVBoxLayout(self) + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setMinimumSize(320, 240) + layout.addWidget(self.image_label, 1) + close_button = QPushButton("关闭") + close_button.clicked.connect(self.accept) + button_row = QHBoxLayout() + button_row.addStretch(1) + button_row.addWidget(close_button) + layout.addLayout(button_row) + self.resize(820, 620) + self._render() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._render() + + def _render(self): + if self._source.isNull(): + self.image_label.setText("图片文件不存在或无法读取") + self.image_label.setPixmap(QPixmap()) + return + target = self.image_label.size() - QSize(16, 16) + if target.width() <= 0 or target.height() <= 0: + return + self.image_label.setPixmap( + self._source.scaled(target, Qt.KeepAspectRatio, Qt.SmoothTransformation) + ) + + +class ProductOriginalList(QListWidget): + filesDropped = Signal(list) + clipboardImage = Signal(bytes) + orderChanged = Signal(list) + deleteRequested = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("suiteOriginalList") + self.setViewMode(QListView.IconMode) + self.setResizeMode(QListView.Adjust) + self.setMovement(QListView.Snap) + self.setWrapping(True) + self.setIconSize(QSize(82, 64)) + self.setGridSize(QSize(112, 98)) + self.setSpacing(4) + self.setAcceptDrops(True) + self.setDragEnabled(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QListWidget.InternalMove) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_menu) + self.setMouseTracking(True) + self.itemEntered.connect(self._show_hover_remove) + self._hovered_asset_id = None + self._hover_remove_button = QToolButton(self.viewport()) + self._hover_remove_button.setText("×") + self._hover_remove_button.setToolTip("删除这张商品原图") + self._hover_remove_button.setAccessibleName("删除商品原图") + self._hover_remove_button.setFixedSize(28, 28) + self._hover_remove_button.clicked.connect(self._remove_hovered) + self._hover_remove_button.hide() + self.setToolTip("点击添加图片;也可拖入图片、粘贴图片或拖动缩略图排序") + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + return + super().dragEnterEvent(event) + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + return + super().dragMoveEvent(event) + + def dropEvent(self, event): + if event.mimeData().hasUrls(): + paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()] + self.filesDropped.emit(paths) + event.acceptProposedAction() + return + super().dropEvent(event) + self.orderChanged.emit(self.asset_ids()) + + def keyPressEvent(self, event): + if event.matches(QKeySequence.Paste): + image = QApplication.clipboard().image() + if not image.isNull(): + payload = QByteArray() + buffer = QBuffer(payload) + buffer.open(QIODevice.WriteOnly) + image.save(buffer, "PNG") + buffer.close() + self.clipboardImage.emit(bytes(payload)) + return + if event.key() in {Qt.Key_Delete, Qt.Key_Backspace}: + 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 + super().keyPressEvent(event) + + def 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 + ] + + 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))) + + def _show_hover_remove(self, item): + value = item.data(Qt.UserRole) + if value is None: + self._hovered_asset_id = None + self._hover_remove_button.hide() + return + self._hovered_asset_id = int(value) + rect = self.visualItemRect(item) + self._hover_remove_button.move(max(0, rect.right() - 27), rect.top() + 1) + self._hover_remove_button.show() + self._hover_remove_button.raise_() + + def _remove_hovered(self): + if self._hovered_asset_id is not None: + self.deleteRequested.emit(int(self._hovered_asset_id)) + + def leaveEvent(self, event): + self._hovered_asset_id = None + self._hover_remove_button.hide() + super().leaveEvent(event) + + +class CategoryChipButton(QPushButton): + doubleClicked = Signal() + + def mouseDoubleClickEvent(self, event): + self.doubleClicked.emit() + event.accept() + + +class SuiteResultCard(QFrame): + previewRequested = Signal(object) + retryRequested = Signal(object) + menuRequested = Signal(object, object) + deleteRequested = Signal(object) + + def __init__(self, job, asset=None, parent=None): + super().__init__(parent) + self.job = job + self.asset = asset + self.setObjectName("suiteResultCard") + self.setFrameShape(QFrame.StyledPanel) + self.setFixedSize(186, 224) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect( + lambda point: self.menuRequested.emit(self.job, self.mapToGlobal(point)) + ) + layout = QVBoxLayout(self) + layout.setContentsMargins(7, 7, 7, 7) + layout.setSpacing(5) + + title_row = QHBoxLayout() + title = QLabel(str(getattr(job, "job_type", "套图"))) + title.setStyleSheet("font-weight: 600; color: #24292f;") + title_row.addWidget(title, 1) + if asset is not None and _asset_usable(asset): + delete_button = QToolButton() + delete_button.setText("×") + delete_button.setToolTip("删除这张生成图片") + delete_button.setAccessibleName("删除生成图片") + delete_button.setMinimumSize(28, 28) + delete_button.clicked.connect(lambda: self.deleteRequested.emit(self.job)) + title_row.addWidget(delete_button) + layout.addLayout(title_row) + + image_label = QLabel() + image_label.setAlignment(Qt.AlignCenter) + image_label.setFixedSize(170, 150) + status = str(getattr(job, "status", "pending") or "pending") + if asset is not None and _asset_usable(asset): + image_label.setPixmap(_image_pixmap(asset.local_path, QSize(170, 150))) + else: + label = { + "pending": "等待提交", + "submitted": "排队中", + "running": "生成中", + "failed": "生成失败", + "expired": "任务过期", + "cancelled": "已停止", + "succeeded": "图片已删除", + }.get(status, "处理中") + color = "#ffebe9" if status in {"failed", "expired"} else "#f3f4f6" + image_label.setPixmap(_placeholder_pixmap(label, QSize(170, 150), color)) + image_label.mousePressEvent = lambda event: ( + self.previewRequested.emit(self.job) + if event.button() == Qt.LeftButton + else None + ) + layout.addWidget(image_label) + + footer = QHBoxLayout() + status_text = { + "pending": "等待提交", + "submitted": "已提交", + "running": "生成中", + "succeeded": "生成成功" if asset is not None and _asset_usable(asset) else "已删除", + "failed": "失败:%s" % _user_error(getattr(job, "error", "")), + "expired": "已过期", + "cancelled": "已停止", + }.get(status, status) + status_label = QLabel(status_text) + status_label.setWordWrap(False) + status_label.setToolTip(status_text) + footer.addWidget(status_label, 1) + if status in {"failed", "expired", "cancelled"}: + retry_button = QPushButton("重试") + retry_button.setMinimumWidth(52) + retry_button.clicked.connect(lambda: self.retryRequested.emit(self.job)) + footer.addWidget(retry_button) + layout.addLayout(footer) + + +@dataclass +class SuiteTaskState: + key: int + serial: int + account_alias: str = "" + item_id: str = "" + project_id: int = None + prompt: str = "" + settings: dict = field(default_factory=product_suite.default_suite_settings) + current_job_ids: list = field(default_factory=list) + show_history: bool = False + worker: object = None + thread: object = None + pull_worker: object = None + pull_thread: object = None + import_worker: object = None + import_thread: object = None + ai_worker: object = None + ai_thread: object = None + download_queue: list = field(default_factory=list) + downloads: dict = field(default_factory=dict) + done: int = 0 + failed: int = 0 + total: int = 0 + started_at: float = None + ai_started_at: float = None + 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 + + +class ProductSuiteTab(QWidget): + """Native PySide6 product-suite UI backed by image_studio services.""" + + PLATFORM_OPTIONS = ("Shopee", "Lazada", "TikTok Shop", "Amazon") + COUNTRY_OPTIONS = ("中国台湾", "新加坡", "马来西亚", "菲律宾", "泰国", "越南") + LANGUAGE_OPTIONS = ("繁体中文", "简体中文", "英文", "泰文", "越南文") + + def __init__( + self, + parent=None, + db_path=None, + config=None, + config_path=None, + status_callback=None, + ): + super().__init__(parent) + self.setObjectName("productSuiteTab") + self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config + self.db_path = db_path or appconfig.db_path(self.config) + self.config_path = config_path or self.config.get("config_path") or appconfig.CONFIG_PATH + self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config) + self.status_callback = status_callback + self.accounts = [] + self._states = {} + self._retired_states = [] + self._next_key = 1 + self._next_serial = 1 + self._displayed_state = None + self._loading = False + self._result_refresh_pending = False + + self._build_ui() + self._connect_signals() + self.refresh_accounts() + self.add_task(inherit=False) + self.elapsed_timer = QTimer(self) + self.elapsed_timer.setInterval(1000) + self.elapsed_timer.timeout.connect(self._refresh_elapsed) + self.elapsed_timer.start() + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(10, 8, 10, 8) + root.setSpacing(8) + root.addWidget(self._build_task_bar()) + root.addWidget(self._build_context_bar()) + + self.main_splitter = QSplitter(Qt.Horizontal) + self.main_splitter.setObjectName("suiteMainSplitter") + self.main_splitter.addWidget(self._build_config_panel()) + self.main_splitter.addWidget(self._build_results_panel()) + self.main_splitter.setStretchFactor(0, 2) + self.main_splitter.setStretchFactor(1, 3) + self.main_splitter.setSizes([430, 700]) + root.addWidget(self.main_splitter, 1) + + def _build_task_bar(self): + frame = QFrame() + layout = QHBoxLayout(frame) + layout.setContentsMargins(0, 0, 0, 0) + self.task_tabs = QTabBar() + self.task_tabs.setObjectName("suiteTaskTabs") + self.task_tabs.setTabsClosable(True) + self.task_tabs.setMovable(True) + self.task_tabs.setExpanding(False) + layout.addWidget(self.task_tabs, 1) + self.add_task_button = QToolButton() + self.add_task_button.setObjectName("suiteAddTaskButton") + self.add_task_button.setText("+") + self.add_task_button.setToolTip("新建套图任务") + self.add_task_button.setAccessibleName("新建套图任务") + self.add_task_button.setMinimumSize(32, 32) + layout.addWidget(self.add_task_button) + return frame + + def _build_context_bar(self): + frame = QFrame() + frame.setObjectName("suiteContextBar") + layout = QHBoxLayout(frame) + layout.setContentsMargins(10, 7, 10, 7) + layout.setSpacing(8) + layout.addWidget(QLabel("账号")) + self.account_combo = QComboBox() + self.account_combo.setObjectName("suiteAccountCombo") + self.account_combo.setMinimumWidth(180) + layout.addWidget(self.account_combo) + layout.addWidget(QLabel("商品ID")) + self.item_id_edit = QLineEdit() + self.item_id_edit.setObjectName("suiteItemIdEdit") + self.item_id_edit.setPlaceholderText("请输入蝦皮商品ID") + self.item_id_edit.setMinimumWidth(150) + layout.addWidget(self.item_id_edit) + layout.addStretch(1) + self.pull_button = QPushButton("拉取蝦皮主图") + self.pull_button.setObjectName("suitePullButton") + layout.addWidget(self.pull_button) + return frame + + def _build_config_panel(self): + panel = QWidget() + layout = QVBoxLayout(panel) + layout.setContentsMargins(0, 0, 4, 0) + layout.setSpacing(7) + + scroll = QScrollArea() + scroll.setObjectName("suiteConfigScroll") + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + content = QWidget() + self.config_layout = QVBoxLayout(content) + self.config_layout.setContentsMargins(4, 2, 8, 2) + self.config_layout.setSpacing(9) + self.config_layout.addWidget(self._build_original_section()) + self.config_layout.addWidget(self._build_settings_section()) + self.config_layout.addWidget(self._build_prompt_section()) + self.config_layout.addWidget(self._build_category_section()) + self.config_layout.addStretch(1) + scroll.setWidget(content) + layout.addWidget(scroll, 1) + + self.generate_button = QPushButton("生成套图(0)") + self.generate_button.setObjectName("suiteGenerateButton") + self.generate_button.setMinimumHeight(42) + self.generate_button.setStyleSheet( + "QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }" + "QPushButton:hover { background: #245fce; }" + ) + layout.addWidget(self.generate_button) + self.generate_helper_label = QLabel("建议填写产品名称、核心卖点、目标人群、使用场景与禁用元素") + self.generate_helper_label.setWordWrap(True) + self.generate_helper_label.setStyleSheet("color: #6b7280;") + layout.addWidget(self.generate_helper_label) + return panel + + def _build_original_section(self): + frame = QFrame() + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 0, 0, 0) + title_row = QHBoxLayout() + title = QLabel("商品原图") + title.setStyleSheet("font-weight: 600;") + title_row.addWidget(title) + self.original_count_label = QLabel("0/16") + self.original_count_label.setStyleSheet("color: #6b7280;") + title_row.addWidget(self.original_count_label) + title_row.addStretch(1) + self.add_images_button = QPushButton("添加图片") + self.add_images_button.setObjectName("suiteAddImagesButton") + title_row.addWidget(self.add_images_button) + layout.addLayout(title_row) + self.original_list = ProductOriginalList() + self.original_list.setFixedHeight(210) + layout.addWidget(self.original_list) + return frame + + def _build_settings_section(self): + frame = QFrame() + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 0, 0, 0) + title = QLabel("生成设置") + title.setStyleSheet("font-weight: 600;") + layout.addWidget(title) + grid = QGridLayout() + grid.setContentsMargins(0, 0, 0, 0) + grid.setSpacing(6) + self.platform_combo = self._prefixed_combo("suitePlatformCombo", "平台", self.PLATFORM_OPTIONS) + self.country_combo = self._prefixed_combo("suiteCountryCombo", "国家", self.COUNTRY_OPTIONS) + self.language_combo = self._prefixed_combo("suiteLanguageCombo", "语言", self.LANGUAGE_OPTIONS) + self.ratio_combo = self._prefixed_combo("suiteRatioCombo", "比例", product_suite.RATIOS) + grid.addWidget(self.platform_combo, 0, 0) + grid.addWidget(self.country_combo, 0, 1) + grid.addWidget(self.language_combo, 1, 0) + grid.addWidget(self.ratio_combo, 1, 1) + layout.addLayout(grid) + self.per_image_checkbox = QCheckBox("每张上传图分别作为主图生成") + self.per_image_checkbox.setObjectName("suitePerImageCheckbox") + layout.addWidget(self.per_image_checkbox) + return frame + + def _prefixed_combo(self, object_name, prefix, values): + combo = QComboBox() + combo.setObjectName(object_name) + combo.setToolTip("%s设置" % prefix) + for value in values: + combo.addItem("%s %s" % (prefix, value), value) + return combo + + def _build_prompt_section(self): + frame = QFrame() + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 0, 0, 0) + title_row = QHBoxLayout() + title = QLabel("商品卖点与要求") + title.setStyleSheet("font-weight: 600;") + title_row.addWidget(title) + title_row.addStretch(1) + self.ai_cancel_button = QPushButton("取消") + self.ai_cancel_button.setObjectName("suiteAiCancelButton") + self.ai_cancel_button.hide() + title_row.addWidget(self.ai_cancel_button) + self.ai_write_button = QPushButton("AI 帮写") + self.ai_write_button.setObjectName("suiteAiWriteButton") + title_row.addWidget(self.ai_write_button) + layout.addLayout(title_row) + self.prompt_edit = QPlainTextEdit() + self.prompt_edit.setObjectName("suitePromptEdit") + self.prompt_edit.setPlaceholderText("输入产品名称、卖点、目标人群、使用场景和禁用元素") + self.prompt_edit.setMinimumHeight(96) + self.prompt_edit.setMaximumHeight(112) + layout.addWidget(self.prompt_edit) + helper = QLabel("内容越具体,生成的商品套图越稳定。AI 帮写不会阻塞其他套图任务。") + helper.setWordWrap(True) + helper.setStyleSheet("color: #6b7280;") + layout.addWidget(helper) + return frame + + def _build_category_section(self): + frame = QFrame() + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 0, 0, 0) + title_row = QHBoxLayout() + title = QLabel("套图结构配置") + title.setStyleSheet("font-weight: 600;") + title_row.addWidget(title) + title_row.addStretch(1) + self.category_total_label = QLabel("合计 0 张") + self.category_total_label.setStyleSheet("color: #2f6fed; font-weight: 600;") + title_row.addWidget(self.category_total_label) + 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) + layout.addLayout(self.category_grid) + self.add_category_button = QPushButton("添加自定义分类") + self.add_category_button.setObjectName("suiteAddCategoryButton") + layout.addWidget(self.add_category_button) + self.custom_category_edit = QLineEdit() + self.custom_category_edit.setObjectName("suiteCustomCategoryEdit") + self.custom_category_edit.setPlaceholderText("输入分类名称后按回车") + self.custom_category_edit.hide() + layout.addWidget(self.custom_category_edit) + return frame + + def _build_results_panel(self): + panel = QWidget() + layout = QVBoxLayout(panel) + layout.setContentsMargins(4, 0, 0, 0) + layout.setSpacing(7) + toolbar = QHBoxLayout() + title = QLabel("生成结果") + title.setStyleSheet("font-weight: 600; font-size: 15px;") + toolbar.addWidget(title) + self.result_summary_label = QLabel("共 0 张 · 成功 0 张") + self.result_summary_label.setObjectName("suiteResultSummaryLabel") + self.result_summary_label.setStyleSheet( + "background: #eef4ff; color: #2459c4; padding: 3px 8px; border-radius: 4px;" + ) + toolbar.addWidget(self.result_summary_label) + toolbar.addStretch(1) + self.undo_button = QPushButton("撤销删除") + self.undo_button.setObjectName("suiteUndoButton") + self.undo_button.hide() + toolbar.addWidget(self.undo_button) + self.history_button = QPushButton("历史生成") + self.history_button.setObjectName("suiteHistoryButton") + self.history_button.setCheckable(True) + toolbar.addWidget(self.history_button) + self.open_folder_button = QPushButton("打开文件夹") + self.open_folder_button.setObjectName("suiteOpenFolderButton") + toolbar.addWidget(self.open_folder_button) + self.more_button = QToolButton() + self.more_button.setText("⋯") + self.more_button.setToolTip("更多操作") + self.more_button.setAccessibleName("更多操作") + self.more_button.setMinimumSize(32, 32) + toolbar.addWidget(self.more_button) + layout.addLayout(toolbar) + + self.result_scroll = QScrollArea() + self.result_scroll.setObjectName("suiteResultScroll") + self.result_scroll.setWidgetResizable(True) + self.result_scroll.setFrameShape(QFrame.StyledPanel) + self.result_content = QWidget() + self.result_grid = QGridLayout(self.result_content) + self.result_grid.setContentsMargins(12, 12, 12, 12) + self.result_grid.setSpacing(10) + self.result_grid.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.result_scroll.setWidget(self.result_content) + layout.addWidget(self.result_scroll, 1) + + bottom = QHBoxLayout() + self.progress_bar = QProgressBar() + self.progress_bar.setObjectName("suiteProgressBar") + self.progress_bar.setRange(0, 1) + self.progress_bar.setValue(0) + self.progress_bar.setTextVisible(False) + bottom.addWidget(self.progress_bar, 1) + self.elapsed_label = QLabel("套图 0/0(0秒) · 失败 0") + self.elapsed_label.setObjectName("suiteElapsedLabel") + self.elapsed_label.setMinimumWidth(190) + bottom.addWidget(self.elapsed_label) + layout.addLayout(bottom) + return panel + + def _connect_signals(self): + self.task_tabs.currentChanged.connect(self._on_task_changed) + self.task_tabs.tabCloseRequested.connect(self.close_task) + self.add_task_button.clicked.connect(lambda: self.add_task(inherit=True)) + self.account_combo.currentIndexChanged.connect(self._on_account_changed) + self.item_id_edit.editingFinished.connect(self._on_item_finished) + self.pull_button.clicked.connect(self.pull_main_images) + self.add_images_button.clicked.connect(self.choose_images) + self.original_list.filesDropped.connect(self.import_files) + 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.itemClicked.connect(self._on_original_clicked) + self.original_list.itemDoubleClicked.connect(self._preview_original) + for combo in ( + self.platform_combo, + self.country_combo, + self.language_combo, + self.ratio_combo, + ): + combo.currentIndexChanged.connect(self._on_settings_changed) + self.per_image_checkbox.toggled.connect(self._on_settings_changed) + self.prompt_edit.textChanged.connect(self._on_prompt_changed) + self.ai_write_button.clicked.connect(self.start_ai_write) + self.ai_cancel_button.clicked.connect(self.cancel_ai_write) + self.add_category_button.clicked.connect(self.add_custom_category) + self.custom_category_edit.returnPressed.connect(self._commit_custom_category) + self.custom_category_edit.editingFinished.connect(self._finish_custom_category_edit) + self.generate_button.clicked.connect(self.toggle_generation) + self.history_button.toggled.connect(self._toggle_history) + self.open_folder_button.clicked.connect(self.open_project_folder) + self.undo_button.clicked.connect(self.undo_delete) + self.more_button.clicked.connect(self._show_more_menu) + + def _status(self, message, level=None): + _emit_status(self.status_callback, message, level=level) + + def _message(self, title, message, icon=QMessageBox.Warning): + box = QMessageBox(self) + box.setIcon(icon) + box.setWindowTitle(str(title)) + box.setText(str(message)) + box.exec() + + def _confirm(self, title, message, *, destructive=False): + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning if destructive else QMessageBox.Question) + box.setWindowTitle(str(title)) + box.setText(str(message)) + confirm_button = box.addButton("确认", QMessageBox.AcceptRole) + box.addButton("取消", QMessageBox.RejectRole) + if not destructive: + box.setDefaultButton(confirm_button) + box.exec() + return box.clickedButton() is confirm_button + + def refresh_accounts(self): + selected = self.account_combo.currentData() + try: + self.accounts = accounts.list_accounts(path=self.db_path, config=self.config) + except Exception as exc: + self.accounts = [] + self._status("商品套图账号读取失败:%s" % _user_error(exc), "danger") + self._loading = True + try: + self.account_combo.clear() + for account in self.accounts: + self.account_combo.addItem( + "%s(%s)" % (account.account_name, account.alias), + account.alias, + ) + if not self.accounts: + self.account_combo.addItem("暂无账号,请先到④账号管理添加", "") + index = self.account_combo.findData(selected) + if index >= 0: + self.account_combo.setCurrentIndex(index) + finally: + self._loading = False + + def add_task(self, checked=False, inherit=True): + source = self._displayed_state if inherit else None + state = SuiteTaskState( + key=self._next_key, + serial=self._next_serial, + account_alias=(source.account_alias if source is not None else ""), + prompt=(source.prompt if source is not None else ""), + settings=product_suite.normalize_suite_settings( + source.settings if source is not None else None + ), + ) + if not state.account_alias and self.accounts: + state.account_alias = self.accounts[0].alias + self._next_key += 1 + self._next_serial += 1 + self._states[state.key] = state + index = self.task_tabs.addTab("套图任务 %d" % state.serial) + self.task_tabs.setTabData(index, state.key) + if self.task_tabs.currentIndex() == index: + self._on_task_changed(index) + else: + self.task_tabs.setCurrentIndex(index) + return state + + def close_task(self, index): + key = self.task_tabs.tabData(index) + state = self._states.get(key) + if state is None: + return + if state.generation_running(): + if not self._confirm( + "关闭套图任务", + "任务生成中,关闭会取消该任务。确认关闭吗?", + destructive=True, + ): + return + state.worker.cancel() + if state.ai_worker is not None: + state.ai_worker.cancel() + if state.pull_worker is not None: + state.pull_worker.cancel() + self._retired_states.append(state) + self._states.pop(state.key, None) + self.task_tabs.removeTab(index) + if self.task_tabs.count() == 0: + self.add_task(inherit=False) + + def _state_for_index(self, index): + if index < 0: + return None + return self._states.get(self.task_tabs.tabData(index)) + + def _on_task_changed(self, index): + if self._loading: + return + if self._displayed_state is not None: + self._save_controls_to_state(self._displayed_state) + state = self._state_for_index(index) + self._displayed_state = state + if state is not None: + self._load_state(state) + self._offer_pending_ai_result(state) + + def _save_controls_to_state(self, state): + if self._loading or state is None: + return + state.account_alias = str(self.account_combo.currentData() or "") + state.item_id = self.item_id_edit.text().strip() + state.prompt = self.prompt_edit.toPlainText() + state.settings = self._settings_from_controls() + + def _load_state(self, state): + self._loading = True + try: + self.custom_category_edit.hide() + self.add_category_button.show() + index = self.account_combo.findData(state.account_alias) + if index >= 0: + self.account_combo.setCurrentIndex(index) + self.item_id_edit.setText(state.item_id) + self.prompt_edit.setPlainText(state.prompt) + self._set_combo_value(self.platform_combo, state.settings.get("platform")) + self._set_combo_value(self.country_combo, state.settings.get("country")) + self._set_combo_value(self.language_combo, state.settings.get("language")) + self._set_combo_value(self.ratio_combo, state.settings.get("ratio")) + self.per_image_checkbox.setChecked(bool(state.settings.get("per_image_primary"))) + self.history_button.setChecked(bool(state.show_history)) + finally: + self._loading = False + self._refresh_originals(state) + self._rebuild_categories(state) + self._refresh_results(state) + self._apply_running_state(state) + self._refresh_elapsed() + + def _set_combo_value(self, combo, value): + index = combo.findData(value) + if index >= 0: + combo.setCurrentIndex(index) + + def _settings_from_controls(self): + current = self._displayed_state.settings if self._displayed_state else {} + settings = dict(current or {}) + settings.update( + { + "platform": self.platform_combo.currentData(), + "country": self.country_combo.currentData(), + "language": self.language_combo.currentData(), + "ratio": self.ratio_combo.currentData(), + "per_image_primary": self.per_image_checkbox.isChecked(), + } + ) + return product_suite.normalize_suite_settings(settings) + + def _on_account_changed(self, index): + if self._loading or self._displayed_state is None: + return + state = self._displayed_state + alias = str(self.account_combo.currentData() or "") + if state.project_id is not None and alias != state.account_alias: + if not self._confirm( + "切换账号", + "切换账号后,当前任务会改为新的账号与商品上下文。确认继续吗?", + ): + self._loading = True + try: + self._set_combo_value(self.account_combo, state.account_alias) + finally: + self._loading = False + return + self._clear_project_binding(state) + state.account_alias = alias + self._update_context_actions(state) + + def _on_item_finished(self): + if self._loading or self._displayed_state is None: + return + state = self._displayed_state + item_id = self.item_id_edit.text().strip() + if item_id and not item_id.isdigit(): + self.item_id_edit.setStyleSheet("border: 1px solid %s;" % COLOR_DANGER) + self._status("商品ID只能输入数字", "warning") + return + self.item_id_edit.setStyleSheet("") + if state.project_id is not None and item_id != state.item_id: + if not self._confirm( + "切换商品", + "切换商品ID后,原图和生成结果会按新商品重新载入。确认继续吗?", + ): + self._loading = True + try: + self.item_id_edit.setText(state.item_id) + finally: + self._loading = False + return + self._clear_project_binding(state) + state.item_id = item_id + if not item_id: + self.item_id_edit.setStyleSheet("border: 1px solid %s;" % COLOR_DANGER) + self._status("商品ID不能为空", "warning") + self._update_context_actions(state) + return + if item_id and state.account_alias: + self._bind_project(state, load_existing=True) + self._update_context_actions(state) + + def _clear_project_binding(self, state): + state.project_id = None + state.current_job_ids = [] + state.done = state.failed = state.total = 0 + state.started_at = None + if state is self._displayed_state: + self._refresh_originals(state) + self._refresh_results(state) + + def _account_for_alias(self, alias): + return next((account for account in self.accounts if account.alias == alias), None) + + def _valid_context(self, state, *, show_message=True): + if not state.account_alias: + if show_message: + self._message("未选择账号", "请先在顶部选择账号。") + return False + if not state.item_id or not state.item_id.isdigit(): + if show_message: + self._message("商品ID无效", "请输入正确的数字商品ID。") + return False + return True + + def _bind_project(self, state, *, load_existing=False): + if not self._valid_context(state): + return None + account = self._account_for_alias(state.account_alias) + if account is None: + self._message("账号不可用", "所选账号不存在,请到④账号管理刷新账号。") + return None + previous_id = state.project_id + try: + project = image_studio.create_or_get_project( + account, + item_id=state.item_id, + path=self.db_path, + ) + except Exception as exc: + self._message("创建套图任务失败", _user_error(exc)) + return None + state.project_id = int(project.id) + if load_existing and previous_id != state.project_id: + state.prompt = str(project.draft_prompt or "") + state.settings = product_suite.normalize_suite_settings( + image_studio.project_suite_settings(project) + ) + if state is self._displayed_state: + self._load_state(state) + return project + + def _persist_state(self, state): + if state.project_id is None: + return + try: + image_studio.update_project_prompt( + state.project_id, + state.prompt, + path=self.db_path, + ) + image_studio.update_project_suite_settings( + state.project_id, + state.settings, + path=self.db_path, + ) + except Exception as exc: + self._status("商品套图设置保存失败:%s" % _user_error(exc), "danger") + + def _on_settings_changed(self, value=None): + if self._loading or self._displayed_state is None: + return + state = self._displayed_state + state.settings = self._settings_from_controls() + self._persist_state(state) + self._refresh_totals(state) + + def _on_prompt_changed(self): + if self._loading or self._displayed_state is None: + return + state = self._displayed_state + state.prompt = self.prompt_edit.toPlainText() + + def _update_context_actions(self, state): + self.pull_button.setEnabled( + self._valid_context(state, show_message=False) and state.pull_worker is None + ) + + def _start_thread(self, worker, name): + thread = run_worker(worker, thread_name=name, start=False) + token = id(thread) + _PRODUCT_SUITE_THREAD_REFS[token] = (thread, worker) + thread.finished.connect(lambda token=token: _PRODUCT_SUITE_THREAD_REFS.pop(token, None)) + thread.start() + return thread + + def _refresh_originals(self, state): + 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) + self.original_count_label.setText("%d/16" % len(assets)) + self._refresh_totals(state) + + def _original_assets(self, state, *, include_missing=False): + if state is None or state.project_id is None: + return [] + try: + return image_studio.list_assets( + state.project_id, + kind=image_studio.ASSET_KIND_ORIGINAL, + include_missing=include_missing, + path=self.db_path, + ) + except Exception as exc: + self._status("商品原图读取失败:%s" % _user_error(exc), "danger") + return [] + + def choose_images(self, checked=False): + paths, _ = QFileDialog.getOpenFileNames( + self, + "选择商品原图", + "", + "图片文件 (*.png *.jpg *.jpeg *.webp *.bmp)", + ) + if paths: + self.import_files(paths) + + def import_files(self, paths): + self._start_import(file_paths=list(paths or [])) + + def import_clipboard_image(self, image_bytes): + self._start_import(image_bytes=image_bytes) + + def _start_import(self, *, file_paths=None, image_bytes=None): + state = self._displayed_state + if state is None: + return + self._save_controls_to_state(state) + if state.project_id is None and self._bind_project(state) is None: + return + if state.import_worker is not None: + self._status("当前任务正在添加图片,请稍候", "warning") + return + current_count = len(self._original_assets(state, include_missing=True)) + if current_count >= image_studio_images.MAX_ORIGINAL_ASSETS: + self._message("图片数量已满", "每个商品最多添加16张原图。") + return + worker = ProductSuiteImportImagesWorker( + state.project_id, + file_paths=file_paths, + image_bytes=image_bytes, + db_path=self.db_path, + config=self.config, + ) + state.import_worker = worker + worker.finished.connect(lambda result, state=state: self._on_import_finished(state, result)) + worker.cancelled.connect(lambda result, state=state: self._on_import_finished(state, result)) + worker.failed.connect(lambda row, error, state=state: self._on_import_failed(state, error)) + state.import_thread = self._start_thread(worker, "商品套图添加原图") + self._status("正在后台添加商品原图", "info") + + def _on_import_failed(self, state, error): + self._status("添加商品原图失败:%s" % _user_error(error), "danger") + + def _on_import_finished(self, state, result): + state.import_worker = None + state.import_thread = None + if result.get("ok") is False: + self._message("添加商品原图失败", _user_error(result.get("error"))) + else: + errors = result.get("errors") or [] + count = len(result.get("assets") or []) + if errors: + self._message( + "部分图片未添加", + "已添加%d张,%d张失败。\n%s" % ( + count, + len(errors), + _user_error(errors[0].get("error")), + ), + ) + else: + self._status("已添加%d张商品原图" % count, "success") + if state is self._displayed_state: + self._refresh_originals(state) + + def delete_original(self, asset_id): + state = self._displayed_state + if state is None or state.generation_running(): + self._status("生成中不能删除当前任务的商品原图", "warning") + return + if not self._confirm("删除商品原图", "确认从当前商品的原图列表移除这张图片吗?"): + return + try: + image_studio.remove_asset_if_unused(asset_id, path=self.db_path) + except Exception as exc: + self._message("不能删除商品原图", _user_error(exc)) + return + self._refresh_originals(state) + self._status("商品原图已移除", "success") + + def reorder_originals(self, visible_ids): + state = self._displayed_state + if state is None or state.project_id is None: + return + all_assets = self._original_assets(state, include_missing=True) + hidden_ids = [int(asset.id) for asset in all_assets if int(asset.id) not in set(visible_ids)] + try: + image_studio.reorder_original_assets( + state.project_id, + list(visible_ids) + hidden_ids, + path=self.db_path, + ) + except Exception as exc: + self._message("原图排序失败", _user_error(exc)) + self._refresh_originals(state) + + def _asset_for_item(self, item): + state = self._displayed_state + if state is None or item is None: + return None + value = item.data(Qt.UserRole) + if value is None: + return None + asset_id = int(value) + return next( + (asset for asset in self._original_assets(state, include_missing=True) if int(asset.id) == asset_id), + None, + ) + + def _on_original_clicked(self, item): + asset = self._asset_for_item(item) + if asset is None and item.data(Qt.UserRole) is None: + self.choose_images() + return + if asset is not None and not _asset_usable(asset) and asset.remote_url: + self._queue_original_downloads(self._displayed_state, [asset]) + + def _preview_original(self, item): + asset = self._asset_for_item(item) + if asset is None: + return + if not _asset_usable(asset): + self._queue_original_downloads(self._displayed_state, [asset]) + self._status("原图正在后台下载,完成后可双击预览", "info") + return + ProductSuitePreviewDialog(asset.local_path, "商品原图预览", self).exec() + + def pull_main_images(self, checked=False): + state = self._displayed_state + if state is None: + return + self._save_controls_to_state(state) + if not self._valid_context(state): + return + if state.pull_worker is not None: + self._status("当前任务正在拉取蝦皮主图", "info") + return + existing = [asset for asset in self._original_assets(state, include_missing=False) if _asset_usable(asset)] + if existing and not self._confirm( + "覆盖拉取蝦皮主图", + "本地已有商品原图。继续拉取会刷新蝦皮原图列表,本地上传图片会保留。确认继续吗?", + ): + return + worker = ImageStudioPullImagesWorker( + state.account_alias, + state.item_id, + db_path=self.db_path, + config=self.config, + ) + state.pull_worker = worker + worker.finished.connect(lambda result, state=state: self._on_pull_finished(state, result)) + worker.cancelled.connect(lambda result, state=state: self._on_pull_finished(state, result)) + worker.failed.connect(lambda row, error, state=state: self._on_pull_failed(state, error)) + state.pull_thread = self._start_thread(worker, "商品套图拉取蝦皮主图") + if state is self._displayed_state: + self.pull_button.setText("正在拉取...") + self._update_context_actions(state) + self._status("开始拉取蝦皮主图,可继续操作其他套图任务", "info") + + def _on_pull_failed(self, state, error): + self._status("拉取蝦皮主图失败:%s" % _user_error(error), "danger") + + def _on_pull_finished(self, state, result): + state.pull_worker = None + state.pull_thread = None + if state is self._displayed_state: + self.pull_button.setText("拉取蝦皮主图") + if result.get("ok") is False: + self._message("拉取蝦皮主图失败", _user_error(result.get("error"))) + else: + project = result.get("project") + if project is not None: + state.project_id = int(project.id) + assets = [ + asset + for asset in (result.get("assets") or []) + if asset.status != image_studio.ASSET_STATUS_MISSING + ][: image_studio_images.MAX_ORIGINAL_ASSETS] + self._queue_original_downloads(state, assets) + self._status("已读取%d张蝦皮主图,正在后台下载" % len(assets), "success") + if state is self._displayed_state: + self._refresh_originals(state) + self._update_context_actions(state) + + def _queue_original_downloads(self, state, assets): + if state is None: + return + queued = set(state.download_queue) | set(state.downloads) + for asset in assets or []: + if _asset_usable(asset) or not getattr(asset, "remote_url", None): + continue + asset_id = int(asset.id) + if asset_id not in queued: + state.download_queue.append(asset_id) + queued.add(asset_id) + self._start_queued_downloads(state) + + def _start_queued_downloads(self, state): + while state.download_queue and len(state.downloads) < ORIGINAL_DOWNLOAD_CONCURRENCY: + asset_id = int(state.download_queue.pop(0)) + worker = ImageStudioDownloadOriginalWorker( + asset_id, + db_path=self.db_path, + config=self.config, + max_retries=2, + ) + worker.finished.connect( + lambda result, state=state, asset_id=asset_id: self._on_download_finished( + state, asset_id, result + ) + ) + worker.cancelled.connect( + lambda result, state=state, asset_id=asset_id: self._on_download_finished( + state, asset_id, result + ) + ) + worker.failed.connect( + lambda row, error, state=state, asset_id=asset_id: self._status( + "商品原图 #%d 下载失败:%s" % (asset_id, _user_error(error)), + "danger", + ) + ) + thread = self._start_thread(worker, "商品套图下载原图") + state.downloads[asset_id] = (worker, thread) + + def _on_download_finished(self, state, asset_id, result): + state.downloads.pop(int(asset_id), None) + if result.get("ok") is False: + self._status( + "商品原图 #%d 下载失败:%s" % (asset_id, _user_error(result.get("error"))), + "danger", + ) + elif not result.get("cancelled"): + self._status("商品原图 #%d 已下载" % asset_id, "success") + if state is self._displayed_state: + self._refresh_originals(state) + self._start_queued_downloads(state) + + def _rebuild_categories(self, state): + while self.category_grid.count(): + item = self.category_grid.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + settings = product_suite.normalize_suite_settings(state.settings) + state.settings = settings + categories = settings["categories"] + ordered = product_suite.category_order(settings) + if state.active_category not in ordered: + state.active_category = ordered[0] + 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; }" + ) + 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) + 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: + return + settings = product_suite.normalize_suite_settings(state.settings) + categories = dict(settings["categories"]) + categories[name] = max(0, int(categories.get(name, 0)) + int(delta)) + settings["categories"] = categories + state.settings = settings + self._persist_state(state) + self._rebuild_categories(state) + + def add_custom_category(self, checked=False): + if self._displayed_state is None: + return + self.add_category_button.hide() + self.custom_category_edit.clear() + self.custom_category_edit.show() + self.custom_category_edit.setFocus(Qt.OtherFocusReason) + + def _commit_custom_category(self): + state = self._displayed_state + if state is None: + return + name = self.custom_category_edit.text() + settings = product_suite.normalize_suite_settings(state.settings) + error = product_suite.suite_name_error(name, settings["categories"]) + if error: + self._message("分类名称无效", error) + self.custom_category_edit.setFocus(Qt.OtherFocusReason) + return + categories = dict(settings["categories"]) + categories[name] = 1 + 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() + self._rebuild_categories(state) + + def _finish_custom_category_edit(self): + if not self.custom_category_edit.isHidden() and not self.custom_category_edit.text().strip(): + self.custom_category_edit.hide() + self.add_category_button.show() + + def rename_custom_category(self, old_name): + state = self._displayed_state + if state is None or old_name in product_suite.FIXED_CATEGORIES: + return + name, ok = QInputDialog.getText( + self, + "重命名自定义分类", + "分类名称", + text=old_name, + ) + if not ok or name == old_name: + return + settings = product_suite.normalize_suite_settings(state.settings) + error = product_suite.suite_name_error(name, settings["categories"], old_name=old_name) + if error: + self._message("分类名称无效", error) + return + categories = dict(settings["categories"]) + count = categories.pop(old_name, 1) + categories[name] = count + settings["categories"] = categories + settings["custom_category_order"] = [ + name if value == old_name else value + 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) + + def delete_custom_category(self, name): + state = self._displayed_state + if state is None or name in product_suite.FIXED_CATEGORIES: + return + settings = product_suite.normalize_suite_settings(state.settings) + categories = dict(settings["categories"]) + categories.pop(name, None) + settings["categories"] = categories + settings["custom_category_order"] = [ + 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) + + def _refresh_totals(self, state): + image_count = sum(1 for asset in self._original_assets(state) if _asset_usable(asset)) + total = product_suite.suite_total_count(state.settings, image_count) + self.category_total_label.setText("合计 %d 张" % total) + if not state.generation_running(): + self.generate_button.setText("生成套图(%d)" % total) + + def start_ai_write(self, checked=False): + state = self._displayed_state + if state is None: + return + if state.ai_worker is not None: + self._status("当前套图任务正在AI帮写", "info") + return + local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] + if not local_assets: + self._message("缺少商品原图", "请先添加或拉取至少一张可用商品原图。") + return + self._save_controls_to_state(state) + context = ( + "商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s" + % ( + state.item_id or "未填写", + state.settings["platform"], + state.settings["country"], + state.settings["language"], + state.prompt or "无", + ) + ) + instruction = ( + "请为电商商品图片生成简洁、可执行的商品卖点与画面要求。" + "只输出可直接用于生图的中文提示词,包含核心卖点、目标人群、使用场景和禁用元素," + "不要虚构商品参数。" + ) + state.ai_prompt_snapshot = state.prompt + state.ai_started_at = time.monotonic() + worker = ProductSuiteAiWriteWorker( + instruction, + context, + config=self.config, + cmhub_config_path=self.cmhub_config_path, + ) + state.ai_worker = worker + worker.finished.connect(lambda result, state=state: self._on_ai_write_finished(state, result)) + worker.cancelled.connect(lambda result, state=state: self._on_ai_write_finished(state, result)) + worker.failed.connect(lambda row, error, state=state: self._on_ai_write_failed(state, error)) + state.ai_thread = self._start_thread(worker, "商品套图AI帮写") + if state is self._displayed_state: + self._apply_running_state(state) + self._status("AI帮写已开始,可切换到其他套图任务", "info") + + def cancel_ai_write(self, checked=False): + state = self._displayed_state + if state is not None and state.ai_worker is not None: + state.ai_worker.cancel() + self._status("已请求取消AI帮写", "warning") + + def _on_ai_write_failed(self, state, error): + self._status("AI帮写失败:%s" % _user_error(error), "danger") + + def _on_ai_write_finished(self, state, result): + state.ai_worker = None + state.ai_thread = None + state.ai_started_at = None + if result.get("ok") is False: + self._message("AI帮写失败", _user_error(result.get("error"))) + elif not result.get("cancelled"): + text = str(result.get("text") or "").strip() + if text: + if state.prompt != state.ai_prompt_snapshot: + state.pending_ai_text = text + if state is self._displayed_state: + self._offer_pending_ai_result(state) + else: + self._status("AI帮写已完成,切回对应套图任务后确认是否采用", "success") + else: + state.prompt = text + self._persist_state(state) + if state is self._displayed_state: + self._loading = True + try: + self.prompt_edit.setPlainText(text) + finally: + self._loading = False + self._status("AI帮写已完成", "success") + if state is self._displayed_state: + self._apply_running_state(state) + + def _offer_pending_ai_result(self, state): + if not state.pending_ai_text: + return + if self._confirm( + "采用AI帮写结果", + "AI帮写期间卖点内容已被修改。是否用AI帮写结果覆盖当前内容?", + ): + state.prompt = state.pending_ai_text + self._loading = True + try: + self.prompt_edit.setPlainText(state.prompt) + finally: + self._loading = False + self._persist_state(state) + state.pending_ai_text = "" + + def toggle_generation(self, checked=False): + state = self._displayed_state + if state is None: + return + if state.generation_running(): + if self._confirm( + "停止生成套图", + "确认取消当前任务吗?已提交任务会在安全边界停止。", + destructive=True, + ): + state.worker.cancel() + self.generate_button.setText("正在停止...") + self.generate_button.setEnabled(False) + self._status("已请求停止当前套图任务", "warning") + return + self.start_generation(state) + + def start_generation(self, state, specs=None): + if state.generation_running(): + self._status("当前套图任务仍在生成", "warning") + return False + if state is self._displayed_state: + self._save_controls_to_state(state) + if state.project_id is None and self._bind_project(state) is None: + return False + local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)] + if not local_assets: + self._message("缺少商品原图", "请先添加图片,或等待蝦皮主图下载完成。") + return False + if specs is None and not state.prompt.strip(): + self._message("商品卖点不能为空", "请填写商品卖点与要求,或使用AI帮写。") + return False + specs = list(specs or product_suite.build_job_specs( + local_assets, + state.prompt, + state.settings, + state.item_id, + )) + if not specs: + self._message("生成数量为0", "请至少把一个套图分类的数量设为1。") + return False + if len(specs) > product_suite.MAX_GENERATION_COUNT_WITHOUT_CONFIRM: + if not self._confirm( + "确认生成数量", + "本轮将生成%d张图片,预计耗时和点数较多。确认继续吗?" % len(specs), + ): + return False + self._persist_state(state) + worker = ProductSuiteGenerateWorker( + state.project_id, + specs, + aspect_ratio=state.settings["ratio"], + db_path=self.db_path, + config=self.config, + cmhub_config_path=self.cmhub_config_path, + ) + state.worker = worker + state.done = 0 + state.failed = 0 + state.total = len(specs) + state.started_at = time.monotonic() + state.current_job_ids = [] + state.show_history = False + worker.progress.connect(lambda payload, state=state: self._on_generation_progress(state, payload)) + worker.finished.connect(lambda result, state=state: self._on_generation_finished(state, result)) + worker.cancelled.connect(lambda result, state=state: self._on_generation_finished(state, result)) + worker.failed.connect(lambda row, error, state=state: self._on_generation_failed(state, error)) + state.thread = self._start_thread(worker, "商品套图生成") + if state is self._displayed_state: + self._loading = True + try: + self.history_button.setChecked(False) + finally: + self._loading = False + self._apply_running_state(state) + self._refresh_results(state) + self._status("商品套图生成已开始,共%d张;可切换到其他任务" % len(specs), "info") + return True + + def _on_generation_progress(self, state, payload): + state.total = int(payload.get("total", state.total) or state.total) + state.done = int(payload.get("done", state.done) or 0) + state.failed = int(payload.get("failed", state.failed) or 0) + job_ids = payload.get("job_ids") + if job_ids: + state.current_job_ids = [int(job_id) for job_id in job_ids] + if state is self._displayed_state: + self._refresh_results(state) + self._refresh_elapsed() + + def _on_generation_failed(self, state, error): + self._status("商品套图生成失败:%s" % _user_error(error), "danger") + + def _on_generation_finished(self, state, result): + state.worker = None + state.thread = None + state.done = int(result.get("success", 0) or 0) + int(result.get("failed", 0) or 0) + int( + result.get("cancelled", 0) or 0 + ) + state.failed = int(result.get("failed", state.failed) or 0) + state.total = int(result.get("total", state.total) or state.total) + elapsed = int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0 + state.started_at = None + if state is self._displayed_state: + self._apply_running_state(state) + self._refresh_results(state) + self._refresh_elapsed() + if state.key not in self._states: + return + if result.get("ok") is False: + if state is self._displayed_state: + self._message("商品套图生成失败", _user_error(result.get("error"))) + else: + self._status("套图任务%d生成失败" % state.serial, "danger") + return + success = int(result.get("success", 0) or 0) + cancelled = int(result.get("cancelled", 0) or 0) + if state is self._displayed_state: + self._message( + "商品套图生成完成", + "本轮共%d张:成功%d张,失败%d张,停止%d张;总用时%d秒。" + % (state.total, success, state.failed, cancelled, elapsed), + icon=QMessageBox.Information, + ) + self._status("商品套图生成完成:成功%d张,失败%d张" % (success, state.failed), "success") + + def _apply_running_state(self, state): + generation_running = state.generation_running() + self.pull_button.setText("正在拉取..." if state.pull_worker is not None else "拉取蝦皮主图") + self.account_combo.setEnabled(not generation_running and state.pull_worker is None) + 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) + for widget in ( + self.platform_combo, + self.country_combo, + self.language_combo, + self.ratio_combo, + self.per_image_checkbox, + self.prompt_edit, + self.add_category_button, + ): + widget.setEnabled(not generation_running) + self.generate_button.setEnabled(True) + if generation_running: + self.generate_button.setText("停止生成") + self.generate_button.setStyleSheet( + "QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }" + "QPushButton:hover { background: #a40e26; }" + ) + else: + self.generate_button.setStyleSheet( + "QPushButton { background: #2f6fed; color: white; border-color: #2459c4; font-weight: 600; }" + "QPushButton:hover { background: #245fce; }" + ) + self._refresh_totals(state) + ai_running = state.ai_worker is not None + self.ai_write_button.setEnabled(not ai_running and not generation_running) + self.ai_cancel_button.setVisible(ai_running) + self._update_context_actions(state) + + def _refresh_elapsed(self): + state = self._displayed_state + if state is None: + return + elapsed = int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0 + self.elapsed_label.setText( + "套图 %d/%d(%d秒) · 失败 %d" % ( + state.done, + state.total, + elapsed, + state.failed, + ) + ) + self.progress_bar.setRange(0, max(1, state.total)) + self.progress_bar.setValue(min(state.done, max(1, state.total))) + if state.ai_worker is not None and state.ai_started_at is not None: + ai_elapsed = int(max(0, time.monotonic() - state.ai_started_at)) + self.ai_write_button.setText("AI 帮写中(%d秒)" % ai_elapsed) + else: + self.ai_write_button.setText("AI 帮写") + + def _toggle_history(self, checked): + if self._loading or self._displayed_state is None: + return + self._displayed_state.show_history = bool(checked) + self.history_button.setText("返回本轮" if checked else "历史生成") + self._refresh_results(self._displayed_state) + + def _jobs_for_state(self, state): + if state.project_id is None: + return [] + try: + jobs = image_studio.list_jobs(state.project_id, path=self.db_path) + except Exception as exc: + self._status("生成结果读取失败:%s" % _user_error(exc), "danger") + return [] + if state.show_history: + return jobs + current = set(state.current_job_ids) + return [job for job in jobs if int(job.id) in current] + + def _refresh_results(self, state): + while self.result_grid.count(): + item = self.result_grid.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + jobs = self._jobs_for_state(state) + assets = {} + if state.project_id is not None: + try: + assets = { + int(asset.id): asset + for asset in image_studio.list_assets(state.project_id, path=self.db_path) + } + except Exception: + assets = {} + success = 0 + if not jobs: + empty = QLabel("还没有生成结果,点击左侧“生成套图”开始") + empty.setAlignment(Qt.AlignCenter) + empty.setStyleSheet("color: #6b7280; padding: 48px;") + self.result_grid.addWidget(empty, 0, 0, 1, 3) + else: + columns = max(2, min(4, max(1, self.result_scroll.viewport().width() // 200))) + for index, job in enumerate(jobs): + asset = assets.get(int(job.output_asset_id or 0)) + if job.status == "succeeded" and asset is not None and _asset_usable(asset): + success += 1 + card = SuiteResultCard(job, asset) + card.previewRequested.connect(self.preview_job) + card.retryRequested.connect(self.retry_job) + card.menuRequested.connect(self._show_job_menu) + card.deleteRequested.connect(self.delete_job_asset) + self.result_grid.addWidget(card, index // columns, index % columns) + self.result_summary_label.setText("共 %d 张 · 成功 %d 张" % (len(jobs), success)) + self.undo_button.setVisible(bool(state.undo_records)) + self.history_button.setText("返回本轮" if state.show_history else "历史生成") + + def preview_job(self, job): + asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None + if asset is None or not _asset_usable(asset): + self._status("这张生成图片尚不可预览", "warning") + return + ProductSuitePreviewDialog(asset.local_path, "%s预览" % job.job_type, self).exec() + + def retry_job(self, job): + state = self._displayed_state + if state is None: + return + if state.generation_running(): + self._message("当前任务正在生成", "请等待当前生成结束或停止后再重试单张图片。") + return + spec = { + "source_asset_id": job.source_asset_id, + "job_type": job.job_type, + "prompt": job.prompt, + } + self.start_generation(state, specs=[spec]) + + def _show_job_menu(self, job, global_position): + menu = QMenu(self) + preview_action = menu.addAction("预览") + copy_action = menu.addAction("复制路径") + folder_action = menu.addAction("打开文件夹") + retry_action = menu.addAction("重新生成") + delete_action = menu.addAction("删除") + action = menu.exec(global_position) + asset = image_studio.get_asset(job.output_asset_id, path=self.db_path) if job.output_asset_id else None + if action is preview_action: + self.preview_job(job) + elif action is copy_action: + if asset is None or not _asset_usable(asset): + self._status("当前图片没有可复制的本地路径", "warning") + else: + QApplication.clipboard().setText(asset.local_path) + self._status("生成图片路径已复制", "success") + elif action is folder_action: + if asset is not None and _asset_usable(asset): + try: + file_manager.open_in_file_manager(os.path.dirname(asset.local_path)) + except Exception as exc: + self._message("打开文件夹失败", _user_error(exc)) + else: + self.open_project_folder() + elif action is retry_action: + self.retry_job(job) + elif action is delete_action: + self.delete_job_asset(job) + + def delete_job_asset(self, job): + state = self._displayed_state + if state is None or not job.output_asset_id: + return + if not self._confirm("删除生成图片", "确认把这张图片移到当前商品的废纸篓吗?"): + return + try: + record = image_studio_images.trash_generated_asset( + job.output_asset_id, + path=self.db_path, + config=self.config, + ) + except Exception as exc: + self._message("删除生成图片失败", _user_error(exc)) + return + state.undo_records.append(record) + self._refresh_results(state) + self._status("生成图片已删除,可点击“撤销删除”恢复", "success") + + def undo_delete(self, checked=False): + state = self._displayed_state + if state is None or not state.undo_records: + return + record = state.undo_records.pop() + try: + image_studio_images.restore_trashed_asset( + record, + path=self.db_path, + config=self.config, + ) + except Exception as exc: + state.undo_records.append(record) + self._message("撤销删除失败", _user_error(exc)) + return + self._refresh_results(state) + self._status("生成图片已恢复", "success") + + def _show_more_menu(self): + menu = QMenu(self) + delete_all_action = menu.addAction("删除当前视图全部图片") + if menu.exec(self.more_button.mapToGlobal(self.more_button.rect().bottomLeft())) is delete_all_action: + self.delete_all_visible_results() + + def delete_all_visible_results(self): + state = self._displayed_state + if state is None: + return + jobs = [job for job in self._jobs_for_state(state) if job.output_asset_id] + if not jobs: + self._status("当前视图没有可删除的生成图片", "warning") + return + if not self._confirm( + "删除全部生成图片", + "确认把当前视图中的%d张生成图片移到废纸篓吗?" % len(jobs), + destructive=True, + ): + return + failures = 0 + for job in jobs: + try: + state.undo_records.append( + image_studio_images.trash_generated_asset( + job.output_asset_id, + path=self.db_path, + config=self.config, + ) + ) + except Exception: + failures += 1 + self._refresh_results(state) + if failures: + self._message("部分图片未删除", "有%d张图片无法删除,请查看当前结果。" % failures) + else: + self._status("当前视图的生成图片已移到废纸篓", "success") + + def open_project_folder(self, checked=False): + state = self._displayed_state + if state is None or state.project_id is None: + self._message("未绑定商品", "请先选择账号并输入商品ID。") + return + project = image_studio.get_project(state.project_id, path=self.db_path) + if project is None: + self._message("商品任务不存在", "当前商品套图任务已经不存在。") + return + target = image_studio.default_project_image_dirs(project, config=self.config)["generated"] + os.makedirs(target, exist_ok=True) + try: + file_manager.open_in_file_manager(target) + except Exception as exc: + self._message("打开文件夹失败", _user_error(exc)) + + def resizeEvent(self, event): + super().resizeEvent(event) + if not self._result_refresh_pending: + self._result_refresh_pending = True + QTimer.singleShot(80, self._refresh_after_resize) + + def _refresh_after_resize(self): + self._result_refresh_pending = False + if self._displayed_state is not None: + self._refresh_results(self._displayed_state) + + def closeEvent(self, event): + for state in list(self._states.values()) + list(self._retired_states): + for worker in (state.worker, state.pull_worker, state.import_worker, state.ai_worker): + if worker is not None and hasattr(worker, "cancel"): + worker.cancel() + for worker, thread in list(state.downloads.values()): + worker.cancel() + super().closeEvent(event) diff --git a/app/gui/widgets.py b/app/gui/widgets.py index 1983382..7801c05 100644 --- a/app/gui/widgets.py +++ b/app/gui/widgets.py @@ -60,6 +60,7 @@ TAB_TITLES = [ "③ 更新蝦皮", "④ 账号管理", "⑤ 设置", + "⑥ 商品套图", ] TAB_STYLE = """ diff --git a/app/gui/workers.py b/app/gui/workers.py index 9dbcbb8..f00e9d5 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -7,7 +7,7 @@ import re import threading import time -from .. import image_studio, image_studio_export, image_studio_generation, image_studio_images +from .. import ai, image_studio, image_studio_export, image_studio_generation, image_studio_images from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts from .widgets import * @@ -67,6 +67,10 @@ def _format_image_studio_event(event): return prefix +def _format_product_suite_event(event): + return _format_image_studio_event(event).replace("[AI工场]", "[商品套图]", 1) + + def _image_studio_user_detail(detail): text = diagnostics.redact_log_text(str(detail or "")).replace("\r", " ").replace("\n", " ").strip() text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text) @@ -286,6 +290,158 @@ class ImageStudioGenerateJobsWorker(BaseWorker): return summary +class ProductSuiteGenerateWorker(BaseWorker): + """Create and run independently configured product-suite jobs.""" + + def __init__( + self, + project_id, + job_specs, + *, + aspect_ratio="1:1", + db_path=None, + config=None, + cmhub_config_path=None, + ): + super().__init__() + self.project_id = int(project_id) + self.job_specs = [dict(spec) for spec in (job_specs or [])] + self.aspect_ratio = str(aspect_ratio or "1:1") + self.db_path = db_path + self.config = config + self.cmhub_config_path = cmhub_config_path + self._done = 0 + self._failed = 0 + self._lock = threading.Lock() + + def execute(self): + total = len(self.job_specs) + if total <= 0: + raise ValueError("商品套图生成任务不能为空") + jobs = [] + for spec in self.job_specs: + if self.should_cancel(): + break + jobs.append( + image_studio.create_job( + self.project_id, + source_asset_id=spec.get("source_asset_id"), + job_type=spec.get("job_type") or "套图", + prompt=spec.get("prompt") or "", + generation_source="cmhub", + provider="cmhub", + path=self.db_path, + ) + ) + self.progress.emit( + { + "total": len(jobs), + "done": 0, + "failed": 0, + "job_ids": [job.id for job in jobs], + } + ) + + def on_event(payload): + event = dict(payload or {}) + self.log.emit(_format_product_suite_event(event)) + if event.get("step") == "job_done": + with self._lock: + self._done += 1 + if event.get("result") != "success": + self._failed += 1 + progress = { + "total": len(jobs), + "done": self._done, + "failed": self._failed, + "job_ids": [job.id for job in jobs], + } + self.progress.emit(progress) + + summary = image_studio_generation.run_jobs( + jobs, + aspect_ratio=self.aspect_ratio, + config=self.config, + cmhub_config_path=self.cmhub_config_path, + path=self.db_path, + should_stop=self.should_cancel, + on_event=on_event, + ) + summary["project_id"] = self.project_id + summary["job_ids"] = [job.id for job in jobs] + return summary + + +class ProductSuiteAiWriteWorker(BaseWorker): + """Generate product selling-point copy without blocking the suite workspace.""" + + def __init__( + self, + instruction, + context, + *, + config=None, + cmhub_config_path=None, + ): + super().__init__() + self.instruction = str(instruction or "") + self.context = str(context or "") + self.config = config + self.cmhub_config_path = cmhub_config_path + + def execute(self): + if self.should_cancel(): + return {"cancelled": True} + text = ai.gen_title( + self.instruction, + self.context, + config=self.config, + cmhub_config_path=self.cmhub_config_path, + ) + if self.should_cancel(): + return {"cancelled": True} + return {"text": str(text or "").strip()} + + +class ProductSuiteImportImagesWorker(BaseWorker): + """Validate and copy local product images outside the GUI thread.""" + + def __init__( + self, + project_id, + *, + file_paths=None, + image_bytes=None, + filename_hint="clipboard.png", + db_path=None, + config=None, + ): + super().__init__() + self.project_id = int(project_id) + self.file_paths = list(file_paths or []) + self.image_bytes = bytes(image_bytes) if image_bytes is not None else None + self.filename_hint = str(filename_hint or "clipboard.png") + self.db_path = db_path + self.config = config + + def execute(self): + if self.image_bytes is not None: + asset = image_studio_images.import_original_bytes( + self.project_id, + self.image_bytes, + filename_hint=self.filename_hint, + path=self.db_path, + config=self.config, + ) + return {"assets": [asset], "errors": [], "limit": 16} + return image_studio_images.import_original_files( + self.project_id, + self.file_paths, + path=self.db_path, + config=self.config, + ) + + class ImageStudioResumeJobsWorker(BaseWorker): """Resume submitted/running or failed-download AI studio jobs.""" diff --git a/app/image_studio.py b/app/image_studio.py index bfcdc1a..2927492 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import uuid from contextlib import contextmanager @@ -41,6 +42,7 @@ class ImageStudioProject: target_main_count: int target_detail_count: int draft_prompt: Optional[str] + suite_settings_json: str status: str created_at: str updated_at: str @@ -357,6 +359,32 @@ def update_project_prompt(project_id, draft_prompt, path=None, conn=None): return get_project(project_id, conn=database) +def project_suite_settings(project) -> dict: + raw = _get(project, "suite_settings_json", "{}") or "{}" + try: + value = json.loads(str(raw)) + except (TypeError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +def update_project_suite_settings(project_id, settings, path=None, conn=None): + if not isinstance(settings, dict): + raise db.DbError("商品套图设置必须是对象") + payload = json.dumps(settings, ensure_ascii=False, sort_keys=True) + with _connection(conn, path) as database: + with database: + database.execute( + """ + UPDATE image_studio_projects + SET suite_settings_json = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL + """, + (payload, _now(), int(project_id)), + ) + return get_project(project_id, conn=database) + + def soft_delete_project(project_id, reason="", path=None, conn=None): now = _now() with _connection(conn, path) as database: @@ -461,6 +489,47 @@ def list_assets(project_id, kind=None, include_missing=True, path=None, conn=Non return _fetch_all(database, sql, params, ImageStudioAsset) +def reorder_original_assets(project_id, asset_ids, path=None, conn=None): + ordered_ids = [int(asset_id) for asset_id in asset_ids] + if len(ordered_ids) != len(set(ordered_ids)): + raise db.DbError("商品原图排序不能包含重复图片") + with _connection(conn, path) as database: + rows = database.execute( + """ + SELECT id FROM image_studio_assets + WHERE project_id = ? AND kind = ? + ORDER BY source_order, id + """, + (int(project_id), ASSET_KIND_ORIGINAL), + ).fetchall() + existing_ids = [int(row["id"]) for row in rows] + if set(existing_ids) != set(ordered_ids): + raise db.DbError("商品原图排序必须包含当前项目的全部原图") + now = _now() + with database: + for source_order, asset_id in enumerate(ordered_ids, 1): + database.execute( + """ + UPDATE image_studio_assets + SET source_order = ?, updated_at = ? + WHERE id = ? AND project_id = ? AND kind = ? + """, + ( + source_order, + now, + asset_id, + int(project_id), + ASSET_KIND_ORIGINAL, + ), + ) + return list_assets( + project_id, + kind=ASSET_KIND_ORIGINAL, + path=path, + conn=database, + ) + + def asset_reference_counts(asset_id, path=None, conn=None): """Return selection/job references for one asset before pool removal.""" @@ -506,7 +575,7 @@ def remove_asset_if_unused(asset_id, path=None, conn=None): return asset -def sync_original_asset_urls(project_id, image_urls, path=None, conn=None): +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.""" normalized = [] @@ -538,6 +607,13 @@ def sync_original_asset_urls(project_id, image_urls, path=None, conn=None): for row in existing_rows if row["remote_url"] } + local_only_count = sum( + 1 + for row in existing_rows + if not row["remote_url"] and row["status"] != ASSET_STATUS_MISSING + ) + remote_limit = max(0, int(max_assets) - local_only_count) + normalized = normalized[:remote_limit] active_ids = set() for item in normalized: row = by_url.get(item["src"]) @@ -577,7 +653,11 @@ def sync_original_asset_urls(project_id, image_urls, path=None, conn=None): ), ) active_ids.add(int(cursor.lastrowid)) - missing_ids = [int(row["id"]) for row in existing_rows if int(row["id"]) not in active_ids] + missing_ids = [ + int(row["id"]) + for row in existing_rows + if row["remote_url"] and int(row["id"]) not in active_ids + ] if missing_ids: placeholders = ",".join("?" for _ in missing_ids) database.execute( @@ -677,6 +757,23 @@ def get_job(job_id, path=None, conn=None): ) +def list_jobs(project_id, *, statuses=None, path=None, conn=None): + clauses = ["project_id = ?"] + params = [int(project_id)] + requested_statuses = [str(status) for status in (statuses or []) if str(status)] + if requested_statuses: + invalid = set(requested_statuses) - JOB_STATUSES + if invalid: + raise db.DbError("图片生成任务状态无效") + placeholders = ",".join("?" for _ in requested_statuses) + clauses.append(f"status IN ({placeholders})") + params.extend(requested_statuses) + sql = "SELECT * FROM image_studio_jobs WHERE " + " AND ".join(clauses) + sql += " ORDER BY updated_at DESC, id DESC" + with _connection(conn, path) as database: + return _fetch_all(database, sql, params, ImageStudioJob) + + def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points_balance=None, path=None, conn=None): now = _now() with _connection(conn, path) as database: diff --git a/app/image_studio_generation.py b/app/image_studio_generation.py index 535d8f9..6bd5623 100644 --- a/app/image_studio_generation.py +++ b/app/image_studio_generation.py @@ -13,6 +13,7 @@ from .version import APP_VERSION MAX_CMHUB_IMAGE_STUDIO_WORKERS = 5 +_GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_CMHUB_IMAGE_STUDIO_WORKERS) class ImageStudioGenerationError(RuntimeError): @@ -171,7 +172,7 @@ def run_jobs( with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit( - _run_one_job, + _run_one_job_with_global_slot, job.id, runtime, cfg, @@ -206,6 +207,11 @@ def run_jobs( return summary +def _run_one_job_with_global_slot(*args): + with _GLOBAL_IMAGE_STUDIO_SLOTS: + return _run_one_job(*args) + + def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, should_stop, on_event): job = image_studio.get_job(job_id, path=db_path) if job is None: @@ -237,14 +243,24 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho source_asset, runtime, config, + aspect_ratio, db_path, should_stop, on_event, ) image_studio.update_job_status(job.id, "running", path=db_path) request_result = _poll_job(job.id, request_result["task_id"], runtime, request_result, db_path, should_stop, on_event) + _raise_if_stopped(should_stop) out_path = _output_path(project, job, image_root) saved_path = _download_and_save_job_image(request_result, out_path, config, on_event, job.id) + try: + _raise_if_stopped(should_stop) + except ImageStudioGenerationError: + try: + if os.path.isfile(saved_path): + os.remove(saved_path) + finally: + raise asset = image_studio.add_asset( project.id, _generated_kind(job.job_type), @@ -278,7 +294,16 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho return {"job": updated, "status": status, "error": str(exc)} -def _submit_or_resume_job(job, source_asset, runtime, config, db_path, should_stop, on_event): +def _submit_or_resume_job( + job, + source_asset, + runtime, + config, + aspect_ratio, + db_path, + should_stop, + on_event, +): if job.task_id: _notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": job.task_id}) return _request_result(job.task_id, runtime, config) @@ -291,7 +316,7 @@ def _submit_or_resume_job(job, source_asset, runtime, config, db_path, should_st "model": runtime["alias"], "image_base64": ai._image_data_url(source_path), "resolution": ai._normalize_cmhub_resolution(resolution), - "aspect_ratio": "1:1", + "aspect_ratio": str(aspect_ratio or "1:1"), } _notify(on_event, {"job_id": job.id, "step": "cover_submit", "result": "start"}) data = ai._cmhub_call_with_retry( diff --git a/app/image_studio_images.py b/app/image_studio_images.py index 4eddd07..bf61fe0 100644 --- a/app/image_studio_images.py +++ b/app/image_studio_images.py @@ -25,6 +25,7 @@ DEFAULT_CONNECT_TIMEOUT_SECONDS = 5 DEFAULT_READ_TIMEOUT_SECONDS = 30 DEFAULT_THUMBNAIL_SIZE = 220 DEFAULT_THUMBNAIL_WORKERS = 4 +MAX_ORIGINAL_ASSETS = 16 class ImageStudioImageError(RuntimeError): @@ -311,6 +312,203 @@ def _existing_local_asset(asset): return None +def import_original_files( + project_id, + file_paths, + *, + path=None, + config=None, + image_root=None, + max_assets=MAX_ORIGINAL_ASSETS, +): + """Validate and atomically copy local product images into one studio project.""" + + imported = [] + errors = [] + for file_path in file_paths or []: + try: + with open(os.path.abspath(str(file_path)), "rb") as fh: + content = fh.read(int(ORIGINAL_MAX_BYTES) + 1) + imported.append( + import_original_bytes( + project_id, + content, + filename_hint=os.path.basename(str(file_path)), + path=path, + config=config, + image_root=image_root, + max_assets=max_assets, + ) + ) + except Exception as exc: + errors.append({"path": os.path.abspath(str(file_path)), "error": str(exc)}) + return {"assets": imported, "errors": errors, "limit": int(max_assets)} + + +def import_original_bytes( + project_id, + content, + *, + filename_hint="clipboard.png", + path=None, + config=None, + image_root=None, + max_assets=MAX_ORIGINAL_ASSETS, +): + cfg = appconfig.load_config() if config is None else config + database_path = path or appconfig.db_path(cfg) + project = image_studio.get_project(project_id, path=database_path) + if project is None: + raise ImageStudioImageError("商品套图任务不存在") + image_bytes = bytes(content or b"") + if not image_bytes: + raise ImageStudioImageError("商品原图内容为空") + if len(image_bytes) > int(ORIGINAL_MAX_BYTES): + raise ImageStudioImageError("商品原图超过大小上限") + info = _image_info(image_bytes) + originals = image_studio.list_assets( + project.id, + kind=image_studio.ASSET_KIND_ORIGINAL, + path=database_path, + ) + active_originals = [ + asset for asset in originals if asset.status != image_studio.ASSET_STATUS_MISSING + ] + digest = hashlib.sha256(image_bytes).hexdigest()[:16] + duplicate = next( + ( + asset + for asset in originals + if digest in os.path.basename(str(asset.local_path or "")) + and _existing_local_asset(asset) is not None + ), + None, + ) + if duplicate is not None: + return duplicate + if len(active_originals) >= int(max_assets): + raise ImageStudioImageError("商品原图最多只能添加%d张" % int(max_assets)) + + directory = image_studio.project_image_dirs( + image_root or appconfig.image_dir(cfg), + project, + )["originals"] + os.makedirs(directory, exist_ok=True) + order = max([int(asset.source_order or 0) for asset in originals] + [0]) + 1 + extension = _extension_for_format(info["format"]) + safe_hint = os.path.splitext(os.path.basename(str(filename_hint or "image")))[0] + safe_hint = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in safe_hint) + safe_hint = safe_hint.strip("_")[:32] or "image" + final_path = os.path.join( + directory, + "original_%02d_%s_%s%s" % (order, safe_hint, digest, extension), + ) + temp_path = final_path + ".tmp-" + uuid.uuid4().hex + created = False + try: + with open(temp_path, "wb") as fh: + fh.write(image_bytes) + with open(temp_path, "rb") as fh: + _image_info(fh.read()) + os.replace(temp_path, final_path) + created = True + return image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=final_path, + aspect_ratio="%d:%d" % (int(info["width"]), int(info["height"])), + source_order=order, + path=database_path, + ) + except Exception as exc: + for candidate in (temp_path, final_path if created else None): + if candidate and os.path.exists(candidate): + try: + os.remove(candidate) + except OSError: + pass + if isinstance(exc, ImageStudioImageError): + raise + raise ImageStudioImageError(f"保存商品原图失败:{exc}") from exc + + +def trash_generated_asset(asset_id, *, path=None, config=None, image_root=None): + """Move one generated image to the app-managed trash without losing DB history.""" + + cfg = appconfig.load_config() if config is None else config + database_path = path or appconfig.db_path(cfg) + asset = image_studio.get_asset(asset_id, path=database_path) + if asset is None or not str(asset.kind or "").startswith("generated_"): + raise ImageStudioImageError("只能删除商品套图生成结果") + source_path = os.path.abspath(str(asset.local_path or "")) + if not source_path or not os.path.isfile(source_path): + raise ImageStudioImageError("生成图片文件不存在") + project = image_studio.get_project(asset.project_id, path=database_path) + if project is None: + raise ImageStudioImageError("商品套图任务不存在") + root = image_studio.project_image_dirs( + image_root or appconfig.image_dir(cfg), + project, + )["root"] + trash_dir = os.path.join(root, ".trash") + os.makedirs(trash_dir, exist_ok=True) + trash_path = os.path.join( + trash_dir, + "%s_%s" % (uuid.uuid4().hex, os.path.basename(source_path)), + ) + try: + os.replace(source_path, trash_path) + image_studio.update_asset_local_path( + asset.id, + trash_path, + status=image_studio.ASSET_STATUS_MISSING, + path=database_path, + ) + except Exception as exc: + if os.path.isfile(trash_path) and not os.path.exists(source_path): + try: + os.replace(trash_path, source_path) + except OSError: + pass + raise ImageStudioImageError(f"删除生成图片失败:{exc}") from exc + return { + "asset_id": asset.id, + "original_path": source_path, + "trash_path": trash_path, + } + + +def restore_trashed_asset(record, *, path=None, config=None): + cfg = appconfig.load_config() if config is None else config + database_path = path or appconfig.db_path(cfg) + payload = dict(record or {}) + asset = image_studio.get_asset(payload.get("asset_id"), path=database_path) + if asset is None: + raise ImageStudioImageError("待撤销的生成图片记录不存在") + trash_path = os.path.abspath(str(payload.get("trash_path") or "")) + original_path = os.path.abspath(str(payload.get("original_path") or "")) + if not os.path.isfile(trash_path): + raise ImageStudioImageError("废纸篓中的生成图片不存在") + if os.path.exists(original_path): + stem, extension = os.path.splitext(original_path) + original_path = "%s_restored_%s%s" % (stem, uuid.uuid4().hex[:8], extension) + os.makedirs(os.path.dirname(original_path), exist_ok=True) + os.replace(trash_path, original_path) + try: + return image_studio.update_asset_local_path( + asset.id, + original_path, + status=image_studio.ASSET_STATUS_AVAILABLE, + path=database_path, + ) + except Exception as exc: + try: + os.replace(original_path, trash_path) + except OSError: + pass + raise ImageStudioImageError(f"撤销删除生成图片失败:{exc}") from exc + + def download_original_asset(asset_id, *, path=None, config=None, image_root=None, session=None): """Download one Shopee original image into originals/ and mark its asset available.""" diff --git a/app/product_suite.py b/app/product_suite.py new file mode 100644 index 0000000..b95c8d8 --- /dev/null +++ b/app/product_suite.py @@ -0,0 +1,148 @@ +"""Pure product-suite configuration and generation planning helpers.""" + +from __future__ import annotations + +from collections import OrderedDict + + +FIXED_CATEGORIES = ("白底图", "场景图", "卖点图") +DEFAULT_CATEGORY_COUNTS = OrderedDict( + (("白底图", 1), ("场景图", 2), ("卖点图", 2)) +) +RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16") +MAX_CATEGORY_NAME_LENGTH = 10 +MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16 + + +def default_suite_settings(): + return { + "platform": "Shopee", + "country": "中国台湾", + "language": "繁体中文", + "ratio": "1:1", + "per_image_primary": False, + "categories": dict(DEFAULT_CATEGORY_COUNTS), + "custom_category_order": [], + } + + +def normalize_suite_settings(value=None): + raw = dict(value or {}) if isinstance(value, dict) else {} + normalized = default_suite_settings() + normalized["platform"] = str(raw.get("platform") or "Shopee") + normalized["country"] = str(raw.get("country") or "中国台湾") + normalized["language"] = str(raw.get("language") or "繁体中文") + ratio = str(raw.get("ratio") or "1:1") + normalized["ratio"] = ratio if ratio in RATIOS else "1:1" + normalized["per_image_primary"] = bool(raw.get("per_image_primary", False)) + + raw_categories = raw.get("categories") if isinstance(raw.get("categories"), dict) else {} + categories = OrderedDict() + for name, default_count in DEFAULT_CATEGORY_COUNTS.items(): + categories[name] = _count(raw_categories.get(name, default_count)) + requested_order = raw.get("custom_category_order") + requested_order = requested_order if isinstance(requested_order, list) else [] + seen = set(FIXED_CATEGORIES) + custom_order = [] + for candidate in list(requested_order) + list(raw_categories): + name = str(candidate or "") + if name in seen or suite_name_error(name, categories): + continue + seen.add(name) + custom_order.append(name) + categories[name] = _count(raw_categories.get(name, 1)) + normalized["categories"] = dict(categories) + normalized["custom_category_order"] = custom_order + return normalized + + +def suite_name_error(name, existing=None, old_name=""): + value = str(name or "") + if not value.strip(): + return "分类名称不能为空" + if value != value.strip() or any(character.isspace() for character in value): + return "分类名称不能包含空格" + if len(value) > MAX_CATEGORY_NAME_LENGTH: + return "分类名称不能超过10个字" + names = set(existing or ()) + if value in names and value != str(old_name or ""): + return "分类名称已存在" + return "" + + +def category_order(settings): + normalized = normalize_suite_settings(settings) + categories = normalized["categories"] + custom = [ + name + for name in normalized["custom_category_order"] + if name in categories and name not in FIXED_CATEGORIES + ] + return list(FIXED_CATEGORIES) + custom + + +def suite_total_count(settings, image_count): + normalized = normalize_suite_settings(settings) + categories = normalized["categories"] + base = sum(_count(categories.get(name, 0)) for name in category_order(normalized)) + if not normalized["per_image_primary"]: + return base + white_count = _count(categories.get("白底图", 0)) + other_count = max(0, base - white_count) + return white_count + other_count * max(1, int(image_count or 0)) + + +def build_suite_prompt(base_prompt, settings, category, item_id, source_index=1): + normalized = normalize_suite_settings(settings) + context = [ + "生成一张电商商品套图。", + "平台:%s" % normalized["platform"], + "国家地区:%s" % normalized["country"], + "输出语言:%s" % normalized["language"], + "图片比例:%s" % normalized["ratio"], + "套图分类:%s" % str(category), + "商品ID:%s" % str(item_id or ""), + "当前主参考图序号:%d" % max(1, int(source_index or 1)), + "商品卖点与要求:%s" % str(base_prompt or "").strip(), + "保持商品主体、款式、颜色和关键细节准确,不添加无依据的功能或参数。", + ] + return "\n".join(context) + + +def build_job_specs(source_assets, base_prompt, settings, item_id): + assets = list(source_assets or []) + if not assets: + return [] + normalized = normalize_suite_settings(settings) + specs = [] + for category in category_order(normalized): + count = _count(normalized["categories"].get(category, 0)) + if count <= 0: + continue + targets = assets if normalized["per_image_primary"] and category != "白底图" else assets[:1] + for source_index, asset in enumerate(targets, 1): + for category_index in range(1, count + 1): + specs.append( + { + "source_asset_id": int(getattr(asset, "id", asset)), + "job_type": str(category), + "category": str(category), + "category_index": category_index, + "source_index": source_index, + "prompt": build_suite_prompt( + base_prompt, + normalized, + category, + item_id, + source_index=source_index, + ), + } + ) + return specs + + +def _count(value): + try: + return max(0, int(value or 0)) + except (TypeError, ValueError): + return 0 diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index f036509..e8c4282 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -4,10 +4,10 @@ ## 一句话定位 -蝦皮圈優化助手(代号 cmshopee)是一个 Windows 本地桌面自动化工具(PySide6,当前正式界面显示 5 个工作流 Tab),让运营管理多个 Shopee 卖家账号,并用 CDP 驱动 Chrome + AI 批量改商品标题、换商品封面。AI工场图片候选工作区的代码与数据能力保留,但当前暂不在主界面展示入口。 +蝦皮圈優化助手(代号 cmshopee)是一个 Windows 本地桌面自动化工具(PySide6,当前正式界面显示 6 个工作流 Tab),让运营管理多个 Shopee 卖家账号,并用 CDP 驱动 Chrome + AI 批量改商品标题、换商品封面;⑥「商品套图」用于按账号与商品生成本地电商套图,不自动上传蝦皮。 主流水线(工作流优先顺序): -**① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置**。⑥ AI工场是保留的图片项目/照片池/cmhub 托管生图工作区,当前隐藏主界面入口且不自动上传蝦皮。 +**① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图**。①~③是 Excel 批量更新主流水线;⑥是独立的本地商品套图工作区,复用 cmhub 托管生图与既有 `image_studio_*` 数据,不自动上传蝦皮。 目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(不设逐条确认阶段)→ ③ 对已生成任务点击「开始更新」,弹窗确认后批量改标题+换封面并点「更新」提交 → 结果实时存 SQLite、批量回写原 Excel。 @@ -30,7 +30,7 @@ ## 当前阶段 -当前项目处于:**V0 单账号流程已验证,正在实现 V1 多账号 + Excel + AI 的 5 Tab GUI 工作台;AI工场入口暂时隐藏**。 +当前项目处于:**V0 单账号流程已验证,V1 多账号 + Excel + AI 的 6 Tab GUI 工作台持续收口中;⑥商品套图已接入**。 优先路径: @@ -65,7 +65,7 @@ **V1 当前 coding 目标**: -- 当前正式界面为 5 Tab 工作台:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置;⑥ AI工场的代码与本地数据保留,但主界面入口暂时隐藏,不自动提交线上。 +- 当前正式界面为 6 Tab 工作台:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图。旧 `ImageStudioTab` 只保留内部兼容,主界面入口由 `ProductSuiteTab` 替代;⑥只生成和管理本地图片,不自动提交线上。 - GUI 固定为 PySide6;后台采集/生成/更新用 `QObject` worker + `QThread` + signal 回传进度。 - 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,提供「检查本轮更新」按钮;⑤ 可开启按账号并行和设置每批最大更新条数。 - Excel 导入/回写 + SQLite 实时落库 + 本地图片目录。 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 01a5db2..65ec4f2 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -32,9 +32,9 @@ | 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时必须先确认本地旧封面备份存在,再删原封面并上传) | P0 | | 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 | -### V1 当前目标(多账号 + Excel + AI,5 Tab 流水线) +### V1 当前目标(多账号 + Excel + AI,6 Tab 工作台) -5 个 Tab,顺序:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置。 +6 个 Tab,顺序:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图。 | 功能 | 用户能做什么 | 优先级 | | --- | --- | --- | @@ -49,6 +49,7 @@ | 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批、更新内容和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 | | 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 | | 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 900 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量,其中 cmhub 生图请求与图片下载实际并发最大 5,超过后内部限流且运行日志展示实际并发;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 | +| 商品套图(⑥) | 以账号+商品ID建立本地图片项目;可拉取蝦皮主图或添加本地原图,按白底图/场景图/卖点图及自定义分类生成套图;支持多任务并行、历史结果、单图重试、删除撤销;只写本地项目,不自动上传或修改蝦皮 | P1 | | 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 | ### 后续迭代 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index fc2ebbe..bbc34fb 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -30,7 +30,7 @@ ## 二、决策记录与演进 - **CDP 自研而非 playwright**:当前已验证根目录 `cdp.py`,正式代码迁入 `app/cdp.py`;它零重依赖、完全可控,并已在开发环境绕开了代理(`*_proxy` 指向本地 :1080)和 Chrome 的 Origin 403 两个坑。未来若交互复杂度大幅上升,再评估 playwright。 -- **GUI 选 PySide6**:V1 是 6 Tab 运营工作台,包含任务表格、筛选、图片预览、后台采集/生成/更新、AI工场照片池、进度与停止。当前环境已安装 PySide6,且 Tkinter 不可用;Qt 的 `QThread`/signal-slot 比 Tkinter 手动 queue/after 更适合长任务回传 UI。 +- **GUI 选 PySide6**:V1 是 6 Tab 运营工作台,包含任务表格、筛选、图片预览、后台采集/生成/更新、商品套图多任务、进度与停止。⑥采用原生 `ProductSuiteTab`,不引入 QtWebEngine;Qt 的 `QThread`/signal-slot 用于长任务回传 UI。 - **存储拆两层**:应用设置进 `data/config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。 - **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。 - **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 6e7b7cd..37a9e29 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -5,14 +5,14 @@ ## 一、系统结构 -Windows 本地桌面自动化工具,无后端服务。当前正式 GUI 显示 5 个工作流 Tab;AI工场图片工作区的代码、数据库与本地资产能力保留,但主界面入口暂时隐藏。 +Windows 本地桌面自动化工具,无后端服务。当前正式 GUI 显示 6 个工作流 Tab;⑥商品套图使用新的原生 PySide6 界面,并复用既有 `image_studio_*` 数据与生图服务。 ```text 运营(人) | v -GUI(PySide6 QTabWidget,当前显示 5 Tab) - ① 导入采集 ② AI生成 ③ 更新蝦皮 ④ 账号管理 ⑤ 设置 +GUI(PySide6 QTabWidget,当前显示 6 Tab) + ① 导入采集 ② AI生成 ③ 更新蝦皮 ④ 账号管理 ⑤ 设置 ⑥ 商品套图 | v 核心模块(Python) @@ -25,8 +25,9 @@ GUI(PySide6 QTabWidget,当前显示 5 Tab) ├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽) ├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新 ├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面) - ├── image_studio AI工场项目/资产/job/终选顺序数据服务 - ├── image_studio_images 远程原图安全下载、缩略图、原图落盘 + ├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选) + ├── product_suite 套图设置归一化、数量计算、完整提示词与job规划 + ├── image_studio_images 远程原图安全下载、本地原图导入、生成图废纸篓 ├── image_studio_generation cmhub 托管多图异步 submit/poll/download 编排 └── image_studio_export 终选图片本地 JPEG 转码、目录安全导出 | @@ -39,8 +40,8 @@ Shopee 卖家中心页面 / 本地图片目录 真实组件: -- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,当前正式界面显示 5 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。`ImageStudioTab` 及其服务层仍保留在代码和发布包中,但当前不加入顶层 `QTabWidget`。 -- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`、`image_studio.py`、`image_studio_images.py`、`image_studio_generation.py`、`image_studio_export.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。 +- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,当前正式界面显示 6 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。第六 Tab 只创建 `ProductSuiteTab`;旧 `ImageStudioTab` 保留内部兼容,但不加入顶层 `QTabWidget`。 +- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`、`image_studio.py`、`product_suite.py`、`image_studio_images.py`、`image_studio_generation.py`、`image_studio_export.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。 - 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。 - 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;普通产品默认 cmhub 网关,由 `data/config.json` 的 `ai.cmhub` + `data/config/cmhub.json` 配置;direct 直连模型清单仅作为内部兼容/手工回滚路径保留);`openpyxl`。 @@ -63,7 +64,7 @@ imported → collected → generated → applied ## 三、职责划分 -**GUI(当前显示 5 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**① 采集点击后会为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后只检测登录态,未登录账号的任务跳过并汇总提示去 ④人工登录。③ 更新蝦皮仍是线上提交高风险链路:执行前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。⑥ AI工场入口当前隐藏;其只读拉图、下载图片和 cmhub 托管生成代码仍保留,且不会自动上传蝦皮。** +**GUI(当前显示 6 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**① 采集点击后会为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后只检测登录态,未登录账号的任务跳过并汇总提示去 ④人工登录。③ 更新蝦皮仍是线上提交高风险链路:执行前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。⑥商品套图的只读拉图、原图下载/导入、AI帮写和 cmhub 生成均在独立 worker 中运行;多任务可并行,且不会自动上传蝦皮。** **核心模块** @@ -84,7 +85,7 @@ imported → collected → generated → applied - cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。 - 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。 - 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。 -- 提示词 → 标题当前工作文本存单文件 `data/title_prompt.txt`;标题命名模板存 `data/prompts/title/<名称>.txt`;封面命名模板存 `data/prompts/cover/<名称>.txt`;AI工场完整提示词模板存 `data/prompts/image_studio/<名称>.txt`。 +- 提示词 → 标题当前工作文本存单文件 `data/title_prompt.txt`;标题命名模板存 `data/prompts/title/<名称>.txt`;封面命名模板存 `data/prompts/cover/<名称>.txt`;旧 AI工场模板目录 `data/prompts/image_studio/<名称>.txt` 仅保留兼容,⑥商品套图直接把卖点文本与结构化设置保存在项目表。 - 登录态 → 各账号 `data/chrome_user_data_dir//`。 T-538 后统一数据根为 `data/`:打包版默认 `/data`,源码运行默认项目根 `data/`。`config.json` 中 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时由 `appconfig` 解析到 `data/` 下;绝对路径作为高级自定义仍按原值使用。启动时会迁移 T-524 旧包的 exe 顶层数据到 `data/`,并检测 `data/` 可写。 @@ -141,7 +142,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 `ai` 段只放**选择 + 全局生成参数**: - `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,⑤设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。 -- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。⑤设置页与⑥AI工场只展示 cmhub 托管“默认档 / 高质量档 / 省点档”、展示名、用途和扣点提示,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug;执行层仍只保存 alias。 +- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。⑤设置页展示托管档位、别名和扣点提示;⑥商品套图直接使用已保存的生图 alias,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug。 - `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,⑤设置页不再展示标题/图片模型角色下拉。 - `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。 - `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。 @@ -419,7 +420,9 @@ data/images///__new. # AI 生成的新 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 - 任一组件生成后 `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”。 -- ⑥AI工场固定使用⑤保存的 cmhub 生图 alias。生成区显示“cmhub 托管默认档 / 高质量档 / 省点档”、当前生图别名、余额和扣点;档位是产品说明层,真实模型可由 cmhub 后台调整,客户端不保存 OpenAI slug。 +- ⑥商品套图固定使用⑤保存的 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。 +- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。 提示词管理: @@ -461,14 +464,14 @@ data/images///__new. # AI 生成的新 | 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 | | WebSocket Origin | `websocket-client` `suppress_origin=True` | | 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 | -| 前台激活 | ①采集和⑥AI工场只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | +| 前台激活 | ①采集和⑥商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 | | SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 | | 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” | | 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` | | 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 | | 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 | -| AI工场原主图读取 | 复用 `open_product(..., bring_to_front=False)` 后台只读打开商品详情页,使用已验证 itembox 顺序读取全部主图 `img.src` 并返回 `{index, src}`;不要求上传 input 之外的新选择器、不下载图片、不改标题/封面、不拖拽、不点击更新;本轮自动新建 tab 按采集规则关闭,复用用户已有 tab 不关闭 | +| 商品套图原主图读取 | 复用 `open_product(..., bring_to_front=False)` 后台只读打开商品详情页,使用已验证 itembox 顺序读取全部主图 `img.src` 并返回 `{index, src}`;不要求上传 input 之外的新选择器、不改标题/封面、不拖拽、不点击更新;URL 读取完成后再由最多2个图片下载 worker 落盘;本轮自动新建 tab 按采集规则关闭,复用用户已有 tab 不关闭 | | 上传输入框 | `.shopee-image-manager__upload input[type=file]`;上传前先点击 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` | | 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。若手动上传成功但自动上传一直转圈,优先检查是否绕过了上传块点击导致 Shopee 前端上传队列未完整初始化;代码应走“点击上传块 → 等待 → 重新取 input → `DOM.setFileInputFiles`”的人工等价路径。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理;`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即失败并提示新封面与现有商品图片重复 | | 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` | @@ -516,7 +519,7 @@ cmshopee/ │ └── prompts/ │ ├── title/<名称>.txt # 标题提示词命名模板 │ ├── cover/<名称>.txt # 封面提示词命名模板 -│ └── image_studio/<名称>.txt # AI工场完整提示词模板 +│ └── image_studio/<名称>.txt # 旧AI工场模板兼容目录,商品套图不依赖 └── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py) # 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md ``` diff --git a/docs/README.md b/docs/README.md index c59e3cc..6383488 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ ## 一句话定位 -蝦皮圈優化助手(代号 cmshopee)是一个给**电商运营**使用的 Windows PySide6 桌面自动化工具,用于**管理多个 Shopee 卖家账号、并用 CDP 驱动 Chrome 批量修改商品标题、替换商品封面图**。V0 已验证单账号「改标题 + 换封面」闭环;当前 V1 工作台为 6 Tab:①导入采集 → ②AI生成 → ③点击「开始更新」并确认后批量提交 → 回写结果,④账号管理,⑤设置,⑥AI工场图片候选生成。 +蝦皮圈優化助手(代号 cmshopee)是一个给**电商运营**使用的 Windows PySide6 桌面自动化工具,用于**管理多个 Shopee 卖家账号、并用 CDP 驱动 Chrome 批量修改商品标题、替换商品封面图**。V0 已验证单账号「改标题 + 换封面」闭环;当前 V1 工作台为 6 Tab:①导入采集 → ②AI生成 → ③点击「开始更新」并确认后批量提交 → 回写结果,④账号管理,⑤设置,⑥商品套图生成。 ## 文档导航 @@ -21,7 +21,7 @@ - [任务看板(历史归档)](06-tasks.md):T-000~T-549 历史任务,已冻结,不再新增。 - [模块 / CLI 合约](api.md):本地模块接口、Chrome 启动参数、账号配置 schema。 - [界面与流程结构](routes.md):GUI 窗口、操作流程、按钮职责(无前端路由,用 GUI 流程替代)。 -- [AI工场端到端验收](ai-studio-e2e-checklist.md):⑥ AI工场 cmhub 托管主线的自动化覆盖、人工只读验收和发布检查。 +- [旧 AI工场兼容层端到端验收](ai-studio-e2e-checklist.md):T-622 前图片工作区的自动化覆盖;当前⑥商品套图继续复用其中的数据与 cmhub 服务。 - [AI工场托管模型评测与默认档位策略](ai-studio-model-evaluation.md):OpenAI / GPT 托管能力经 cmhub 别名落地前的评测样本、档位策略、上线门槛与运营用法。 - [AI工场 BYOK 启动门禁记录](byok-startup-gate.md):自定义模型 / BYOK 开工前的授权、设备凭证、Provider 白名单、task secret 和接口契约检查记录。 - [当前实现状态](current-state.md):当前代码现实、可运行命令、下一步可做任务。 diff --git a/docs/ai-studio-e2e-checklist.md b/docs/ai-studio-e2e-checklist.md index c989174..db0872a 100644 --- a/docs/ai-studio-e2e-checklist.md +++ b/docs/ai-studio-e2e-checklist.md @@ -1,5 +1,7 @@ # AI工场端到端验收与发布检查 +> T-622 后主窗口入口已由「⑥商品套图」替换;本文保留为旧 `ImageStudioTab` 与共享 `image_studio_*` 服务的兼容回归清单,不再代表当前第六 Tab 的界面验收。当前商品套图交互以 [`tasks/T-622.md`](tasks/T-622.md) 和 [`routes.md`](routes.md) 为准。 + > T-595 阶段性收口文档。本文只记录 AI工场当前已实现的 cmhub 托管主线,不包含 BYOK、自定义 Provider、自动上传蝦皮或本地图片导入。 ## 已实现闭环 diff --git a/docs/api.md b/docs/api.md index c1d3c5b..2990c52 100644 --- a/docs/api.md +++ b/docs/api.md @@ -355,9 +355,9 @@ render_prompt(template_text, task) -> str - `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。 - 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。 -## AI工场模块(`app/image_studio*.py`,已建) +## 商品套图模块(`app/product_suite.py` + `app/image_studio*.py`,已建) -AI工场是 ⑥ Tab 的本地图片项目工作区,不自动上传蝦皮、不修改线上商品。 +商品套图是 ⑥ Tab 的本地图片项目工作区,不自动上传蝦皮、不修改线上商品。底层继续复用旧 AI工场的数据表与生成服务,避免迁移既有项目数据。 ```python # app/image_studio.py @@ -365,9 +365,13 @@ ImageStudioProject / ImageStudioAsset / ImageStudioJob / ImageStudioSelection create_or_get_project(account_or_fields, item_id, ...) -> ImageStudioProject list_projects(path=None) -> list[ImageStudioProject] update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject +project_suite_settings(project) -> dict +update_project_suite_settings(project_id, settings, path=None) -> ImageStudioProject 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] 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] replace_selections(project_id, selection_type, asset_ids, path=None) -> list[ImageStudioSelection] pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None) -> dict @@ -376,6 +380,16 @@ pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None) - download_remote_image(url, max_bytes=..., timeout=(connect, read)) -> RemoteImage load_thumbnail(url, key=None, max_size=220) -> ThumbnailResult download_original_asset(asset_id, path=None, config=None) -> ImageStudioAsset +import_original_files(project_id, file_paths, path=None, config=None) -> dict +import_original_bytes(project_id, content, filename_hint="clipboard.png", ...) -> ImageStudioAsset +trash_generated_asset(asset_id, path=None, config=None) -> dict +restore_trashed_asset(record, path=None, config=None) -> ImageStudioAsset + +# app/product_suite.py +normalize_suite_settings(value=None) -> dict +suite_total_count(settings, image_count) -> int +build_suite_prompt(base_prompt, settings, category, item_id, source_index=1) -> str +build_job_specs(source_assets, base_prompt, settings, item_id) -> list[dict] # app/image_studio_generation.py generate_image_jobs(project_id, source_asset_id, prompt, count, job_type="main", aspect_ratio="1:1", ...) -> dict @@ -387,10 +401,11 @@ export_project_selection(project_id, parent_dir, existing_mode="fail", path=None 要点: -- 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool///` 下分 `originals/generated/exports`。 +- 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool///` 下分 `originals/generated/exports`,删除生成图进入项目内 `.trash` 并可撤销。 +- `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 解码校验;只在用户单击时落盘。 -- cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。AI工场生成区只展示托管档位、生图别名、扣点和余额,不展示 Provider URL、OpenAI Key 或上游接口路径。 +- 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()` 事务替换,主图/详情图同类别去重、跨类别可复用。 - 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。 @@ -406,7 +421,8 @@ class CollectTab(QWidget) # ① 导入采集:导入 Excel + class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志 class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志 class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪 -class ImageStudioTab(QWidget) # ⑥ AI工场:项目、原图、照片池、终选、cmhub托管生成、导出 +class ProductSuiteTab(QWidget) # ⑥ 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果 +class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建 class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度 class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped @@ -417,6 +433,9 @@ class ImageStudioDownloadOriginalWorker(BaseWorker)# ⑥ 后台下载远程原 class ImageStudioGenerateJobsWorker(BaseWorker) # ⑥ 后台提交/查询/下载 cmhub 生图 job class ImageStudioResumeJobsWorker(BaseWorker) # ⑥ 后台恢复已有 task_id 的生图 job class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG +class ProductSuiteImportImagesWorker(BaseWorker) # ⑥ 后台校验并复制本地/剪贴板商品原图 +class ProductSuiteGenerateWorker(BaseWorker) # ⑥ 按套图job规划提交/查询/下载 +class ProductSuiteAiWriteWorker(BaseWorker) # ⑥ 后台生成商品卖点与画面要求 class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑 class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单 @@ -428,7 +447,7 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑥ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。 -`MainWindow` 已实现六 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ cmhub 设置、⑥ AI工场图片候选生成。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 +`MainWindow` 已实现六 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ cmhub 设置、⑥ 商品套图生成。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:6 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。 diff --git a/docs/routes.md b/docs/routes.md index f2e0b24..06e21a6 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -1,11 +1,11 @@ # 界面与流程结构 -> 桌面工具,无前端路由。当前正式界面用 **5 Tab GUI(PySide6 `QMainWindow` + `QTabWidget`)+ 流水线** 约定界面职责与导航;AI工场代码与数据保留,但主界面入口暂时隐藏。 +> 桌面工具,无前端路由。当前正式界面用 **6 Tab GUI(PySide6 `QMainWindow` + `QTabWidget`)+ 流水线** 约定界面职责与导航;⑥商品套图已替换旧 AI工场入口。 ## Tab 顺序与职责(工作流优先) ``` -① 导入采集 │ ② AI生成 │ ③ 更新蝦皮 │ ④ 账号管理 │ ⑤ 设置 +① 导入采集 │ ② AI生成 │ ③ 更新蝦皮 │ ④ 账号管理 │ ⑤ 设置 │ ⑥ 商品套图 ``` | Tab | 职责 | 风险 | @@ -15,18 +15,19 @@ | ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** | | ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 | | ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — | +| ⑥ 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,按套图分类异步生图,查看历史与重试 | 本地生成,中 | -⑥ AI工场原有代码、SQLite项目与图片资产继续保留,但当前不加入主窗口Tab栏;后续重新开放入口需另立任务。隐藏入口不会删除或迁移用户已有AI工场数据。 +旧 `ImageStudioTab` 与 `image_studio_*` SQLite/图片资产服务继续保留作内部兼容;主窗口只创建 `ProductSuiteTab`,不会并列暴露两套第六 Tab,也不会删除或迁移用户既有项目数据。 任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。 ## 启动强制升级门禁 -创建五个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。 +创建六个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。 ## 全局 Tab 栏可用性 -当前 5 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式: +当前 6 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式: - 每个 Tab 设置稳定最小宽度和足够左右 padding,避免文字贴边或窄到误点。 - Tab 之间保留明显间距,当前 Tab 有清晰背景/边框高亮。 @@ -179,37 +180,30 @@ - ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。 - ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再弹确认框。 -## ⑥ AI工场(入口暂时隐藏) +## ⑥ 商品套图 ``` -┌ AI工场 ──────────────────────────────────────────────────────┐ -│ 左轨:账号[▼] 商品ID[____] [打开项目] [拉取主图] [打开项目文件夹] │ -│ 项目列表:账号 / 商品ID / 更新时间 │ -│ 中区:蝦皮原主图(单击下载进池,双击预览) │ -│ 照片池:原图/主图/详情图角标、比例、排队/生成/失败状态 │ -│ 右侧:模板[▼] [新建][重命名][保存][删除] │ -│ [完整提示词输入框] │ -│ 类型[主图▼] 数量[4] 比例[1:1▼] cmhub扣点/余额提示 │ -│ [开始生成][停止] 进度条 运行日志 │ -│ 底部:主图终选 / 详情图终选(拖入、插入、重排、移出) [导出终选] │ -└───────────────────────────────────────────────────────────────┘ +┌ 套图任务1 │ 套图任务2 │ + ───────────────────────────────────┐ +│ 账号[▼] 商品ID[____________] [拉取蝦皮主图] │ +├ 左侧配置(滚动)────────────┬ 右侧生成结果 ─────────────────────┤ +│ 商品原图:主图/参考1..5/添加 │ 共N张·成功M张 [历史生成][打开文件夹] │ +│ 平台/国家/语言/比例 │ [结果卡][结果卡][失败卡·重试] │ +│ 每张上传图分别作为主图生成 │ │ +│ 商品卖点与要求 [AI帮写/取消] │ │ +│ 白底图/场景图/卖点图/自定义类 │ 进度条 套图X/Y(秒)·失败N │ +│ [生成套图(N) ⇄ 停止生成] │ │ +└─────────────────────────────┴──────────────────────────────────┘ ``` -- 项目以 `账号别名 + 商品ID` 唯一;打开项目只创建/切换本地项目,不修改蝦皮。 -- 「拉取主图」复用已验证只读 CDP:后台打开商品详情页读取主图 URL,写入 `image_studio_assets(kind=original)`;不下载图片、不改标题/封面、不点击更新。 -- 原主图抽屉单击时才下载对应远程原图到项目 `originals/` 并设为源图;双击远程原图会先下载再打开大图预览。 -- 照片池只展示本地文件存在且可解码使用的原图、生成主图和生成详情图;单击可用图片设为源图,双击打开大图;右键移除只删除未被任务或终选引用的照片池记录,不删除本地图片文件。照片池附近的「生成任务」区单独展示尚未产出可用图片的等待提交、已提交、生成中、生成失败、任务过期和已停止任务,并显示脱敏后的中文原因摘要、恢复方式和已有计费信息;任务卡不能设为源图或拖入终选。 -- 右侧只有一个完整提示词框;模板目录固定为 `data/prompts/image_studio/`,与②标题/封面模板隔离。界面不显示“主提示词 / 每张动作词”。 -- 生图固定走 cmhub 托管模型,使用⑤设置里的 cmhub Base URL/API Key/生图别名和图片并发;界面显示当前托管档位(默认/高质量/省点)、生图别名、扣点、余额、进度、失败,不展示自定义 Provider、API Key、生成来源选择或“导入本地图片”入口。 -- 「继续查询任务」只恢复当前项目中已保存 `task_id` 且标记为可恢复的 cmhub 生图任务,包括已提交、生成中及下载/保存中断后可继续处理的任务;恢复时只 poll/download 原任务,不再次 submit,不重复扣点。上游已终态失败、任务过期或尚未取得 `task_id` 即停止的任务会明确提示需要重新生成,可能产生新的计费;已有 `task_id` 后被用户停止的任务仍可继续查询。 -- 底部终选盘分为主图和详情图两列;照片池中已下载/已生成且本地文件可用的图片可拖入终选,落到已有位置时按插入顺延,同一类别内同一照片只能出现一次,主图和详情图之间允许复用同一照片。 -- 终选列表内可拖动重排,Delete 或右键「移出终选」只移出终选,不删除照片池资产或本地文件;拖放/移出失败时刷新回 SQLite 中的持久化顺序。 -- 主图推荐 1:1;比例不匹配只用黄色轻提示和 tooltip 提醒,不硬拦。文件缺失或尚未下载的照片不能拖入终选。 -- 拉主图、下载原图、生图 submit/poll/download 均通过 worker 执行,主线程只刷新 UI。拉主图、导出等互斥 worker 运行时禁用项目切换和工作区写操作;生图或继续查询任务运行时仍只允许一个耗时 worker,但当前项目的照片池浏览/源图选择、主图和详情图终选拖放、提示词模板编辑以及下一轮类型/数量/比例设置保持可用。这些修改只作用于下一轮,当前已提交任务的参数不变;此时仍禁用项目切换、原图区下载、拉主图、开始生成、继续查询、导出和删除项目,停止为协作式停止。 -- 「导出终选」可在主图/详情图未满目标数量时导出当前终选;主图和详情图总数为 0 时阻断。用户选择导出父目录后,程序在其下创建商品 ID 子目录,按终选顺序转码为 `商品ID_主图_1.jpg`、`商品ID_详情图_1.jpg`,透明图铺白底输出真正 JPEG。 -- 商品目录已存在时只提供三选:覆盖本软件导出的图片(仅删除匹配当前商品命名规则的旧主图/详情图,保留用户其它文件)、新建带时间目录、取消;不提供合并,也不递归清空用户目录。 -- 导出前会预检所有终选源文件和图片解码,先写 staging,转码失败不创建商品目录、不留下半套新图;成功后中文提示实际目录和主图/详情图数量,并提供打开目录。 -- 本小节当前覆盖 T-591/T-593:主界面、终选排序和本地导出已接入;不自动上传或修改蝦皮,BYOK/自定义 Provider 仍后置。 +- 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。 +- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`。 +- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;支持文件选择、外部拖入、剪贴板粘贴和列表内排序。历史失效远程图不占有效名额;第1张是主参考图。 +- 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。 +- 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 +- 平台、国家地区、语言和比例都写进每个 job 的完整提示词;比例还透传到 cmhub 生图请求,不是装饰字段。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 +- 生成按钮按当前总数显示并在运行时切换为停止。结果区显示本轮或历史 job;成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 +- AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。 +- ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 ## 流程导航 @@ -239,12 +233,12 @@ | `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 | | `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 | | `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 | -| `ImageStudioTab(QWidget)` | ⑥ | AI工场项目列表、只读拉蝦皮主图、原图下载进池、照片池、大图预览、完整提示词模板 CRUD、cmhub 托管多图生成控制 | +| `ProductSuiteTab(QWidget)` | ⑥ | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 | | `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 | | `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals | | `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志 | | `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 | | `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 | -| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ImageStudioGenerateJobsWorker` | ⑥ | 后台执行只读拉主图、远程原图下载、cmhub 托管生图 submit/poll/download;不直接操作 QWidget | +| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker` | ⑥ | 后台执行只读拉主图、远程原图下载、本地图片校验复制、cmhub 套图生成与AI帮写;不直接操作 QWidget | > 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。 diff --git a/docs/tasks/T-622.md b/docs/tasks/T-622.md index f044227..934d452 100644 --- a/docs/tasks/T-622.md +++ b/docs/tasks/T-622.md @@ -3,7 +3,7 @@ id: T-622 title: ⑥用「商品套图」模块替换现有 AI工场(迁移电商图生成器套餐模块) phase: 7 deps: [T-564] -status: TODO +status: DONE created: 2026-07-13 --- @@ -42,11 +42,11 @@ UI 已定稿:`docs/ui/tab6-suite-package-v1.svg`(本仓库家风:`#2b3a55` - 结果网格:每张卡片显示图片/生成中骨架/失败(带「重试」);右键单图可 预览 / 复制路径 / 重新生成。 - 底部一行(与左侧「生成套图」按钮同高):进度条 + `套图 X/Y(秒)· 失败 N` 汇总。 -## 待决 / 选型(实现前确认) +## 实现决策 -1. **技术栈**:推荐**原生 PySide6 重写 UI**、复用现有 `image_studio_*` 后端;已排除内嵌 WebView(避免 QtWebEngine 打包体积与依赖)。若坚持搬 WebView 需另评估。 -2. 平台/国家/语言下拉的选中值如何进入生成上下文(提示词模板变量 or 生成请求参数),需与 `image_studio_generation` 对齐。 -3. 效果图当前为「滚动区裁剪」呈现;如需一屏展示全部配置项,属实现期布局细节。 +1. **技术栈**:采用原生 PySide6 `ProductSuiteTab`,复用现有 `image_studio_*` 后端,不引入 QtWebEngine;旧 `ImageStudioTab` 仅保留内部兼容。 +2. **生成上下文**:平台/国家/语言/比例/分类/商品ID/参考图序号统一由 `product_suite.build_suite_prompt()` 写入每个 job prompt;比例另透传到 cmhub `aspect_ratio` 请求字段。 +3. **布局**:配置区可滚动,生成按钮固定;缩略图和卖点框设稳定高度,结构分类用常驻 chip + 单项展开计数,基准窗口首屏可见。 ## 验收要点 @@ -102,3 +102,10 @@ UI 已定稿:`docs/ui/tab6-suite-package-v1.svg`(本仓库家风:`#2b3a55` - 不动 ①~⑤ 既有标签与流程。 - 不引入 `QtWebEngine`(采纳原生重写方案时)。 - 安全红线:不写真实凭证到代码/文档/日志;`config.json`、`cmshopee.db`、`images/` 等不提交;不绕过 Shopee 风控;生成/导出等本地操作不触碰 ③ 的线上提交边界。 + +## 执行记录 + +- 2026-07-14:完成第六 Tab 从旧 AI工场入口切换为原生「商品套图」。新增多任务状态、账号+商品ID上下文、16张商品原图导入/拖放/粘贴/排序/后台拉取、结构分类与AI帮写、生成/停止、结果历史、重试、预览、废纸篓删除与撤销;旧 `ImageStudioTab` 保留但主窗口不再创建。 +- 2026-07-14:扩展 `image_studio_projects.suite_settings_json` 及原位迁移,新增套图纯逻辑、项目 job 查询/原图排序、本地图片原子导入、生成图废纸篓;cmhub 比例从 UI 真实透传,并以进程级闸门限制多个套图任务合计最多5个在途 job。停止信号在下载前后生效,停止后的临时图片不入资产库。 +- 2026-07-14:同步 AI 开发入口、需求、技术栈、架构、API、routes、文档导航与 UI 索引;新增领域、worker、GUI、迁移、图片边界、比例透传和停止清理测试。 +- 验证:在只包含 T-622 暂存文件的干净 worktree 中运行 `py -3.10 -m unittest discover -s tests`,459 项全部通过;`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 全部通过。原工作区另有任务开始前即存在的默认封面提示词改名(删除 `papa1.txt`、新增 `默认.txt`),会使3个仍断言 `papa1` 的旧测试失败,该改动未回退、未纳入本任务提交。 diff --git a/docs/ui/README.md b/docs/ui/README.md index dbc4423..9c5bf80 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -4,7 +4,7 @@ | 文件 | 内容 | | --- | --- | -| [overview-pipeline.svg](overview-pipeline.svg) | 主流水线总览(stage:imported→collected→generated→applied;⑥ AI工场为图片候选工作区) | +| [overview-pipeline.svg](overview-pipeline.svg) | 主流水线总览(stage:imported→collected→generated→applied;图中旧⑥AI工场入口已由商品套图替换) | | [tab1-import-collect.svg](tab1-import-collect.svg) | ① 导入采集:导入 Excel、任务列表、采集旧标题/旧封面、回写、日志 | | [tab2-ai-generate.svg](tab2-ai-generate.svg) | ② AI生成:提示词、生成、新旧封面对照、重置图片后批量补生成 | | [tab2-ai-generate-T584.svg](tab2-ai-generate-T584.svg) | ② AI生成(T-584 提案):标题提示词加模板控件行、封面控件压一行、插入类按钮下移、标题输入框增高 | @@ -14,6 +14,6 @@ | [tab6-image-studio.svg](tab6-image-studio.svg) | ⑥ 图片精修(提案·初版):商品列表、原主图九宫、照片池、生成控制、终选排序 | | [tab6-image-studio-v2.svg](tab6-image-studio-v2.svg) | ⑥ 图片精修(提案·重排):细图标轨、照片池放大、提示词中心整列、全宽出片盘 | | [tab6-ai-studio-v3.svg](tab6-ai-studio-v3.svg) | ⑥ AI工场(需求定稿·v3):合并提示词、完整模板 CRUD、异步任务照片池、部分导出与终选排序 | -| [tab6-suite-package-v1.svg](tab6-suite-package-v1.svg) | ⑥ 商品套图(迁移提案·v1):从「虾皮圈电商图生成器」商品套餐模块迁移,替换 AI工场;已去掉「详情图」(与套图合并为单一图片类型)。采用本仓库既有模块风格(#2b3a55 头栏 / #2f6fed 蓝 / 微软雅黑)。多任务标签页、左侧配置面板(商品原图上传、生成设置、AI 帮写、套图结构配置:白底/场景/模特/细节/卖点+自定义分类计数)、右侧生成结果网格、底部进度与生成/停止 | +| [tab6-suite-package-v1.svg](tab6-suite-package-v1.svg) | ⑥ 商品套图(T-622 已实现):从「虾皮圈电商图生成器」商品套餐交互迁移并替换主界面 AI工场入口;单一套图类型,多任务标签,商品原图,平台/国家/语言/比例,AI 帮写,白底/场景/卖点及自定义分类,结果历史、重试、删除撤销与生成进度 | > 仅为线框效果图,最终样式以实现为准。带 `-T584`/`-v2` 等后缀的为**改版提案**,未定稿。界面职责与流程见 [../routes.md](../routes.md)。 diff --git a/tests/test_gui.py b/tests/test_gui.py index f0c477a..ddf278e 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -56,6 +56,7 @@ from app.gui import ( ForcedUpdateDialog, ImageStudioTab, MainWindow, + ProductSuiteTab, SettingsTab, TAB_STYLE, TAB_TITLES, @@ -491,7 +492,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.addCleanup(window.close) self.assertEqual(gui.display_name(), window.windowTitle()) - self.assertEqual(5, window.tabs.count()) + self.assertEqual(6, window.tabs.count()) self.assertEqual( TAB_TITLES, [window.tabs.tabText(index) for index in range(window.tabs.count())], @@ -511,6 +512,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIsInstance(window.tabs.widget(2), ApplyTab) self.assertIsInstance(window.tabs.widget(3), AccountsTab) self.assertIsInstance(window.tabs.widget(4), SettingsTab) + self.assertIsInstance(window.tabs.widget(5), ProductSuiteTab) self.assertFalse( any( isinstance(window.tabs.widget(index), ImageStudioTab) diff --git a/tests/test_image_studio.py b/tests/test_image_studio.py index bb53be5..946afdb 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -59,6 +59,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): "item_id", "target_main_count", "target_detail_count", + "suite_settings_json", "deleted_at", }.issubset(projects_columns) ) @@ -72,6 +73,50 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_init_db_migrates_legacy_projects_with_default_suite_settings(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "legacy-project.db") + conn = db.connect(db_path) + try: + conn.execute( + """ + CREATE TABLE image_studio_projects ( + id INTEGER PRIMARY KEY, + account_alias TEXT NOT NULL, + account_slug TEXT NOT NULL, + account_name TEXT, + item_id TEXT NOT NULL, + target_main_count INTEGER NOT NULL DEFAULT 9, + target_detail_count INTEGER NOT NULL DEFAULT 12, + draft_prompt TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + deleted_reason TEXT, + UNIQUE(account_alias, item_id) + ) + """ + ) + conn.execute( + """ + INSERT INTO image_studio_projects + (id, account_alias, account_slug, item_id, created_at, updated_at) + VALUES (1, 'alias', 'alias_slug', '51100639510', '2026-07-14', '2026-07-14') + """ + ) + conn.commit() + finally: + conn.close() + + db.init_db(db_path) + project = image_studio.get_project(1, path=db_path) + + self.assertEqual("{}", project.suite_settings_json) + self.assertEqual({}, image_studio.project_suite_settings(project)) + + self.assert_removed(temp_dir) + def test_project_crud_unique_per_account_and_image_dirs(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") @@ -110,6 +155,15 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): updated = image_studio.update_project_prompt(project.id, "二次提示词", path=db_path) self.assertEqual("二次提示词", updated.draft_prompt) + suite_updated = image_studio.update_project_suite_settings( + project.id, + {"ratio": "3:4", "categories": {"白底图": 1}}, + path=db_path, + ) + self.assertEqual( + {"ratio": "3:4", "categories": {"白底图": 1}}, + image_studio.project_suite_settings(suite_updated), + ) dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), project) self.assertEqual( @@ -215,6 +269,43 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_reorder_original_assets_requires_complete_project_order(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, + ) + first = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=os.path.join(temp_dir, "first.png"), + source_order=1, + path=db_path, + ) + second = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=os.path.join(temp_dir, "second.png"), + source_order=2, + path=db_path, + ) + + reordered = image_studio.reorder_original_assets( + project.id, + [second.id, first.id], + path=db_path, + ) + + self.assertEqual([second.id, first.id], [asset.id for asset in reordered]) + with self.assertRaisesRegex(db.DbError, "全部原图"): + image_studio.reorder_original_assets(project.id, [first.id], path=db_path) + + self.assert_removed(temp_dir) + def test_remove_asset_only_when_not_referenced(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") @@ -287,6 +378,55 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_sync_original_asset_urls_preserves_local_upload_and_caps_active_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 = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=os.path.join(temp_dir, "local.png"), + source_order=1, + path=db_path, + ) + + image_studio.sync_original_asset_urls( + project.id, + [ + {"index": index, "src": "https://susercontent.com/%d.jpg" % index} + for index in range(1, 8) + ], + max_assets=3, + path=db_path, + ) + active = image_studio.list_assets( + project.id, + kind=image_studio.ASSET_KIND_ORIGINAL, + include_missing=False, + path=db_path, + ) + + self.assertEqual(3, len(active)) + self.assertIn(local.id, [asset.id for asset in active]) + self.assertEqual( + image_studio.ASSET_STATUS_AVAILABLE, + image_studio.get_asset(local.id, path=db_path).status, + ) + + image_studio.sync_original_asset_urls(project.id, [], max_assets=3, path=db_path) + self.assertEqual( + image_studio.ASSET_STATUS_AVAILABLE, + image_studio.get_asset(local.id, path=db_path).status, + ) + + self.assert_removed(temp_dir) + def test_job_lifecycle_and_resumable_query(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") @@ -376,6 +516,23 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): [], image_studio.list_resumable_jobs(path=db_path, include_failed_downloads=True), ) + self.assertEqual( + [terminal.id, job.id], + [item.id for item in image_studio.list_jobs(project.id, path=db_path)], + ) + self.assertEqual( + [terminal.id], + [ + item.id + for item in image_studio.list_jobs( + project.id, + statuses=["failed"], + path=db_path, + ) + ], + ) + with self.assertRaisesRegex(db.DbError, "状态无效"): + image_studio.list_jobs(project.id, statuses=["unknown"], path=db_path) with self.assertRaises(db.DbError): image_studio.create_job( diff --git a/tests/test_image_studio_generation.py b/tests/test_image_studio_generation.py index 26c3e5c..0daf0bb 100644 --- a/tests/test_image_studio_generation.py +++ b/tests/test_image_studio_generation.py @@ -146,6 +146,95 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generate_image_jobs_sends_selected_aspect_ratio(self): + with self.make_temp_dir() as temp_dir: + cfg, project, source = self._project_source(temp_dir) + submitted_payloads = [] + + def fake_submit(method, url, api_key, **kwargs): + submitted_payloads.append(dict(kwargs["payload"])) + return {"task_id": "cmhub-ratio", "status": "queued"} + + with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \ + mock.patch( + "app.image_studio_generation.ai._cmhub_call_with_retry", + side_effect=fake_submit, + ), \ + mock.patch( + "app.image_studio_generation.ai._cmhub_call_once", + return_value={ + "task_id": "cmhub-ratio", + "status": "succeeded", + "result": {"image_url": "https://cdn.example.com/ratio.png"}, + }, + ), \ + mock.patch( + "app.image_studio_generation.ai._download_cmhub_image_with_retry", + return_value=(self._png_bytes(), 0.1), + ): + summary = image_studio_generation.generate_image_jobs( + project.id, + source.id, + "比例测试", + 1, + aspect_ratio="3:4", + config=cfg, + path=cfg["db_path"], + ) + + self.assertEqual(1, summary["success"]) + self.assertEqual("3:4", submitted_payloads[0]["aspect_ratio"]) + + self.assert_removed(temp_dir) + + def test_stop_after_download_discards_temporary_result(self): + with self.make_temp_dir() as temp_dir: + cfg, project, source = self._project_source(temp_dir) + stopped = {"value": False} + saved_paths = [] + + def fake_download(request_result, out_path, config, on_event, job_id): + os.makedirs(os.path.dirname(out_path), exist_ok=True) + with open(out_path, "wb") as fh: + fh.write(self._png_bytes()) + saved_paths.append(out_path) + stopped["value"] = True + return out_path + + with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \ + mock.patch( + "app.image_studio_generation.ai._cmhub_call_with_retry", + return_value={"task_id": "cmhub-stop", "status": "queued"}, + ), \ + mock.patch( + "app.image_studio_generation.ai._cmhub_call_once", + return_value={ + "task_id": "cmhub-stop", + "status": "succeeded", + "result": {"image_url": "https://cdn.example.com/stop.png"}, + }, + ), \ + mock.patch( + "app.image_studio_generation._download_and_save_job_image", + side_effect=fake_download, + ): + summary = image_studio_generation.generate_image_jobs( + project.id, + source.id, + "停止测试", + 1, + config=cfg, + path=cfg["db_path"], + should_stop=lambda: stopped["value"], + ) + + self.assertEqual(1, summary["cancelled"]) + self.assertEqual([], image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"])) + self.assertEqual(1, len(saved_paths)) + self.assertFalse(os.path.exists(saved_paths[0])) + + self.assert_removed(temp_dir) + def test_resume_existing_job_polls_without_new_submit(self): with self.make_temp_dir() as temp_dir: cfg, project, source = self._project_source(temp_dir) diff --git a/tests/test_image_studio_images.py b/tests/test_image_studio_images.py index 2e5d5ac..e999154 100644 --- a/tests/test_image_studio_images.py +++ b/tests/test_image_studio_images.py @@ -246,6 +246,149 @@ class ImageStudioImageTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_import_original_files_copies_valid_images_deduplicates_and_limits(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, + ) + source = os.path.join(temp_dir, "商品图.png") + with open(source, "wb") as fh: + fh.write(self._png_bytes()) + config = { + "data_dir": temp_dir, + "db_path": db_path, + "image_dir": os.path.join(temp_dir, "images"), + } + + first = image_studio_images.import_original_files( + project.id, + [source], + path=db_path, + config=config, + ) + duplicate = image_studio_images.import_original_files( + project.id, + [source], + path=db_path, + config=config, + ) + + self.assertEqual([], first["errors"]) + self.assertEqual(first["assets"][0].id, duplicate["assets"][0].id) + self.assertNotEqual(os.path.abspath(source), first["assets"][0].local_path) + self.assertTrue(os.path.isfile(first["assets"][0].local_path)) + with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "最多"): + image_studio_images.import_original_bytes( + project.id, + self._png_bytes(color=(10, 20, 30, 255)), + filename_hint="second.png", + path=db_path, + config=config, + max_assets=1, + ) + + self.assert_removed(temp_dir) + + def test_import_original_ignores_missing_history_when_enforcing_limit(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, + ) + config = { + "data_dir": temp_dir, + "db_path": db_path, + "image_dir": os.path.join(temp_dir, "images"), + } + first = image_studio_images.import_original_bytes( + project.id, + self._png_bytes(), + filename_hint="first.png", + path=db_path, + config=config, + max_assets=1, + ) + image_studio.mark_asset_status( + first.id, + image_studio.ASSET_STATUS_MISSING, + path=db_path, + ) + + second = image_studio_images.import_original_bytes( + project.id, + self._png_bytes(color=(10, 20, 30, 255)), + filename_hint="second.png", + path=db_path, + config=config, + max_assets=1, + ) + + self.assertNotEqual(first.id, second.id) + self.assertEqual(image_studio.ASSET_STATUS_AVAILABLE, second.status) + + self.assert_removed(temp_dir) + + def test_generated_asset_trash_and_restore_keep_database_history(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, + ) + config = { + "data_dir": temp_dir, + "db_path": db_path, + "image_dir": os.path.join(temp_dir, "images"), + } + generated_dir = image_studio.default_project_image_dirs(project, config=config)[ + "generated" + ] + os.makedirs(generated_dir, exist_ok=True) + generated_path = os.path.join(generated_dir, "result.png") + with open(generated_path, "wb") as fh: + fh.write(self._png_bytes()) + asset = image_studio.add_asset( + project.id, + "generated_main", + local_path=generated_path, + path=db_path, + ) + + record = image_studio_images.trash_generated_asset( + asset.id, + path=db_path, + config=config, + ) + trashed = image_studio.get_asset(asset.id, path=db_path) + + self.assertEqual(image_studio.ASSET_STATUS_MISSING, trashed.status) + self.assertTrue(os.path.isfile(record["trash_path"])) + self.assertFalse(os.path.exists(generated_path)) + + restored = image_studio_images.restore_trashed_asset( + record, + path=db_path, + config=config, + ) + + self.assertEqual(asset.id, restored.id) + self.assertEqual(image_studio.ASSET_STATUS_AVAILABLE, restored.status) + self.assertTrue(os.path.isfile(restored.local_path)) + + self.assert_removed(temp_dir) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_product_suite.py b/tests/test_product_suite.py new file mode 100644 index 0000000..3607339 --- /dev/null +++ b/tests/test_product_suite.py @@ -0,0 +1,74 @@ +import os +import sys +import unittest +from types import SimpleNamespace + +sys.path.insert(0, os.path.dirname(__file__)) + +from app import product_suite + + +class ProductSuiteTests(unittest.TestCase): + def test_defaults_and_per_image_total(self): + settings = product_suite.default_suite_settings() + + self.assertEqual(["白底图", "场景图", "卖点图"], product_suite.category_order(settings)) + self.assertEqual(5, product_suite.suite_total_count(settings, 3)) + + settings["per_image_primary"] = True + + self.assertEqual(13, product_suite.suite_total_count(settings, 3)) + + def test_custom_category_validation_and_normalization(self): + self.assertEqual("分类名称不能为空", product_suite.suite_name_error("")) + self.assertEqual("分类名称不能包含空格", product_suite.suite_name_error("使用 场景")) + self.assertEqual("分类名称不能超过10个字", product_suite.suite_name_error("一二三四五六七八九十甲")) + self.assertEqual("分类名称已存在", product_suite.suite_name_error("场景图", {"场景图"})) + + settings = product_suite.normalize_suite_settings( + { + "ratio": "3:4", + "categories": {"白底图": 2, "场景图": 0, "卖点图": 1, "尺寸图": 2}, + "custom_category_order": ["尺寸图"], + } + ) + + self.assertEqual("3:4", settings["ratio"]) + self.assertEqual(["白底图", "场景图", "卖点图", "尺寸图"], product_suite.category_order(settings)) + self.assertEqual(5, product_suite.suite_total_count(settings, 1)) + + def test_job_specs_include_selected_context_and_source_assignment(self): + settings = product_suite.default_suite_settings() + settings.update( + { + "platform": "Shopee", + "country": "中国台湾", + "language": "繁体中文", + "ratio": "4:3", + "per_image_primary": True, + "categories": {"白底图": 1, "场景图": 1, "卖点图": 0}, + } + ) + assets = [SimpleNamespace(id=11), SimpleNamespace(id=12)] + + specs = product_suite.build_job_specs( + assets, + "40小时续航,适合通勤", + settings, + "51100639510", + ) + + self.assertEqual(3, len(specs)) + self.assertEqual([11, 11, 12], [spec["source_asset_id"] for spec in specs]) + self.assertEqual(["白底图", "场景图", "场景图"], [spec["job_type"] for spec in specs]) + for spec in specs: + self.assertIn("平台:Shopee", spec["prompt"]) + self.assertIn("国家地区:中国台湾", spec["prompt"]) + self.assertIn("输出语言:繁体中文", spec["prompt"]) + self.assertIn("图片比例:4:3", spec["prompt"]) + self.assertIn("商品ID:51100639510", spec["prompt"]) + self.assertIn("40小时续航", spec["prompt"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py new file mode 100644 index 0000000..3a5057c --- /dev/null +++ b/tests/test_product_suite_gui.py @@ -0,0 +1,172 @@ +import os +import sys +import unittest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, os.path.dirname(__file__)) + +from _helpers import TempDirMixin + +from app import accounts, image_studio, image_studio_images +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 app.gui.tabs.product_suite import ProductSuiteTab, SuiteResultCard + + +class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def tearDown(self): + for widget in QApplication.topLevelWidgets(): + widget.close() + widget.deleteLater() + self.app.processEvents() + + def _config(self, temp_dir): + return { + "chrome_path": "chrome.exe", + "user_data_root": os.path.join(temp_dir, "chrome_user_data_dir"), + "image_dir": os.path.join(temp_dir, "images"), + "db_path": os.path.join(temp_dir, "cmshopee.db"), + "debug_port_range": [9222, 9260], + "config_path": os.path.join(temp_dir, "config.json"), + "cmhub_config_path": os.path.join(temp_dir, "cmhub.json"), + } + + def _write_image(self, path): + image = QImage(40, 30, QImage.Format_RGB32) + image.fill(0xFF336699) + self.assertTrue(image.save(path)) + + def test_tab_builds_suite_controls_without_old_detail_workspace(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=config) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + + self.assertEqual("productSuiteTab", tab.objectName()) + self.assertEqual(1, tab.task_tabs.count()) + self.assertEqual("套图任务 1", tab.task_tabs.tabText(0)) + self.assertEqual("alias-a", tab.account_combo.currentData()) + self.assertEqual("Shopee", tab.platform_combo.currentData()) + self.assertEqual("中国台湾", tab.country_combo.currentData()) + self.assertEqual("繁体中文", tab.language_combo.currentData()) + self.assertEqual("1:1", tab.ratio_combo.currentData()) + self.assertEqual("合计 5 张", tab.category_total_label.text()) + self.assertEqual("生成套图(5)", tab.generate_button.text()) + + visible_text = " ".join( + [widget.text() for widget in tab.findChildren(QLabel)] + + [widget.text() for widget in tab.findChildren(QPushButton)] + ) + self.assertNotIn("详情图", visible_text) + self.assertNotIn("AI工场", visible_text) + self.assertIn("白底图", visible_text) + self.assertIn("场景图", visible_text) + self.assertIn("卖点图", visible_text) + + tab.add_custom_category() + self.assertFalse(tab.custom_category_edit.isHidden()) + tab.custom_category_edit.setText("尺寸图") + 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.assert_removed(temp_dir) + + def test_task_tabs_keep_independent_prompt_and_settings(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=config) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + + tab.prompt_edit.setPlainText("任务一卖点") + first_state = tab._displayed_state + tab.ratio_combo.setCurrentIndex(tab.ratio_combo.findData("3:4")) + second_state = tab.add_task(inherit=True) + + self.assertEqual(2, tab.task_tabs.count()) + self.assertEqual("任务一卖点", second_state.prompt) + self.assertEqual("3:4", second_state.settings["ratio"]) + tab.prompt_edit.setPlainText("任务二卖点") + tab.ratio_combo.setCurrentIndex(tab.ratio_combo.findData("16:9")) + tab.task_tabs.setCurrentIndex(0) + + self.assertIs(first_state, tab._displayed_state) + self.assertEqual("任务一卖点", tab.prompt_edit.toPlainText()) + self.assertEqual("3:4", tab.ratio_combo.currentData()) + + self.assert_removed(temp_dir) + + def test_project_settings_and_result_history_use_existing_backend(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"], + ) + source_path = os.path.join(temp_dir, "source.png") + self._write_image(source_path) + source = image_studio_images.import_original_files( + project.id, + [source_path], + path=config["db_path"], + config=config, + )["assets"][0] + job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="场景图", + prompt="场景卖点", + path=config["db_path"], + ) + image_studio.update_job_status( + job.id, + "failed", + error="上游超时 https://example.invalid/private", + path=config["db_path"], + ) + + 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 + state.current_job_ids = [job.id] + tab._load_state(state) + + self.assertEqual([source.id], tab.original_list.asset_ids()) + self.assertEqual("共 1 张 · 成功 0 张", tab.result_summary_label.text()) + cards = tab.findChildren(SuiteResultCard) + self.assertEqual(1, len(cards)) + self.assertNotIn( + "https://", + " ".join(label.text() for label in cards[0].findChildren(QLabel)), + ) + + state.settings["ratio"] = "4:3" + state.prompt = "持久化卖点" + tab._persist_state(state) + stored = image_studio.get_project(project.id, path=config["db_path"]) + self.assertEqual("持久化卖点", stored.draft_prompt) + self.assertEqual("4:3", image_studio.project_suite_settings(stored)["ratio"]) + + self.assert_removed(temp_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_suite_workers.py b/tests/test_product_suite_workers.py new file mode 100644 index 0000000..7643a80 --- /dev/null +++ b/tests/test_product_suite_workers.py @@ -0,0 +1,79 @@ +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__)) + +from _helpers import TempDirMixin + +from app import db, image_studio +from app import gui + +if gui.QT_IMPORT_ERROR is not None: + raise unittest.SkipTest("PySide6 未安装") + +from app.gui.workers import ProductSuiteGenerateWorker + + +class ProductSuiteWorkerTests(TempDirMixin, unittest.TestCase): + def test_generate_worker_creates_category_jobs_and_forwards_ratio(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, + ) + source = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=os.path.join(temp_dir, "source.png"), + path=db_path, + ) + worker = ProductSuiteGenerateWorker( + project.id, + [ + { + "source_asset_id": source.id, + "job_type": "白底图", + "prompt": "白底商品图", + }, + { + "source_asset_id": source.id, + "job_type": "场景图", + "prompt": "通勤场景图", + }, + ], + aspect_ratio="3:4", + db_path=db_path, + config={"db_path": db_path}, + ) + + with mock.patch( + "app.gui.workers.image_studio_generation.run_jobs", + return_value={ + "total": 2, + "success": 2, + "failed": 0, + "cancelled": 0, + "jobs": [], + }, + ) as run_jobs: + result = worker.execute() + + jobs = image_studio.list_jobs(project.id, path=db_path) + self.assertEqual(2, len(jobs)) + self.assertEqual({"白底图", "场景图"}, {job.job_type for job in jobs}) + self.assertEqual([job.id for job in reversed(jobs)], result["job_ids"]) + self.assertEqual("3:4", run_jobs.call_args.kwargs["aspect_ratio"]) + self.assertEqual(db_path, run_jobs.call_args.kwargs["path"]) + + self.assert_removed(temp_dir) + + +if __name__ == "__main__": + unittest.main()