From e232097ba5b9810a1dd7b6a5939c286fbe3a0801 Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 17 Jul 2026 09:59:55 +0800 Subject: [PATCH] feat(product-suite): add global generation history --- app/gui/__init__.py | 1 + app/gui/tabs/product_suite.py | 656 ++++++++++++++++++++++++++++-- app/gui/workers.py | 28 ++ app/image_studio.py | 164 ++++++++ app/image_studio_export.py | 158 +++++++ docs/04-architecture.md | 2 +- docs/api.md | 11 +- docs/routes.md | 2 +- docs/tasks/T-646.md | 6 +- tests/test_image_studio.py | 154 +++++++ tests/test_image_studio_export.py | 116 ++++++ tests/test_product_suite_gui.py | 125 +++++- tests/test_workers.py | 38 ++ 13 files changed, 1409 insertions(+), 52 deletions(-) diff --git a/app/gui/__init__.py b/app/gui/__init__.py index a74fcca..85ed23e 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -31,6 +31,7 @@ if QT_IMPORT_ERROR is None: ImageStudioResumeJobsWorker, ProductSuiteAiWriteWorker, ProductSuiteGenerateWorker, + ProductSuiteHistoryExportWorker, ProductSuiteImportImagesWorker, WriteBackWorker, ) diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index fcee533..bc6a093 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -71,6 +71,7 @@ from ..workers import ( ImageStudioPullImagesWorker, ProductSuiteAiWriteWorker, ProductSuiteGenerateWorker, + ProductSuiteHistoryExportWorker, ProductSuiteImportImagesWorker, ) @@ -1132,6 +1133,609 @@ class ProductSuiteHistoryDialog(QDialog): super().closeEvent(event) +class ProductSuiteRoundPreviewDialog(ImagePreviewDialog): + """Browse the usable output images that belong to one generation round.""" + + def __init__(self, entries, start_index=0, title="套图原图预览", parent=None): + self._entries = [ + (str(path or ""), str(label or "生成图片")) + for path, label in list(entries or []) + if str(path or "") + ] + if not self._entries: + self._entries = [("", "生成图片")] + self._entry_index = max(0, min(int(start_index or 0), len(self._entries) - 1)) + self._title_prefix = str(title or "套图原图预览") + path, label = self._entries[self._entry_index] + super().__init__(path, self._entry_title(label), parent) + self.setObjectName("suiteHistoryRoundPreviewDialog") + + navigation = QHBoxLayout() + navigation.setSpacing(6) + self.previous_button = QToolButton() + self.previous_button.setObjectName("suiteHistoryPreviewPreviousButton") + self.previous_button.setText("上一张") + self.previous_button.setToolTip("查看上一张生成图片") + self.previous_button.clicked.connect(self.show_previous) + navigation.addWidget(self.previous_button) + self.index_label = QLabel() + self.index_label.setObjectName("suiteHistoryPreviewIndexLabel") + self.index_label.setAlignment(Qt.AlignCenter) + self.index_label.setMinimumWidth(86) + navigation.addWidget(self.index_label) + self.next_button = QToolButton() + self.next_button.setObjectName("suiteHistoryPreviewNextButton") + self.next_button.setText("下一张") + self.next_button.setToolTip("查看下一张生成图片") + self.next_button.clicked.connect(self.show_next) + navigation.addWidget(self.next_button) + navigation.addStretch(1) + self.layout().insertLayout(1, navigation) + self._update_navigation() + + def _entry_title(self, label): + return "%s · %s" % (self._title_prefix, str(label or "生成图片")) + + def _update_navigation(self): + total = len(self._entries) + self.index_label.setText("%d / %d" % (self._entry_index + 1, total)) + self.previous_button.setEnabled(total > 1 and self._entry_index > 0) + self.next_button.setEnabled(total > 1 and self._entry_index < total - 1) + + def show_previous(self, checked=False): + if self._entry_index > 0: + self._show_entry(self._entry_index - 1) + + def show_next(self, checked=False): + if self._entry_index < len(self._entries) - 1: + self._show_entry(self._entry_index + 1) + + def _show_entry(self, index): + self._entry_index = max(0, min(int(index), len(self._entries) - 1)) + path, label = self._entries[self._entry_index] + self._base_title = self._entry_title(label) + self._source = self._load_source(path) + self.fit_to_window = True + self._display_size = QSize() + self.setWindowTitle(self._window_title()) + self._update_navigation() + self._render() + + +class SuiteGlobalHistoryThumbnail(QLabel): + """Fixed-size thumbnail that opens its source image on double-click.""" + + previewRequested = Signal(int) + + def __init__(self, job, asset, entry_index, parent=None): + super().__init__(parent) + self.entry_index = int(entry_index) + self._available = asset is not None and _asset_usable(asset) + self.setObjectName("suiteGlobalHistoryThumbnail") + self.setAlignment(Qt.AlignCenter) + self.setFixedSize(104, 78) + self.setToolTip( + "%s · %s" + % ( + str(getattr(job, "job_type", "套图") or "套图"), + SuiteHistoryImageCard._status_text(getattr(job, "status", "")), + ) + ) + if self._available: + self.setPixmap(_image_pixmap(asset.local_path, QSize(104, 78))) + self.setCursor(Qt.PointingHandCursor) + else: + self.setPixmap( + _placeholder_pixmap( + "图片不可用", + QSize(104, 78), + "#fff8c5", + ) + ) + + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.LeftButton and self._available: + self.previewRequested.emit(self.entry_index) + super().mouseDoubleClickEvent(event) + + +class SuiteGlobalHistoryRoundRow(QFrame): + """One fixed-height, cross-project history row for a generation round.""" + + previewRequested = Signal(object, int) + exportRequested = Signal(object) + + MAX_THUMBNAILS = 5 + + def __init__(self, round_info, jobs, assets_by_job, parent=None): + super().__init__(parent) + self.round_info = round_info + self.jobs = list(jobs or []) + self.assets_by_job = dict(assets_by_job or {}) + self._usable_entries = [ + (job, self.assets_by_job.get(int(getattr(job, "id", 0) or 0))) + for job in self.jobs + if self.assets_by_job.get(int(getattr(job, "id", 0) or 0)) is not None + and _asset_usable(self.assets_by_job.get(int(getattr(job, "id", 0) or 0))) + ] + self.setObjectName("suiteGlobalHistoryRoundRow") + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setFixedHeight(118) + self.setStyleSheet( + "QFrame#suiteGlobalHistoryRoundRow {" + "border: 1px solid #d8dee4; border-radius: 6px; background: #ffffff;" + "}" + ) + + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 8, 10, 8) + layout.setSpacing(10) + + meta = QVBoxLayout() + meta.setSpacing(3) + meta.setContentsMargins(0, 0, 0, 0) + time_label = QLabel( + "旧版历史记录" + if bool(getattr(round_info, "is_legacy", False)) + else "生成于 %s" % _history_time_text(getattr(round_info, "created_at", "")) + ) + time_label.setObjectName("suiteGlobalHistoryTime") + time_label.setStyleSheet("font-weight: 600; color: #24292f;") + meta.addWidget(time_label) + account = str( + getattr(round_info, "account_name", "") + or getattr(round_info, "account_alias", "") + or "未命名店铺" + ) + item_text = ( + "临时草稿" + if str(getattr(round_info, "binding_state", "")) + == image_studio.PROJECT_BINDING_DRAFT + else "商品ID:%s" % str(getattr(round_info, "item_id", "") or "未填写") + ) + meta.addWidget(QLabel("店铺:%s" % account)) + meta.addWidget(QLabel(item_text)) + layout.addLayout(meta, 0) + + thumbnails = QHBoxLayout() + thumbnails.setSpacing(6) + thumbnails.setContentsMargins(0, 0, 0, 0) + shown_entries = self._usable_entries[: self.MAX_THUMBNAILS] + for entry_index, (job, asset) in enumerate(shown_entries): + thumbnail = SuiteGlobalHistoryThumbnail(job, asset, entry_index) + thumbnail.previewRequested.connect(self._emit_preview) + thumbnails.addWidget(thumbnail) + if not shown_entries: + placeholder = QLabel("本轮没有可预览图片") + placeholder.setObjectName("suiteGlobalHistoryNoImage") + placeholder.setAlignment(Qt.AlignCenter) + placeholder.setFixedSize(150, 78) + placeholder.setStyleSheet("color: #6b7280; background: #f6f8fa;") + thumbnails.addWidget(placeholder) + remaining = max(0, len(self._usable_entries) - len(shown_entries)) + if remaining: + more = QLabel("+%d" % remaining) + more.setObjectName("suiteGlobalHistoryMoreImages") + more.setAlignment(Qt.AlignCenter) + more.setFixedSize(42, 78) + more.setToolTip("本轮还有%d张可预览生成图片" % remaining) + more.setStyleSheet("color: #57606a; background: #f6f8fa; border-radius: 4px;") + thumbnails.addWidget(more) + thumbnails.addStretch(1) + layout.addLayout(thumbnails, 1) + + badges = QVBoxLayout() + badges.setSpacing(5) + badges.setContentsMargins(0, 0, 0, 0) + if bool(getattr(round_info, "is_current", False)): + current = QLabel("当前") + current.setObjectName("suiteGlobalHistoryCurrentBadge") + current.setAlignment(Qt.AlignCenter) + current.setStyleSheet( + "color: #0969da; background: #ddf4ff; border: 1px solid #54aeff; " + "border-radius: 5px; padding: 2px 6px; font-weight: 600;" + ) + badges.addWidget(current) + elif bool(getattr(round_info, "is_legacy", False)): + legacy = QLabel("旧版") + legacy.setAlignment(Qt.AlignCenter) + legacy.setStyleSheet( + "color: #57606a; background: #f6f8fa; border: 1px solid #d8dee4; " + "border-radius: 5px; padding: 2px 6px;" + ) + badges.addWidget(legacy) + stats = QLabel(self._stats_text(round_info)) + stats.setObjectName("suiteGlobalHistoryStats") + stats.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + stats.setStyleSheet("color: #57606a;") + badges.addWidget(stats) + badges.addStretch(1) + self.export_button = QPushButton("导出本轮") + self.export_button.setObjectName("suiteGlobalHistoryExportButton") + self.export_button.setEnabled(bool(getattr(round_info, "succeeded_count", 0))) + self.export_button.setToolTip("复制本轮成功生成的图片到所选目录") + self.export_button.clicked.connect(lambda: self.exportRequested.emit(self)) + badges.addWidget(self.export_button) + layout.addLayout(badges, 0) + + @staticmethod + def _stats_text(round_info): + parts = ["成功 %d" % int(getattr(round_info, "succeeded_count", 0) or 0)] + failed = int(getattr(round_info, "failed_count", 0) or 0) + cancelled = int(getattr(round_info, "cancelled_count", 0) or 0) + active = int(getattr(round_info, "active_count", 0) or 0) + retry = int(getattr(round_info, "retry_count", 0) or 0) + if failed: + parts.append("失败 %d" % failed) + if cancelled: + parts.append("停止 %d" % cancelled) + if active: + parts.append("进行中 %d" % active) + if retry: + parts.append("重试 %d" % retry) + return " · ".join(parts) + + def set_exporting(self, exporting): + self.export_button.setEnabled( + not bool(exporting) and bool(getattr(self.round_info, "succeeded_count", 0)) + ) + self.export_button.setText("正在导出" if exporting else "导出本轮") + + def _emit_preview(self, entry_index): + self.previewRequested.emit(self, int(entry_index)) + + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.LeftButton and self._usable_entries: + self._emit_preview(0) + super().mouseDoubleClickEvent(event) + + +class ProductSuiteGlobalHistoryDialog(QDialog): + """Read-only global product-suite history with per-round preview and export.""" + + PAGE_SIZE = 30 + + def __init__(self, *, current_project_id=None, db_path=None, parent=None): + super().__init__(parent) + self.db_path = db_path + self.current_project_id = None + self._offset = 0 + self._has_more = False + self._round_count = 0 + self._available_image_count = 0 + self._history_rows = [] + self._asset_cache = {} + self._export_worker = None + self._export_thread = None + self._export_error_handled = False + + self.setObjectName("suiteGlobalHistoryDialog") + self.setWindowTitle("套图历史生成记录") + self.setModal(False) + self.setAttribute(Qt.WA_DeleteOnClose, True) + self.setMinimumSize(940, 560) + self.resize(1160, 720) + + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(10) + + header = QHBoxLayout() + title_layout = QVBoxLayout() + title_layout.setSpacing(2) + self.context_label = QLabel("全部商品的套图生成历史") + self.context_label.setObjectName("suiteGlobalHistoryContextLabel") + self.context_label.setStyleSheet("font-weight: 600; color: #24292f;") + self.summary_label = QLabel() + self.summary_label.setObjectName("suiteGlobalHistorySummaryLabel") + self.summary_label.setStyleSheet("color: #57606a;") + title_layout.addWidget(self.context_label) + title_layout.addWidget(self.summary_label) + header.addLayout(title_layout, 1) + self.refresh_button = QPushButton("刷新") + self.refresh_button.setObjectName("suiteGlobalHistoryRefreshButton") + self.refresh_button.setToolTip("重新读取套图历史生成记录") + self.refresh_button.clicked.connect(self.refresh_history) + header.addWidget(self.refresh_button) + layout.addLayout(header) + + filters = QHBoxLayout() + filters.setSpacing(8) + filters.addWidget(QLabel("店铺")) + self.account_filter_edit = QLineEdit() + self.account_filter_edit.setObjectName("suiteGlobalHistoryAccountFilter") + self.account_filter_edit.setPlaceholderText("店铺或账号") + self.account_filter_edit.setClearButtonEnabled(True) + self.account_filter_edit.returnPressed.connect(self.refresh_history) + filters.addWidget(self.account_filter_edit, 1) + filters.addWidget(QLabel("商品ID")) + self.item_filter_edit = QLineEdit() + self.item_filter_edit.setObjectName("suiteGlobalHistoryItemFilter") + self.item_filter_edit.setPlaceholderText("输入商品ID") + self.item_filter_edit.setClearButtonEnabled(True) + self.item_filter_edit.returnPressed.connect(self.refresh_history) + filters.addWidget(self.item_filter_edit, 1) + self.current_project_checkbox = QCheckBox("仅当前商品") + self.current_project_checkbox.setObjectName("suiteGlobalHistoryCurrentProjectFilter") + self.current_project_checkbox.toggled.connect(self.refresh_history) + filters.addWidget(self.current_project_checkbox) + self.filter_button = QPushButton("筛选") + self.filter_button.setObjectName("suiteGlobalHistoryFilterButton") + self.filter_button.clicked.connect(self.refresh_history) + filters.addWidget(self.filter_button) + layout.addLayout(filters) + + self.notice_label = QLabel() + self.notice_label.setObjectName("suiteGlobalHistoryNoticeLabel") + self.notice_label.setWordWrap(True) + self.notice_label.hide() + layout.addWidget(self.notice_label) + + self.scroll = QScrollArea() + self.scroll.setObjectName("suiteGlobalHistoryScrollArea") + self.scroll.setWidgetResizable(True) + self.history_content = QWidget() + self.history_content.setObjectName("suiteGlobalHistoryContent") + self.history_layout = QVBoxLayout(self.history_content) + self.history_layout.setContentsMargins(0, 0, 0, 0) + self.history_layout.setSpacing(7) + self.history_layout.setAlignment(Qt.AlignTop) + self.scroll.setWidget(self.history_content) + layout.addWidget(self.scroll, 1) + + self.load_more_button = QPushButton("加载更多") + self.load_more_button.setObjectName("suiteGlobalHistoryLoadMoreButton") + self.load_more_button.clicked.connect(self.load_more) + self.load_more_button.hide() + layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter) + + self.set_current_project(current_project_id, refresh=False) + self.refresh_history() + + def set_current_project(self, project_id, *, refresh=True): + try: + normalized = int(project_id) if project_id is not None else None + except (TypeError, ValueError): + normalized = None + changed = normalized != self.current_project_id + self.current_project_id = normalized + previous = self.current_project_checkbox.blockSignals(True) + self.current_project_checkbox.setEnabled(normalized is not None) + self.current_project_checkbox.setToolTip( + "只显示当前商品项目的历史生成记录" + if normalized is not None + else "当前没有可筛选的商品项目" + ) + if normalized is None: + self.current_project_checkbox.setChecked(False) + self.current_project_checkbox.blockSignals(previous) + if refresh and (changed or self.current_project_checkbox.isChecked()): + self.refresh_history() + + def refresh_history(self, checked=False): + scroll_value = self.scroll.verticalScrollBar().value() + self._clear_history_content() + self._offset = 0 + self._has_more = False + self._round_count = 0 + self._available_image_count = 0 + self._asset_cache = {} + if not self._load_next_page(): + return + QTimer.singleShot( + 0, + lambda: self.scroll.verticalScrollBar().setValue( + min(scroll_value, self.scroll.verticalScrollBar().maximum()) + ), + ) + + def load_more(self, checked=False): + if self._has_more: + self._load_next_page() + + def _load_next_page(self): + try: + project_id = ( + self.current_project_id + if self.current_project_checkbox.isChecked() + else None + ) + rounds = image_studio.list_global_generation_rounds( + account_query=self.account_filter_edit.text(), + item_query=self.item_filter_edit.text(), + project_id=project_id, + limit=self.PAGE_SIZE, + offset=self._offset, + path=self.db_path, + ) + except Exception as exc: + self._set_error("历史记录读取失败:%s" % _user_error(exc)) + return False + + self.notice_label.hide() + if not rounds and self._offset == 0: + self._show_empty_state() + else: + for round_info in rounds: + self._add_round(round_info) + self._offset += len(rounds) + self._has_more = len(rounds) == self.PAGE_SIZE + self.load_more_button.setVisible(self._has_more) + self.load_more_button.setEnabled(self._has_more) + self._update_summary() + return True + + def _add_round(self, round_info): + try: + jobs = image_studio.list_generation_round_current_jobs( + round_info.project_id, + round_info.generation_round_key, + path=self.db_path, + ) + assets_by_job = {} + for job in jobs: + asset_id = getattr(job, "output_asset_id", None) + if not asset_id: + continue + asset_id = int(asset_id) + if asset_id not in self._asset_cache: + self._asset_cache[asset_id] = image_studio.get_asset( + asset_id, + path=self.db_path, + ) + assets_by_job[int(job.id)] = self._asset_cache[asset_id] + except Exception as exc: + self._set_notice("部分历史记录无法读取:%s" % _user_error(exc), "#cf222e") + return + + row = SuiteGlobalHistoryRoundRow(round_info, jobs, assets_by_job) + row.previewRequested.connect(self._preview_round) + row.exportRequested.connect(self._export_round) + self.history_layout.addWidget(row) + self._history_rows.append(row) + self._round_count += 1 + self._available_image_count += len(row._usable_entries) + + def _preview_round(self, row, start_index): + entries = [ + ( + asset.local_path, + str(getattr(job, "job_type", "套图") or "套图"), + ) + for job, asset in row._usable_entries + ] + if not entries: + self._set_notice("本轮没有可预览的本地生成图片。", "#9a6700") + return + title = "商品ID %s" % str(getattr(row.round_info, "item_id", "") or "临时草稿") + ProductSuiteRoundPreviewDialog( + entries, + start_index=start_index, + title=title, + parent=self, + ).exec() + + def _export_round(self, row): + if self._export_worker is not None: + self._set_notice("正在导出另一轮图片,请稍候。", "#9a6700") + return + parent_dir = QFileDialog.getExistingDirectory(self, "选择导出父目录") + if not parent_dir: + return + worker = ProductSuiteHistoryExportWorker( + row.round_info.project_id, + row.round_info.generation_round_key, + parent_dir, + db_path=self.db_path, + ) + thread = run_worker(worker, thread_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)) + worker.finished.connect(self._on_export_finished) + worker.cancelled.connect(self._on_export_cancelled) + worker.failed.connect(self._on_export_failed) + self._export_worker = worker + self._export_thread = thread + self._export_error_handled = False + self._set_export_controls(True) + thread.start() + + def _on_export_finished(self, summary): + if dict(summary or {}).get("ok") is False: + if not self._export_error_handled: + self._on_export_failed(-1, dict(summary or {}).get("error") or "导出失败") + return + self._reset_export_state() + summary = dict(summary or {}) + if summary.get("cancelled"): + self._set_notice("导出已停止,已完成的图片保留在导出目录中。", "#9a6700") + return + file_count = int(summary.get("file_count", 0) or 0) + skipped_count = int(summary.get("skipped_count", 0) or 0) + message = "本轮已导出%d张生成图片" % file_count + if skipped_count: + message += ",略过%d张不可用图片" % skipped_count + self._set_notice(message, "#1a7f37") + target_dir = str(summary.get("target_dir") or "") + if not target_dir: + return + box = QMessageBox(self) + box.setWindowTitle("导出完成") + box.setText(message) + open_button = box.addButton("打开目录", QMessageBox.AcceptRole) + box.addButton("确定", QMessageBox.RejectRole) + box.setDefaultButton(open_button) + box.exec() + if box.clickedButton() is open_button: + try: + file_manager.open_in_file_manager(target_dir) + except Exception as exc: + self._set_notice("打开导出目录失败:%s" % _user_error(exc), "#cf222e") + + def _on_export_cancelled(self, summary): + self._reset_export_state() + self._set_notice("导出已停止,已完成的图片保留在导出目录中。", "#9a6700") + + def _on_export_failed(self, _row, error): + if self._export_error_handled: + return + self._export_error_handled = True + self._reset_export_state() + self._set_notice("导出本轮失败:%s" % _user_error(error), "#cf222e") + + def _reset_export_state(self): + self._export_worker = None + self._export_thread = None + self._set_export_controls(False) + + def _set_export_controls(self, exporting): + for row in list(self._history_rows): + try: + row.set_exporting(exporting) + except RuntimeError: + continue + + def _update_summary(self): + refreshed = time.strftime("%H:%M:%S") + self.summary_label.setText( + "已加载 %d 轮 · 可预览图片 %d 张 · 最近刷新 %s" + % (self._round_count, self._available_image_count, refreshed) + ) + + def _clear_history_content(self): + self._history_rows = [] + while self.history_layout.count(): + item = self.history_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def _set_error(self, message): + self._clear_history_content() + self._set_notice(message, "#cf222e") + self.load_more_button.hide() + self.refresh_button.setEnabled(True) + + def _set_notice(self, text, color="#57606a"): + self.notice_label.setText(str(text)) + self.notice_label.setStyleSheet("color: %s; padding: 8px 0;" % color) + self.notice_label.show() + + def _show_empty_state(self): + empty = QLabel("暂无套图历史生成记录,完成套图生成后会自动出现在这里") + empty.setObjectName("suiteGlobalHistoryEmptyLabel") + empty.setAlignment(Qt.AlignCenter) + empty.setStyleSheet("color: #6b7280; padding: 56px;") + self.history_layout.addWidget(empty) + + def closeEvent(self, event): + if self._export_worker is not None: + self._export_worker.cancel() + self._clear_history_content() + super().closeEvent(event) + + @dataclass class SuiteTaskState: key: int @@ -1855,8 +2459,6 @@ class ProductSuiteTab(QWidget): self._generation_run_states.pop(state.generation_run_token, None) if state.pull_run_token: self._pull_run_states.pop(state.pull_run_token, None) - if state.project_id is not None: - self._close_history_dialog_for_project(state.project_id) self._retired_states.append(state) self._states.pop(state.key, None) self.task_tabs.removeTab(index) @@ -4310,48 +4912,23 @@ class ProductSuiteTab(QWidget): def open_history_dialog(self, checked=False): state = self._displayed_state - if state is None or state.project_id is None: - self._message( - "未创建商品套图", - "请先选择账号并添加本地图片,完成生成后再查看历史记录。", - ) - return - try: - project = image_studio.get_project(state.project_id, path=self.db_path) - rounds = image_studio.list_generation_rounds( - state.project_id, - limit=1, - path=self.db_path, - ) - except Exception as exc: - self._message("读取历史生成失败", _user_error(exc)) - return - if project is None: - self._message("商品项目不存在", "当前商品套图项目已经不存在或已删除。") - return - if not rounds: - self._message( - "暂无历史生成记录", - "完成套图生成后会自动出现在历史生成记录中。", - ) - return + current_project_id = state.project_id if state is not None else None dialog = self._history_dialog if dialog is not None: try: - if dialog.project_id == int(project.id): - dialog.refresh_history() - dialog.show() - dialog.raise_() - dialog.activateWindow() - return - dialog.close() + dialog.set_current_project(current_project_id, refresh=False) + dialog.refresh_history() + dialog.show() + dialog.raise_() + dialog.activateWindow() + return except RuntimeError: pass self._history_dialog = None - dialog = ProductSuiteHistoryDialog( - project.id, + dialog = ProductSuiteGlobalHistoryDialog( + current_project_id=current_project_id, db_path=self.db_path, parent=self, ) @@ -4368,13 +4945,14 @@ class ProductSuiteTab(QWidget): self._history_dialog = None def _close_history_dialog_for_project(self, project_id=None): + if project_id is not None: + return dialog = self._history_dialog if dialog is None: return try: - if project_id is None or dialog.project_id == int(project_id): - dialog.close() - self._history_dialog = None + dialog.close() + self._history_dialog = None except RuntimeError: self._history_dialog = None diff --git a/app/gui/workers.py b/app/gui/workers.py index d62631e..782c17b 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -418,6 +418,34 @@ class ProductSuiteGenerateWorker(BaseWorker): return summary +class ProductSuiteHistoryExportWorker(BaseWorker): + """Copy one product-suite generation round outside the GUI thread.""" + + def __init__(self, project_id, generation_round_key, parent_dir, *, db_path=None): + super().__init__() + self.project_id = int(project_id) + self.generation_round_key = generation_round_key + self.parent_dir = str(parent_dir or "") + self.db_path = db_path + + def execute(self): + self.log.emit("[商品套图] 导出历史套图:开始") + result = image_studio_export.export_generation_round( + self.project_id, + self.generation_round_key, + self.parent_dir, + path=self.db_path, + should_stop=self.should_cancel, + ) + self.log.emit("[商品套图] 导出历史套图:完成") + return { + "target_dir": result.target_dir, + "file_count": len(result.files), + "skipped_count": int(result.skipped_count), + "cancelled": bool(result.cancelled), + } + + class ProductSuiteAiWriteWorker(BaseWorker): """Analyze local product images without blocking the suite workspace.""" diff --git a/app/image_studio.py b/app/image_studio.py index 043d0a5..0a0f886 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -128,6 +128,29 @@ class ImageStudioGenerationRound: is_legacy: bool +@dataclass(frozen=True) +class ImageStudioHistoryRound: + """A persisted generation round with enough project context for global history.""" + + project_id: int + account_alias: str + account_name: Optional[str] + item_id: str + binding_state: str + generation_round_key: Optional[str] + created_at: Optional[str] + updated_at: Optional[str] + job_count: int + slot_count: int + succeeded_count: int + failed_count: int + cancelled_count: int + active_count: int + retry_count: int + is_current: bool + is_legacy: bool + + class ImageStudioError(RuntimeError): """Raised when the AI image studio service cannot complete an operation.""" @@ -1296,6 +1319,147 @@ def list_generation_rounds(project_id, *, limit=None, offset=0, path=None, conn= return rounds +def list_global_generation_rounds( + *, + account_query="", + item_query="", + project_id=None, + limit=None, + offset=0, + path=None, + conn=None, +): + """List persisted generation rounds across active projects without loading assets.""" + + try: + offset = max(0, int(offset or 0)) + except (TypeError, ValueError) as exc: + raise db.DbError("全局历史偏移量无效") from exc + if limit is not None: + try: + limit = int(limit) + except (TypeError, ValueError) as exc: + raise db.DbError("全局历史数量无效") from exc + if limit <= 0: + return [] + + clauses = ["projects.deleted_at IS NULL"] + params = [] + if project_id is not None: + try: + clauses.append("projects.id = ?") + params.append(int(project_id)) + except (TypeError, ValueError) as exc: + raise db.DbError("当前商品项目无效") from exc + account_text = str(account_query or "").strip() + if account_text: + pattern = "%%%s%%" % account_text + clauses.append( + "(projects.account_alias LIKE ? OR COALESCE(projects.account_name, '') LIKE ?)" + ) + params.extend([pattern, pattern]) + item_text = str(item_query or "").strip() + if item_text: + clauses.append("projects.item_id LIKE ?") + params.append("%%%s%%" % item_text) + + sql = """ + WITH latest_round_jobs AS ( + SELECT project_id, + generation_round_key, + generation_slot_index, + MAX(id) AS latest_id + FROM image_studio_jobs + WHERE generation_round_key IS NOT NULL + GROUP BY project_id, generation_round_key, generation_slot_index + ), + effective_jobs AS ( + SELECT jobs.* + FROM image_studio_jobs AS jobs + LEFT JOIN latest_round_jobs AS latest + ON latest.latest_id = jobs.id + WHERE jobs.generation_round_key IS NULL OR latest.latest_id IS NOT NULL + ), + round_attempts AS ( + SELECT project_id, + generation_round_key, + COUNT(*) AS attempt_count + FROM image_studio_jobs + GROUP BY project_id, generation_round_key + ) + SELECT projects.id AS project_id, + projects.account_alias AS account_alias, + projects.account_name AS account_name, + projects.item_id AS item_id, + projects.binding_state AS binding_state, + jobs.generation_round_key AS generation_round_key, + MIN(jobs.created_at) AS created_at, + MAX(jobs.updated_at) AS updated_at, + COUNT(*) AS job_count, + COUNT(DISTINCT jobs.generation_slot_index) AS slot_count, + MAX(attempts.attempt_count) AS attempt_count, + SUM(CASE WHEN jobs.status = 'succeeded' THEN 1 ELSE 0 END) AS succeeded_count, + SUM(CASE WHEN jobs.status IN ('failed', 'expired') THEN 1 ELSE 0 END) AS failed_count, + SUM(CASE WHEN jobs.status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_count, + SUM(CASE WHEN jobs.status IN ('pending', 'submitted', 'running') THEN 1 ELSE 0 END) AS active_count, + CASE + WHEN jobs.generation_round_key IS NOT NULL + AND jobs.generation_round_key = projects.current_generation_round_key + THEN 1 ELSE 0 + END AS is_current, + CASE WHEN jobs.generation_round_key IS NULL THEN 1 ELSE 0 END AS is_legacy + FROM image_studio_projects AS projects + INNER JOIN effective_jobs AS jobs ON jobs.project_id = projects.id + INNER JOIN round_attempts AS attempts + ON attempts.project_id = jobs.project_id + AND attempts.generation_round_key IS jobs.generation_round_key + WHERE %s + GROUP BY projects.id, jobs.generation_round_key + ORDER BY MIN(jobs.created_at) DESC, + MAX(jobs.id) DESC + """ % " AND ".join(clauses) + if limit is not None: + sql += " LIMIT ? OFFSET ?" + params.extend([limit, offset]) + elif offset: + sql += " LIMIT -1 OFFSET ?" + params.append(offset) + with _connection(conn, path) as database: + rows = database.execute(sql, params).fetchall() + + rounds = [] + for row in rows: + round_key = row["generation_round_key"] + job_count = int(row["job_count"] or 0) + slot_count = int(row["slot_count"] or 0) + rounds.append( + ImageStudioHistoryRound( + project_id=int(row["project_id"]), + account_alias=str(row["account_alias"] or ""), + account_name=row["account_name"], + item_id=str(row["item_id"] or ""), + binding_state=str(row["binding_state"] or ""), + generation_round_key=round_key, + created_at=row["created_at"], + updated_at=row["updated_at"], + job_count=job_count, + slot_count=slot_count, + succeeded_count=int(row["succeeded_count"] or 0), + failed_count=int(row["failed_count"] or 0), + cancelled_count=int(row["cancelled_count"] or 0), + active_count=int(row["active_count"] or 0), + retry_count=( + max(0, int(row["attempt_count"] or 0) - job_count) + if round_key + else 0 + ), + is_current=bool(row["is_current"]), + is_legacy=bool(row["is_legacy"]), + ) + ) + return rounds + + def list_generation_round_current_jobs(project_id, generation_round_key, path=None, conn=None): project_id = int(project_id) if generation_round_key is None: diff --git a/app/image_studio_export.py b/app/image_studio_export.py index 9c3a83e..0d54689 100644 --- a/app/image_studio_export.py +++ b/app/image_studio_export.py @@ -43,6 +43,23 @@ class ExportResult: existing_mode: str +@dataclass(frozen=True) +class GenerationRoundExportedFile: + job_id: int + asset_id: int + job_type: str + source_path: str + output_path: str + + +@dataclass(frozen=True) +class GenerationRoundExportResult: + target_dir: str + files: tuple[GenerationRoundExportedFile, ...] + skipped_count: int + cancelled: bool + + def target_dir_for_project(project, parent_dir, suffix=None): parent = _existing_parent_dir(parent_dir) item = _safe_item_id(getattr(project, "item_id", "")) @@ -114,6 +131,79 @@ def export_project_selection( shutil.rmtree(staging, ignore_errors=True) +def export_generation_round( + project_id, + generation_round_key, + parent_dir, + *, + path=None, + should_stop=None, +): + """Copy usable successful output images from one persisted generation round.""" + + project = image_studio.get_project(project_id, path=path) + if project is None: + raise ImageStudioExportError("商品套图项目不存在,无法导出历史记录") + try: + parent = _existing_parent_dir(parent_dir) + except ImageStudioExportError as exc: + raise ImageStudioExportError("导出父目录不可用") from exc + jobs = image_studio.list_generation_round_current_jobs( + project.id, + generation_round_key, + path=path, + ) + planned, skipped_count = _planned_generation_round_files(jobs, path) + if not planned: + raise ImageStudioExportError("本轮没有可导出的成功生成图片") + + target = _create_generation_round_target_dir(project, parent, jobs) + copied = [] + cancelled = False + try: + for item in planned: + if callable(should_stop) and should_stop(): + cancelled = True + break + output_path = os.path.join(target, item["filename"]) + try: + shutil.copy2(item["source_path"], output_path) + except OSError: + skipped_count += 1 + if os.path.isfile(output_path): + try: + os.remove(output_path) + except OSError: + pass + continue + copied.append( + GenerationRoundExportedFile( + job_id=int(item["job_id"]), + asset_id=int(item["asset_id"]), + job_type=str(item["job_type"]), + source_path=item["source_path"], + output_path=output_path, + ) + ) + except Exception as exc: + raise ImageStudioExportError("导出本轮生成图片失败") from exc + + if not copied: + try: + os.rmdir(target) + except OSError: + pass + if cancelled: + return GenerationRoundExportResult("", (), skipped_count, True) + raise ImageStudioExportError("本轮生成图片导出失败") + return GenerationRoundExportResult( + target_dir=target, + files=tuple(copied), + skipped_count=skipped_count, + cancelled=cancelled, + ) + + def _choose_target_dir(project, parent_dir, existing_mode, timestamp=None): target = target_dir_for_project(project, parent_dir) if not os.path.exists(target): @@ -157,6 +247,66 @@ def _planned_files(project, db_path): return planned +def _planned_generation_round_files(jobs, db_path): + planned = [] + skipped_count = 0 + for index, job in enumerate(jobs, 1): + if str(getattr(job, "status", "") or "") != "succeeded": + continue + asset_id = getattr(job, "output_asset_id", None) + asset = image_studio.get_asset(asset_id, path=db_path) if asset_id else None + source_path = str(getattr(asset, "local_path", "") or "") + if ( + asset is None + or str(getattr(asset, "status", "") or "") + == image_studio.ASSET_STATUS_MISSING + or not source_path + or not os.path.isfile(source_path) + ): + skipped_count += 1 + continue + suffix = os.path.splitext(source_path)[1].lower() + if not suffix or len(suffix) > 8: + suffix = ".jpg" + job_type = _safe_export_component( + getattr(job, "job_type", ""), + fallback="套图", + ) + planned.append( + { + "job_id": int(job.id), + "asset_id": int(asset.id), + "job_type": str(getattr(job, "job_type", "") or "套图"), + "source_path": os.path.abspath(source_path), + "filename": "%02d_%s%s" % (index, job_type, suffix), + } + ) + return planned, skipped_count + + +def _create_generation_round_target_dir(project, parent_dir, jobs): + account = _safe_export_component( + getattr(project, "account_name", "") or getattr(project, "account_alias", ""), + fallback="未命名店铺", + ) + item = _safe_export_component(getattr(project, "item_id", ""), fallback="临时草稿") + created_at = str(getattr(jobs[0], "created_at", "") or "") if jobs else "" + digits = "".join(character for character in created_at if character.isdigit())[:14] + stamp = digits or datetime.now().strftime("%Y%m%d_%H%M%S") + base = os.path.join(parent_dir, "%s_%s_%s" % (account, item, stamp)) + candidate = base + index = 2 + while True: + try: + os.makedirs(candidate, exist_ok=False) + return candidate + except FileExistsError: + candidate = "%s_%d" % (base, index) + index += 1 + except OSError as exc: + raise ImageStudioExportError("无法创建导出目录") from exc + + def _save_jpeg(source_path, output_path, quality): try: from PIL import Image @@ -208,3 +358,11 @@ def _safe_item_id(item_id): if not safe: raise ImageStudioExportError("商品ID不能作为目录名") return safe + + +def _safe_export_component(value, *, fallback): + text = str(value or "").strip() + safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in text).strip("_") + if safe: + return safe + return str(fallback) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 781f876..823fd0a 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -445,7 +445,7 @@ data/images///__new. # AI 生成的新 - 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。 - T-639 后每轮套图生成使用仅存在内存的 `run_token` 隔离迟到信号,progress/finished/cancelled/failed 通过主线程绑定槽统一处理;正常 worker 结果、`QThread.finished` 和本轮 job 连续两次全部终态看门狗共同进入幂等 finalize。GUI 只按本轮明确 `job_ids` 判断完成,不用历史图片数量;即使最终信号丢失也会恢复按钮,旧线程引用仍保留到真实结束。停止为协作式:调度循环约每200ms检查标记并取消未开始 future,提交/轮询在有界请求返回后停止;requests 在流式数据块边界取消,Windows curl 由隐藏窗口 `Popen` 有界 terminate/kill。已有 `task_id` 的停止任务保留 resume,不假设服务端任务被取消或点数退回。 - T-643 后 `image_studio_projects.current_generation_round_key` 是⑥主结果区的持久化当前轮次;每次常规「生成套图」创建独立 UUID `generation_round_key`,并为每条 `image_studio_jobs` 写入稳定的 `generation_slot_index`。内存 `run_token` 仍只用于线程和迟到信号隔离,不承担业务轮次语义。只有一轮中至少一条 job 成功时,才在同一 SQLite 事务内将该轮提升为当前轮;全失败或全取消保留原当前轮,部分成功则保留成功、失败槽位供后续重试。单张重试新建 job 但继承原轮次和槽位,当前展示按同轮同槽位最新 job、槽位顺序读取;所有尝试、错误、计费和输出资产均保留。旧 job 的轮次/槽位保持 NULL,查询层统一作为“旧版历史记录”,不以时间、文件名或数量猜测轮次;下一次成功的新格式整轮才建立当前轮。 -- T-644 后⑥主结果区始终只显示项目的当前轮次;「历史生成」是当前项目专属的非模态只读弹窗,不再把所有历史 job 混入主网格或维护内存 `show_history` 状态。弹窗按轮次创建时间倒序、每页最多20轮读取 SQLite 摘要,并按稳定槽位展示每槽最新尝试;当前轮标记“当前”,NULL 轮次标记“旧版历史记录”。历史窗口只允许预览、复制本地路径和打开所在文件夹,缺失文件显示占位但不删 DB;不提供删除、重试、设为当前轮或重新生成入口。关闭商品任务或关闭窗口时释放缩略图对象,生成 worker 不受历史窗口影响。 +- T-646 后⑥主结果区仍只显示项目的当前轮次;「历史生成」改为全局非模态只读窗口,默认跨所有未软删除商品项目按轮次创建时间倒序读取(同一时间以稳定 job ID 补序),首屏及每次翻页最多30轮。窗口只读取 SQLite 的轮次摘要、每槽最新有效 job 和受限尺寸缩略图,不扫描图片目录或一次加载原图;店铺/账号、商品 ID 与“仅当前商品”筛选都在查询层完成,当前商品快捷筛选默认关闭。正常 `generation_round_key` 一轮一行,单张重试只更新该槽位当前状态并计入重试数,不新增历史行;NULL 轮次继续作为“旧版历史记录”兼容,不猜测轮次边界。每行最多显示5张缩略图,余量显示 `+N`;文件缺失只显示中文占位,不删除 DB。双击缩略图从被点图片打开该轮所有可用图片的自适应原尺寸浏览;“导出本轮”在后台仅复制成功且本地存在的输出 asset 到用户选择目录下安全命名的新子目录,确定性追加序号避免覆盖,不移动/重命名/删除内部 asset。窗口不提供批量导出、删除、重试、设为当前轮或重新生成;关闭商品任务不关闭全局窗口,应用退出时协作停止导出并释放窗口资源,生成 worker 不受历史窗口影响。 提示词管理: diff --git a/docs/api.md b/docs/api.md index 62889cb..e650bf2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -368,6 +368,7 @@ render_prompt(template_text, task) -> str ```python # app/image_studio.py ImageStudioProject / ImageStudioAsset / ImageStudioJob / ImageStudioSelection +ImageStudioGenerationRound / ImageStudioHistoryRound 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 @@ -380,6 +381,11 @@ remove_original_assets_if_unused(project_id, asset_ids, path=None) -> list[Image 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] +list_generation_rounds(project_id, limit=None, offset=0, path=None) -> list[ImageStudioGenerationRound] +list_global_generation_rounds(account_query="", item_query="", project_id=None, + limit=None, offset=0, path=None) -> list[ImageStudioHistoryRound] +list_generation_round_current_jobs(project_id, generation_round_key, path=None) -> list[ImageStudioJob] +list_generation_round_attempts(project_id, generation_round_key, 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, should_stop=None) -> dict @@ -406,6 +412,8 @@ resume_image_jobs(project_id=None, aspect_ratio="1:1", ...) -> dict # app/image_studio_export.py export_project_selection(project_id, parent_dir, existing_mode="fail", path=None, config=None) -> ExportResult +export_generation_round(project_id, generation_round_key, parent_dir, path=None, + should_stop=None) -> GenerationRoundExportResult ``` 要点: @@ -413,7 +421,7 @@ export_project_selection(project_id, parent_dir, existing_mode="fail", path=None - 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool///` 下分 `originals/generated/exports`,删除生成图进入项目内 `.trash` 并可撤销。 - `image_studio_projects.suite_settings_json` 保存平台/国家/语言/比例/逐图主图/分类数量;`draft_prompt` 保存商品卖点。有效原图上限16张,missing 历史不占名额。 - T-643 后 `image_studio_projects.current_generation_round_key` 记录项目主界面的当前生成轮次;`image_studio_jobs.generation_round_key` 与 `generation_slot_index` 记录整轮归属和稳定展示槽位。`promote_generation_round_if_success()` 只会在同项目、同轮已存在成功 job 时原子切换当前轮;`list_generation_rounds()` 返回轮次摘要,`list_generation_round_current_jobs()` 返回每个槽位最新尝试,`list_generation_round_attempts()` 返回完整尝试历史。旧 job 保持 NULL 并按“旧版历史记录”兼容,不猜测轮次边界。 -- T-644 后 `ProductSuiteHistoryDialog(project_id, db_path=...)` 只组合上述三类查询,且仅访问传入项目 ID;默认读取最近20轮,可继续加载更早轮次。每轮卡片只展示该槽位最新 job,重试次数由完整尝试历史计算;成功但本地资产缺失时保留状态和占位。该对话框没有写数据库、删除文件、触发 worker 或修改当前轮次的接口,右键操作仅预览、复制本地路径和打开所在文件夹。 +- T-646 后 `list_global_generation_rounds()` 是⑥跨项目历史的唯一查询入口:仅返回未软删除项目的轮次摘要,按创建时间倒序并支持账号/店铺、商品 ID、项目 ID、`limit`/`offset` 筛选;正常轮次只统计每个稳定槽位的最新有效 job,完整尝试数仅用于计算重试数,NULL 轮次保留“旧版历史记录”语义。`ProductSuiteGlobalHistoryDialog` 组合该查询和 `list_generation_round_current_jobs()`,默认读取30轮,显示受限缩略图并可按轮预览;窗口不写业务数据,也不改变当前轮次。`ProductSuiteHistoryExportWorker` 在后台调用 `export_generation_round()`,只复制该轮成功且存在的输出 asset 到用户选择目录中新建的安全子目录;同名目录追加稳定序号,不覆盖外部文件,部分文件失败只汇总中文结果,源 asset 保持不变。 - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 - `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 @@ -448,6 +456,7 @@ class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG class ProductSuiteImportImagesWorker(BaseWorker) # ⑥ 后台校验并复制本地/剪贴板商品原图 class ProductSuiteGenerateWorker(BaseWorker) # ⑥ 按套图job规划提交/查询/下载 class ProductSuiteAiWriteWorker(BaseWorker) # ⑥ 后台用本地原图调用图片理解,生成商品卖点与画面要求 +class ProductSuiteHistoryExportWorker(BaseWorker) # ⑥ 后台复制一轮历史成功生成图 class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过” class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑 class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单 diff --git a/docs/routes.md b/docs/routes.md index cd50ccc..48bed9e 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -206,7 +206,7 @@ - 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 - 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。T-643 后项目持久化当前生成轮次:常规新轮至少成功一张才替换主结果区,全部失败/取消保留上一当前轮;主结果按稳定槽位显示同轮最新 job,单张重试留在原槽位。旧版无轮次 job 临时显示为“旧版历史记录”,不按时间或图片数量猜测归属。成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 -- T-644 后「历史生成」打开当前店铺、当前商品专属的非模态「历史生成记录」窗口,主结果区不切换。窗口按生成轮次倒序分组,显示时间、成功/失败/停止数量和“当前”标记;当前槽位有重试时只展示最新图片,并在提示中说明重试次数。默认加载最近20轮,底部可加载更多;旧版记录、全失败轮和本地文件缺失项都保留中文说明。窗口只读:单击选中、双击自适应预览、右键预览/复制路径/打开所在文件夹;不提供删除、重试或切换当前轮操作。重复点击同一商品复用窗口;任务关闭时窗口一起关闭。 +- T-646 后「历史生成」打开全局非模态「套图历史生成记录」窗口,默认显示所有未删除商品项目最近创建的生成轮次,主结果区不切换。窗口支持店铺/账号、商品 ID 关键字和“仅当前商品”快捷筛选,但默认不限制当前任务;一行就是一次正常生成轮次,单张失败重试仍归入原行。行内固定显示时间、店铺/账号、商品 ID、成功/失败/停止/重试统计、最多5张缩略图及余量 `+N`,当前轮标记“当前”,NULL 轮次标记“旧版历史记录”,临时项目显示“临时草稿”。双击缩略图或整行从对应图片打开该轮所有可用图的自适应原尺寸浏览;“导出本轮”后台复制该轮成功且本地存在的图片到用户选择目录下的新安全子目录,不覆盖或修改内部图片。旧版记录、全失败轮和本地文件缺失项保留中文说明;不提供批量导出、删除、重试、切换当前轮或再次生成。重复点击复用同一窗口;关闭任务页不关闭全局窗口,应用退出时正常释放。 - AI帮写和生图按任务独立运行。AI帮写只使用⑤设置的「图片理解别名」调用图片理解能力,不走②标题生成;按商品原图 `source_order` 取1至8张已下载的本地图片,超过8张时状态提示只使用前8张。单图超过10MiB、总计超过32MiB、没有可用本地图、别名未配置或服务异常时不改现有卖点;图片理解读超时或网络中断提示“结果未确认,请先查看点数余额或稍后重试”,不自动重发。成功状态显示理解图片张数、扣点和余额;AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏图片路径、URL、接口路径、base64、完整提示词和敏感信息。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 diff --git a/docs/tasks/T-646.md b/docs/tasks/T-646.md index 3812b09..8dc72a7 100644 --- a/docs/tasks/T-646.md +++ b/docs/tasks/T-646.md @@ -3,7 +3,7 @@ id: T-646 title: 商品套图全局历史生成列表与按轮导出 phase: 7 deps: [T-643, T-644] -status: TODO +status: DONE created: 2026-07-17 --- @@ -119,3 +119,7 @@ git diff --check ## 执行记录 - 2026-07-17:根据产品讨论创建任务。待实现。 +- 2026-07-17:新增 `list_global_generation_rounds()` 跨项目轮次查询,排除软删除项目,支持店铺/账号、商品 ID、当前项目与分页筛选;正常轮次按稳定槽位只统计最新有效 job,重试只计入重试数,旧版 NULL 轮次保持兼容语义。 +- 2026-07-17:⑥「历史生成」改为可复用的全局非模态窗口,支持筛选、每轮最多5张缩略图和余量提示、从指定图片开始的轮次原尺寸浏览;关闭任务页不会关闭该窗口。 +- 2026-07-17:新增后台按轮导出,只复制成功且本地存在的图片到安全命名的新目录,保留源 asset,目录冲突自动追加序号,并在 GUI 汇总中文结果。 +- 验证通过:`py -3.10 -m unittest discover -s tests`(568 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_image_studio.py b/tests/test_image_studio.py index 20b657c..9bd3db6 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -1028,6 +1028,160 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_global_generation_rounds_filter_paginate_and_exclude_deleted_projects(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + first_project = image_studio.create_or_get_project( + account_alias="main-shop", + account_name="主店", + account_slug="main-shop", + item_id="51100639510", + path=db_path, + ) + second_project = image_studio.create_or_get_project( + account_alias="second-shop", + account_name="副店", + account_slug="second-shop", + item_id="51100639511", + path=db_path, + ) + deleted_project = image_studio.create_or_get_project( + account_alias="deleted-shop", + account_slug="deleted-shop", + item_id="51100639512", + path=db_path, + ) + first_source = image_studio.add_asset( + first_project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=db_path, + ) + second_source = image_studio.add_asset( + second_project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=db_path, + ) + deleted_source = image_studio.add_asset( + deleted_project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=db_path, + ) + first = image_studio.create_job( + first_project.id, + source_asset_id=first_source.id, + task_key="global-first-slot", + generation_round_key="first-round", + generation_slot_index=0, + path=db_path, + ) + image_studio.update_job_status(first.id, "failed", path=db_path) + retry = image_studio.create_job( + first_project.id, + source_asset_id=first_source.id, + task_key="global-first-retry", + generation_round_key="first-round", + generation_slot_index=0, + path=db_path, + ) + image_studio.update_job_status(retry.id, "succeeded", path=db_path) + second = image_studio.create_job( + second_project.id, + source_asset_id=second_source.id, + task_key="global-second-round", + generation_round_key="second-round", + generation_slot_index=0, + path=db_path, + ) + image_studio.update_job_status(second.id, "succeeded", path=db_path) + deleted = image_studio.create_job( + deleted_project.id, + source_asset_id=deleted_source.id, + task_key="global-deleted-round", + generation_round_key="deleted-round", + generation_slot_index=0, + path=db_path, + ) + image_studio.update_job_status(deleted.id, "succeeded", path=db_path) + legacy = image_studio.create_job( + first_project.id, + source_asset_id=first_source.id, + task_key="global-legacy-history", + path=db_path, + ) + image_studio.update_job_status(legacy.id, "cancelled", path=db_path) + image_studio.set_current_generation_round( + first_project.id, + "first-round", + path=db_path, + ) + image_studio.soft_delete_project( + deleted_project.id, + reason="测试软删除", + path=db_path, + ) + + rounds = image_studio.list_global_generation_rounds(path=db_path) + self.assertEqual( + {first_project.id, second_project.id}, + {round_.project_id for round_ in rounds}, + ) + self.assertIsNone(rounds[0].generation_round_key) + self.assertTrue(rounds[0].is_legacy) + self.assertEqual(first_project.id, rounds[0].project_id) + self.assertEqual(second_project.id, rounds[1].project_id) + first_round = next( + round_ + for round_ in rounds + if ( + round_.project_id == first_project.id + and round_.generation_round_key == "first-round" + ) + ) + self.assertEqual("first-round", first_round.generation_round_key) + self.assertEqual("主店", first_round.account_name) + self.assertEqual(1, first_round.job_count) + self.assertEqual(1, first_round.slot_count) + self.assertEqual(1, first_round.retry_count) + self.assertEqual(1, first_round.succeeded_count) + self.assertEqual(0, first_round.failed_count) + self.assertTrue(first_round.is_current) + + self.assertEqual( + [first_project.id, first_project.id], + [ + round_.project_id + for round_ in image_studio.list_global_generation_rounds( + account_query="主店", + path=db_path, + ) + ], + ) + self.assertEqual( + [second_project.id], + [ + round_.project_id + for round_ in image_studio.list_global_generation_rounds( + item_query="51100639511", + path=db_path, + ) + ], + ) + self.assertEqual( + [first_project.id, first_project.id], + [ + round_.project_id + for round_ in image_studio.list_global_generation_rounds( + project_id=first_project.id, + path=db_path, + ) + ], + ) + self.assertEqual(1, len(image_studio.list_global_generation_rounds(limit=1, path=db_path))) + self.assertEqual(1, len(image_studio.list_global_generation_rounds(limit=1, offset=1, path=db_path))) + + self.assert_removed(temp_dir) + def test_selections_are_consecutive_unique_and_replaceable(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_image_studio_export.py b/tests/test_image_studio_export.py index a42e3b3..90030ad 100644 --- a/tests/test_image_studio_export.py +++ b/tests/test_image_studio_export.py @@ -53,6 +53,122 @@ class ImageStudioExportTests(TempDirMixin, unittest.TestCase): image_studio.replace_selections(project.id, "detail", [detail.id], path=cfg["db_path"]) return cfg, project, main, detail + def test_export_generation_round_copies_successes_and_uses_new_directory_on_repeat(self): + with self.make_temp_dir() as temp_dir: + cfg, project, _main, _detail = self._project_with_assets(temp_dir) + source = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=cfg["db_path"], + ) + first_source = self._write_image(os.path.join(temp_dir, "round-first.png")) + first_asset = image_studio.add_asset( + project.id, + "generated_main", + local_path=first_source, + parent_asset_id=source.id, + path=cfg["db_path"], + ) + first_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="白底图", + task_key="round-export-first", + generation_round_key="round-export", + generation_slot_index=0, + path=cfg["db_path"], + ) + image_studio.update_job_status( + first_job.id, + "succeeded", + output_asset_id=first_asset.id, + path=cfg["db_path"], + ) + missing_asset = image_studio.add_asset( + project.id, + "generated_main", + local_path=os.path.join(temp_dir, "missing-round.png"), + parent_asset_id=source.id, + path=cfg["db_path"], + ) + missing_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="场景图", + task_key="round-export-missing", + generation_round_key="round-export", + generation_slot_index=1, + path=cfg["db_path"], + ) + image_studio.update_job_status( + missing_job.id, + "succeeded", + output_asset_id=missing_asset.id, + path=cfg["db_path"], + ) + second_source = self._write_image( + os.path.join(temp_dir, "round-second.png"), + color=(220, 80, 40, 255), + ) + second_asset = image_studio.add_asset( + project.id, + "generated_main", + local_path=second_source, + parent_asset_id=source.id, + path=cfg["db_path"], + ) + second_job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type="卖点图", + task_key="round-export-second", + generation_round_key="round-export", + generation_slot_index=2, + path=cfg["db_path"], + ) + image_studio.update_job_status( + second_job.id, + "succeeded", + output_asset_id=second_asset.id, + path=cfg["db_path"], + ) + parent = os.path.join(temp_dir, "exports") + os.makedirs(parent) + + result = image_studio_export.export_generation_round( + project.id, + "round-export", + parent, + path=cfg["db_path"], + ) + + self.assertEqual(2, len(result.files)) + self.assertEqual(1, result.skipped_count) + self.assertFalse(result.cancelled) + self.assertTrue(os.path.isdir(result.target_dir)) + self.assertEqual( + ["01_白底图.png", "03_卖点图.png"], + sorted(os.path.basename(item.output_path) for item in result.files), + ) + self.assertTrue(os.path.isfile(first_source)) + self.assertTrue(os.path.isfile(second_source)) + with open(first_source, "rb") as source_file, open( + result.files[0].output_path, + "rb", + ) as exported_file: + self.assertEqual(source_file.read(), exported_file.read()) + + second_result = image_studio_export.export_generation_round( + project.id, + "round-export", + parent, + path=cfg["db_path"], + ) + self.assertNotEqual(result.target_dir, second_result.target_dir) + self.assertTrue(second_result.target_dir.endswith("_2")) + + self.assert_removed(temp_dir) + def test_export_partial_selection_outputs_ordered_jpegs(self): with self.make_temp_dir() as temp_dir: cfg, project, _main, _detail = self._project_with_assets(temp_dir) diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 0bc2fc6..3ce93cb 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -25,9 +25,13 @@ from app.gui.tabs.product_suite import ( ORIGINAL_CHECK_STATE_ROLE, ProductOriginalDelegate, ProductOriginalList, + ProductSuiteGlobalHistoryDialog, ProductSuiteHistoryDialog, ProductSuitePreviewDialog, + ProductSuiteRoundPreviewDialog, ProductSuiteTab, + SuiteGlobalHistoryRoundRow, + SuiteGlobalHistoryThumbnail, SuiteHistoryImageCard, SuiteResultCard, ) @@ -1976,7 +1980,103 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_history_button_reuses_project_dialog_and_closes_with_task(self): + def test_global_history_dialog_lists_filters_and_previews_round_images(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, sources = self._create_project_with_assets(temp_dir, config, 1) + source = sources[0] + other_project = image_studio.create_or_get_project( + account_alias="other-shop", + account_name="副店", + account_slug="other-shop", + item_id="51100639511", + path=config["db_path"], + ) + other_source = image_studio.add_asset( + other_project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=config["db_path"], + ) + for index in range(7): + image_path = os.path.join(temp_dir, "global-%d.png" % index) + self._write_image(image_path) + self._create_history_job( + project, + source, + config["db_path"], + round_key="main-round", + slot_index=index, + local_path=image_path, + job_type="场景图", + ) + other_path = os.path.join(temp_dir, "other-global.png") + self._write_image(other_path) + self._create_history_job( + other_project, + other_source, + config["db_path"], + round_key="other-round", + slot_index=0, + local_path=other_path, + job_type="卖点图", + ) + image_studio.set_current_generation_round( + project.id, + "main-round", + path=config["db_path"], + ) + + dialog = ProductSuiteGlobalHistoryDialog( + current_project_id=project.id, + db_path=config["db_path"], + ) + dialog.show() + self.app.processEvents() + + self.assertEqual(2, dialog._round_count) + rows = dialog.findChildren(SuiteGlobalHistoryRoundRow) + main_row = next( + row for row in rows if row.round_info.project_id == project.id + ) + self.assertEqual(5, len(main_row.findChildren(SuiteGlobalHistoryThumbnail))) + self.assertTrue( + any( + label.text() == "+2" + for label in main_row.findChildren(QLabel) + ) + ) + self.assertTrue( + any(label.text() == "当前" for label in main_row.findChildren(QLabel)) + ) + with mock.patch.object( + ProductSuiteRoundPreviewDialog, + "exec", + return_value=0, + ) as preview: + dialog._preview_round(main_row, 1) + preview.assert_called_once_with() + + dialog.account_filter_edit.setText("副店") + dialog.refresh_history() + self.app.processEvents() + self.assertEqual(1, dialog._round_count) + self.assertEqual( + [other_project.id], + [row.round_info.project_id for row in dialog._history_rows], + ) + + dialog.account_filter_edit.clear() + dialog.current_project_checkbox.setChecked(True) + self.app.processEvents() + self.assertEqual(1, dialog._round_count) + self.assertEqual( + [project.id], + [row.round_info.project_id for row in dialog._history_rows], + ) + + self.assert_removed(temp_dir) + + def test_history_button_reuses_global_dialog_and_keeps_it_when_task_closes(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) project, sources = self._create_project_with_assets(temp_dir, config, 1) @@ -2014,18 +2114,20 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): tab.open_history_dialog() self.app.processEvents() dialog = tab._history_dialog - self.assertIsInstance(dialog, ProductSuiteHistoryDialog) + self.assertIsInstance(dialog, ProductSuiteGlobalHistoryDialog) + self.assertEqual(project.id, dialog.current_project_id) self.assertTrue(dialog.isVisible()) tab.open_history_dialog() self.assertIs(dialog, tab._history_dialog) tab.close_task(0) self.app.processEvents() - self.assertIsNone(tab._history_dialog) + self.assertIs(dialog, tab._history_dialog) + self.assertTrue(dialog.isVisible()) self.assert_removed(temp_dir) - def test_history_button_explains_empty_project_without_opening_dialog(self): + def test_history_button_opens_global_dialog_for_empty_current_project(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir) project, _ = self._create_project_with_assets(temp_dir, config, 0) @@ -2036,13 +2138,18 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): state.item_id = project.item_id state.project_id = project.id state.project_binding_state = project.binding_state - messages = [] - tab._message = lambda title, message, **kwargs: messages.append((title, message)) - tab.open_history_dialog() + self.app.processEvents() - self.assertEqual("暂无历史生成记录", messages[-1][0]) - self.assertIsNone(tab._history_dialog) + dialog = tab._history_dialog + self.assertIsInstance(dialog, ProductSuiteGlobalHistoryDialog) + self.assertTrue(dialog.isVisible()) + self.assertTrue( + any( + label.text() == "暂无套图历史生成记录,完成套图生成后会自动出现在这里" + for label in dialog.findChildren(QLabel) + ) + ) self.assert_removed(temp_dir) diff --git a/tests/test_workers.py b/tests/test_workers.py index d46da78..e0d8aba 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -22,6 +22,7 @@ from app.gui.workers import ( ImageStudioPullImagesWorker, ProductSuiteAiWriteWorker, ProductSuiteGenerateWorker, + ProductSuiteHistoryExportWorker, ) @@ -229,6 +230,43 @@ class WorkerTests(unittest.TestCase): gen_title.assert_not_called() self.assertEqual(expected, result) + def test_product_suite_history_export_worker_uses_round_export_service(self): + worker = ProductSuiteHistoryExportWorker( + 7, + "round-key", + "D:/exports", + db_path="suite.db", + ) + expected = SimpleNamespace( + target_dir="D:/exports/main-shop_51100639510_20260717", + files=(object(), object()), + skipped_count=1, + cancelled=False, + ) + + with mock.patch( + "app.gui.workers.image_studio_export.export_generation_round", + return_value=expected, + ) as export_round: + result = worker.execute() + + export_round.assert_called_once_with( + 7, + "round-key", + "D:/exports", + path="suite.db", + should_stop=worker.should_cancel, + ) + self.assertEqual( + { + "target_dir": expected.target_dir, + "file_count": 2, + "skipped_count": 1, + "cancelled": False, + }, + result, + ) + def test_product_suite_worker_writes_generation_round_and_stable_slots(self): with tempfile.TemporaryDirectory() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db")