From cd150ef6c7d75038773ed58f3cf4c837e66018f1 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 16 Jul 2026 23:47:47 +0800 Subject: [PATCH] feat(product-suite): add grouped generation history --- app/gui/tabs/product_suite.py | 546 ++++++++++++++++++++++++++++++-- app/image_studio.py | 1 + docs/04-architecture.md | 1 + docs/api.md | 1 + docs/routes.md | 1 + docs/tasks/T-644.md | 13 +- tests/test_image_studio.py | 36 +++ tests/test_product_suite_gui.py | 315 +++++++++++++++++- 8 files changed, 879 insertions(+), 35 deletions(-) diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index 937db99..f6bdac5 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -24,6 +24,7 @@ from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, + QDialog, QFileDialog, QFrame, QGridLayout, @@ -684,6 +685,452 @@ class SuiteResultCard(QFrame): layout.addLayout(footer) +class SuiteHistoryImageCard(QFrame): + """Read-only image card used by the product-suite history dialog.""" + + selected = Signal(object) + previewRequested = Signal(object, object) + menuRequested = Signal(object, object, object) + + def __init__(self, job, asset=None, retry_count=0, parent=None): + super().__init__(parent) + self.job = job + self.asset = asset + self.retry_count = max(0, int(retry_count or 0)) + self._selected = False + self.setObjectName("suiteHistoryImageCard") + self.setFixedSize(166, 190) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect( + lambda point: self.menuRequested.emit( + self.job, + self.asset, + self.mapToGlobal(point), + ) + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + + title = QLabel(str(getattr(job, "job_type", "套图") or "套图")) + title.setStyleSheet("font-weight: 600; color: #24292f;") + title.setAttribute(Qt.WA_TransparentForMouseEvents, True) + layout.addWidget(title) + + image = QLabel() + image.setObjectName("suiteHistoryImage") + image.setAlignment(Qt.AlignCenter) + image.setFixedSize(154, 118) + image.setAttribute(Qt.WA_TransparentForMouseEvents, True) + status = str(getattr(job, "status", "pending") or "pending") + if asset is not None and _asset_usable(asset): + image.setPixmap(_image_pixmap(asset.local_path, QSize(154, 118))) + else: + image.setPixmap( + _placeholder_pixmap( + "图片文件不可用" if status == "succeeded" else self._status_text(status), + QSize(154, 118), + "#fff8c5" if status == "succeeded" else "#f3f4f6", + ) + ) + layout.addWidget(image) + + footer_text = self._status_text(status) + if self.retry_count: + footer_text += " · 重试%d次" % self.retry_count + footer = QLabel(footer_text) + footer.setObjectName("suiteHistoryImageStatus") + footer.setWordWrap(True) + footer.setMaximumHeight(34) + footer.setAttribute(Qt.WA_TransparentForMouseEvents, True) + layout.addWidget(footer) + + tooltip = [ + "类型:%s" % str(getattr(job, "job_type", "套图") or "套图"), + "状态:%s" % self._status_text(status), + "生成时间:%s" % _history_time_text(getattr(job, "created_at", "")), + ] + if self.retry_count: + tooltip.append("本槽位已重试%d次" % self.retry_count) + if asset is None or not _asset_usable(asset): + tooltip.append("本地图片文件不可用") + self.setToolTip("\n".join(tooltip)) + self._apply_selected_style() + + @staticmethod + def _status_text(status): + return { + "pending": "等待提交", + "submitted": "已提交", + "running": "生成中", + "succeeded": "生成成功", + "failed": "生成失败", + "expired": "任务过期", + "cancelled": "已停止", + }.get(str(status or ""), "处理中") + + def set_selected(self, selected): + self._selected = bool(selected) + self._apply_selected_style() + + def _apply_selected_style(self): + border = "#0969da" if self._selected else "#d8dee4" + background = "#eef6ff" if self._selected else "#ffffff" + self.setStyleSheet( + "QFrame#suiteHistoryImageCard {" + "border: %dpx solid %s; border-radius: 6px; background: %s;" + "}" % (2 if self._selected else 1, border, background) + ) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.selected.emit(self) + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.LeftButton: + self.previewRequested.emit(self.job, self.asset) + super().mouseDoubleClickEvent(event) + + +def _history_time_text(value): + text = str(value or "").strip().replace("T", " ") + if not text: + return "时间未知" + return text[:19] + + +class ProductSuiteHistoryDialog(QDialog): + """Read-only, project-scoped generation history grouped by persisted rounds.""" + + PAGE_SIZE = 20 + + def __init__(self, project_id, *, db_path=None, parent=None): + super().__init__(parent) + self.project_id = int(project_id) + self.db_path = db_path + self._offset = 0 + self._has_more = False + self._assets = {} + self._selected_card = None + self._round_count = 0 + self._available_image_count = 0 + self._project_unavailable = False + + self.setObjectName("suiteHistoryDialog") + self.setWindowTitle("历史生成记录") + self.setModal(False) + self.setAttribute(Qt.WA_DeleteOnClose, True) + self.setMinimumSize(740, 520) + self.resize(940, 680) + + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(10) + + header = QHBoxLayout() + header.setSpacing(10) + header_text = QVBoxLayout() + header_text.setSpacing(2) + self.context_label = QLabel() + self.context_label.setObjectName("suiteHistoryContextLabel") + self.context_label.setStyleSheet("font-weight: 600; color: #24292f;") + self.summary_label = QLabel() + self.summary_label.setObjectName("suiteHistorySummaryLabel") + self.summary_label.setStyleSheet("color: #57606a;") + header_text.addWidget(self.context_label) + header_text.addWidget(self.summary_label) + header.addLayout(header_text, 1) + self.refresh_button = QPushButton("刷新") + self.refresh_button.setObjectName("suiteHistoryRefreshButton") + self.refresh_button.setToolTip("重新读取当前商品的生成历史") + self.refresh_button.clicked.connect(self.refresh_history) + header.addWidget(self.refresh_button) + layout.addLayout(header) + + self.notice_label = QLabel() + self.notice_label.setObjectName("suiteHistoryNoticeLabel") + self.notice_label.setWordWrap(True) + self.notice_label.hide() + layout.addWidget(self.notice_label) + + self.scroll = QScrollArea() + self.scroll.setObjectName("suiteHistoryScrollArea") + self.scroll.setWidgetResizable(True) + self.history_content = QWidget() + self.history_content.setObjectName("suiteHistoryContent") + self.history_layout = QVBoxLayout(self.history_content) + self.history_layout.setContentsMargins(0, 0, 0, 0) + self.history_layout.setSpacing(12) + 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("suiteHistoryLoadMoreButton") + self.load_more_button.clicked.connect(self.load_more) + self.load_more_button.hide() + layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter) + + 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._assets = {} + self._round_count = 0 + self._available_image_count = 0 + 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 = image_studio.get_project(self.project_id, path=self.db_path) + if project is None: + self._set_project_unavailable() + return False + self._project_unavailable = False + self._set_context(project) + if not self._assets: + self._assets = { + int(asset.id): asset + for asset in image_studio.list_assets( + self.project_id, + path=self.db_path, + ) + } + rounds = image_studio.list_generation_rounds( + self.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 _set_context(self, project): + account_name = str(project.account_name or project.account_alias or "未命名账号") + item_text = "临时草稿" if image_studio.is_draft_project(project) else "商品ID:%s" % project.item_id + self.context_label.setText("店铺:%s · %s" % (account_name, item_text)) + + 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._selected_card = None + while self.history_layout.count(): + item = self.history_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def _set_project_unavailable(self): + self._project_unavailable = True + self._clear_history_content() + self.context_label.setText("当前商品项目不可用") + self.summary_label.setText("该商品项目已删除或不可访问") + self._set_notice("当前商品项目已删除或不可访问,无法继续读取历史生成记录。", "#cf222e") + self.refresh_button.setEnabled(False) + self.load_more_button.hide() + + 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("suiteHistoryEmptyLabel") + empty.setAlignment(Qt.AlignCenter) + empty.setStyleSheet("color: #6b7280; padding: 56px;") + self.history_layout.addWidget(empty) + + def _add_round(self, round_info): + try: + jobs = image_studio.list_generation_round_current_jobs( + self.project_id, + round_info.generation_round_key, + path=self.db_path, + ) + attempts = image_studio.list_generation_round_attempts( + self.project_id, + round_info.generation_round_key, + path=self.db_path, + ) + except Exception as exc: + self._set_error("历史轮次读取失败:%s" % _user_error(exc)) + return + + section = QWidget() + section.setObjectName("suiteHistoryRound") + section.setStyleSheet( + "QWidget#suiteHistoryRound { border-bottom: 1px solid #d8dee4; }" + ) + layout = QVBoxLayout(section) + layout.setContentsMargins(2, 2, 2, 12) + layout.setSpacing(7) + + header = QHBoxLayout() + header.setSpacing(8) + time_label = QLabel( + "旧版历史记录" if round_info.is_legacy else "生成于 %s" % _history_time_text(round_info.created_at) + ) + time_label.setStyleSheet("font-weight: 600; color: #24292f;") + header.addWidget(time_label) + if round_info.is_current: + current = QLabel("当前") + current.setObjectName("suiteHistoryCurrentBadge") + current.setStyleSheet( + "color: #0969da; background: #ddf4ff; border: 1px solid #54aeff; " + "border-radius: 6px; padding: 1px 6px; font-weight: 600;" + ) + header.addWidget(current) + elif round_info.is_legacy: + legacy = QLabel("旧版") + legacy.setStyleSheet( + "color: #57606a; background: #f6f8fa; border: 1px solid #d8dee4; " + "border-radius: 6px; padding: 1px 6px;" + ) + header.addWidget(legacy) + header.addStretch(1) + stats = self._round_stats_text(round_info) + stats_label = QLabel(stats) + stats_label.setObjectName("suiteHistoryRoundStats") + stats_label.setStyleSheet("color: #57606a;") + header.addWidget(stats_label) + layout.addLayout(header) + + attempts_by_slot = {} + for attempt in attempts: + slot = getattr(attempt, "generation_slot_index", None) + if slot is not None: + attempts_by_slot[int(slot)] = attempts_by_slot.get(int(slot), 0) + 1 + + if not jobs: + message = QLabel("本轮没有可展示的图片,已保留生成状态记录。") + message.setStyleSheet("color: #6b7280; padding: 12px 0;") + layout.addWidget(message) + else: + grid = QGridLayout() + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(8) + columns = 5 + for index, job in enumerate(jobs): + asset = self._assets.get(int(job.output_asset_id or 0)) + slot = getattr(job, "generation_slot_index", None) + retry_count = max(0, attempts_by_slot.get(int(slot), 1) - 1) if slot is not None else 0 + card = SuiteHistoryImageCard(job, asset, retry_count) + card.selected.connect(self._select_card) + card.previewRequested.connect(self._preview_job) + card.menuRequested.connect(self._show_job_menu) + grid.addWidget(card, index // columns, index % columns) + if asset is not None and _asset_usable(asset): + self._available_image_count += 1 + layout.addLayout(grid) + + self.history_layout.addWidget(section) + self._round_count += 1 + + @staticmethod + def _round_stats_text(round_info): + parts = ["记录 %d" % int(round_info.job_count or 0)] + if round_info.slot_count: + parts.append("图片 %d" % int(round_info.slot_count)) + parts.append("成功 %d" % int(round_info.succeeded_count or 0)) + if round_info.failed_count: + parts.append("失败 %d" % int(round_info.failed_count)) + if round_info.cancelled_count: + parts.append("停止 %d" % int(round_info.cancelled_count)) + if round_info.active_count: + parts.append("进行中 %d" % int(round_info.active_count)) + if round_info.retry_count: + parts.append("重试 %d" % int(round_info.retry_count)) + return " · ".join(parts) + + def _select_card(self, card): + if self._selected_card is card: + return + if self._selected_card is not None: + self._selected_card.set_selected(False) + self._selected_card = card + self._selected_card.set_selected(True) + + def _preview_job(self, job, asset): + if asset is None or not _asset_usable(asset): + self._set_notice("这张图片的本地文件不可用,无法预览。", "#9a6700") + return + ProductSuitePreviewDialog( + asset.local_path, + "%s预览" % str(getattr(job, "job_type", "套图") or "套图"), + self, + ).exec() + + def _show_job_menu(self, job, asset, global_position): + menu = QMenu(self) + preview_action = menu.addAction("预览") + copy_action = menu.addAction("复制路径") + folder_action = menu.addAction("打开所在文件夹") + action = menu.exec(global_position) + if action is preview_action: + self._preview_job(job, asset) + elif action is copy_action: + if asset is None or not _asset_usable(asset): + self._set_notice("当前图片没有可复制的本地路径。", "#9a6700") + return + QApplication.clipboard().setText(asset.local_path) + self._set_notice("图片路径已复制。", "#1a7f37") + elif action is folder_action: + if asset is None or not _asset_usable(asset): + self._set_notice("当前图片没有可打开的本地文件夹。", "#9a6700") + return + try: + file_manager.open_in_file_manager(os.path.dirname(asset.local_path)) + except Exception as exc: + self._set_notice("打开文件夹失败:%s" % _user_error(exc), "#cf222e") + + def closeEvent(self, event): + self._clear_history_content() + super().closeEvent(event) + + @dataclass class SuiteTaskState: key: int @@ -697,7 +1144,6 @@ class SuiteTaskState: settings: dict = field(default_factory=product_suite.default_suite_settings) current_job_ids: list = field(default_factory=list) current_generation_round_key: str = "" - show_history: bool = False generation_job_ids: list = field(default_factory=list) generation_mode: str = "batch" generation_retry_job_id: int = None @@ -777,6 +1223,7 @@ class ProductSuiteTab(QWidget): self._loading = False self._result_refresh_pending = False self._prompt_template_init_error = "" + self._history_dialog = None try: prompts.ensure_default_product_suite_prompt(self.product_suite_prompt_path) @@ -869,7 +1316,7 @@ class ProductSuiteTab(QWidget): layout.setSpacing(8) self.history_button = QPushButton("历史生成") self.history_button.setObjectName("suiteHistoryButton") - self.history_button.setCheckable(True) + self.history_button.setToolTip("查看当前商品的历史生成记录") layout.addWidget(self.history_button) self.open_folder_button = QPushButton("打开结果文件夹") self.open_folder_button.setObjectName("suiteOpenFolderButton") @@ -1222,7 +1669,7 @@ class ProductSuiteTab(QWidget): 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.history_button.clicked.connect(self.open_history_dialog) 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) @@ -1407,6 +1854,8 @@ 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) @@ -1474,7 +1923,6 @@ class ProductSuiteTab(QWidget): 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) @@ -3162,7 +3610,6 @@ class ProductSuiteTab(QWidget): state.started_at = time.monotonic() if not retrying: state.current_job_ids = [] - state.show_history = False self._generation_run_states[run_token] = state.key worker.progress.connect(self._on_generation_progress_signal) worker.finished.connect(self._on_generation_finished_signal) @@ -3179,12 +3626,6 @@ class ProductSuiteTab(QWidget): "mode": state.generation_mode, }, ) - if state is self._displayed_state and not retrying: - self._loading = True - try: - self.history_button.setChecked(False) - finally: - self._loading = False if state is self._displayed_state: self._apply_running_state(state) self._refresh_results(state) @@ -3426,7 +3867,7 @@ class ProductSuiteTab(QWidget): if retry_job_id in current: index = current.index(retry_job_id) current[index:index + 1] = normalized - elif not state.show_history: + else: for job_id in normalized: if job_id not in current: current.append(job_id) @@ -3794,13 +4235,6 @@ class ProductSuiteTab(QWidget): 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 [] @@ -3809,8 +4243,6 @@ class ProductSuiteTab(QWidget): except Exception as exc: self._status("生成结果读取失败:%s" % _user_error(exc), "danger") return [] - if state.show_history: - return jobs by_id = {int(job.id): job for job in jobs} return [ by_id[int(job_id)] @@ -3854,7 +4286,76 @@ class ProductSuiteTab(QWidget): 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 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 + + 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() + except RuntimeError: + pass + self._history_dialog = None + + dialog = ProductSuiteHistoryDialog( + project.id, + db_path=self.db_path, + parent=self, + ) + dialog.destroyed.connect( + lambda _object=None, current=dialog: self._clear_history_dialog(current) + ) + self._history_dialog = dialog + dialog.show() + dialog.raise_() + dialog.activateWindow() + + def _clear_history_dialog(self, dialog): + if self._history_dialog is dialog: + self._history_dialog = None + + def _close_history_dialog_for_project(self, project_id=None): + 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 + except RuntimeError: + self._history_dialog = None 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 @@ -4017,6 +4518,7 @@ class ProductSuiteTab(QWidget): self._refresh_results(self._displayed_state) def closeEvent(self, event): + self._close_history_dialog_for_project() if self._displayed_state is not None: self._save_controls_to_state(self._displayed_state) for state in list(self._states.values()): diff --git a/app/image_studio.py b/app/image_studio.py index 25f334c..043d0a5 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -1259,6 +1259,7 @@ def list_generation_rounds(project_id, *, limit=None, offset=0, path=None, conn= WHERE project_id = ? GROUP BY generation_round_key ORDER BY CASE WHEN generation_round_key IS NULL THEN 1 ELSE 0 END, + MIN(created_at) DESC, MAX(id) DESC """ params = [project_id] diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 5f53a24..cc70915 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -443,6 +443,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 不受历史窗口影响。 提示词管理: diff --git a/docs/api.md b/docs/api.md index 8ad0c07..0aaeb0b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -410,6 +410,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 或修改当前轮次的接口,右键操作仅预览、复制本地路径和打开所在文件夹。 - 拉取蝦皮原主图只读:复用 `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`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 diff --git a/docs/routes.md b/docs/routes.md index 61b9ae3..2bf8327 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -206,6 +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轮,底部可加载更多;旧版记录、全失败轮和本地文件缺失项都保留中文说明。窗口只读:单击选中、双击自适应预览、右键预览/复制路径/打开所在文件夹;不提供删除、重试或切换当前轮操作。重复点击同一商品复用窗口;任务关闭时窗口一起关闭。 - AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 diff --git a/docs/tasks/T-644.md b/docs/tasks/T-644.md index bff530d..406eebf 100644 --- a/docs/tasks/T-644.md +++ b/docs/tasks/T-644.md @@ -3,7 +3,7 @@ id: T-644 title: 商品套图历史生成按轮次弹窗展示 phase: 7 deps: [T-643] -status: TODO +status: DONE created: 2026-07-16 --- @@ -140,4 +140,13 @@ git diff --check ## 执行记录 -- 待执行。 +- 2026-07-16:将⑥顶部「历史生成」从可选中切换按钮改为普通按钮;主结果区移除 `show_history` 分支,始终按项目持久化的当前轮次展示。 +- 2026-07-16:新增 `ProductSuiteHistoryDialog` 和只读缩略图卡片。窗口按当前 `project_id` 分页读取最近20轮 SQLite 记录,按轮次时间倒序展示当前/旧版标签、状态统计、稳定槽位图片、重试次数和本地文件缺失占位;只提供预览、复制路径和打开所在文件夹。 +- 2026-07-16:任务关闭时关闭对应历史窗口;同一商品重复点击复用已打开窗口。生成 worker、当前轮和主结果区不受历史窗口影响。 +- 2026-07-16:`list_generation_rounds()` 增加创建时间倒序作为轮次排序主键,并保留 ID 作为同时间的稳定次序。 +- 2026-07-16:补充轮次分页、跨项目隔离、单槽位重试去重、缺失资产、只读操作、窗口复用、关闭任务和无历史空态测试。 +- 验证通过: + - `py -3.10 -m unittest discover -s tests`(558 项通过;Qt 离屏环境仅输出字体/窗口插件提示) + - `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 b8baa80..20b657c 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -981,6 +981,42 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): [failed_round, round_one, None], [round_.generation_round_key for round_ in rounds], ) + self.assertEqual( + [failed_round], + [ + round_.generation_round_key + for round_ in image_studio.list_generation_rounds( + project.id, + limit=1, + offset=0, + path=db_path, + ) + ], + ) + self.assertEqual( + [round_one], + [ + round_.generation_round_key + for round_ in image_studio.list_generation_rounds( + project.id, + limit=1, + offset=1, + path=db_path, + ) + ], + ) + self.assertEqual( + [None], + [ + round_.generation_round_key + for round_ in image_studio.list_generation_rounds( + project.id, + limit=1, + offset=2, + path=db_path, + ) + ], + ) summary = rounds[1] self.assertTrue(summary.is_current) self.assertEqual(3, summary.job_count) diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index e987f22..06c9ef7 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -25,7 +25,10 @@ from app.gui.tabs.product_suite import ( ORIGINAL_CHECK_STATE_ROLE, ProductOriginalDelegate, ProductOriginalList, + ProductSuiteHistoryDialog, + ProductSuitePreviewDialog, ProductSuiteTab, + SuiteHistoryImageCard, SuiteResultCard, ) from app.gui.product_suite_prompt_dialog import ProductSuitePromptDialog @@ -85,6 +88,44 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): ) return project, assets + def _create_history_job( + self, + project, + source, + db_path, + *, + round_key=None, + slot_index=None, + status="succeeded", + local_path=None, + job_type="白底图", + ): + asset = None + if local_path is not None: + asset = image_studio.add_asset( + project.id, + "generated_main", + local_path=local_path, + parent_asset_id=source.id, + path=db_path, + ) + job = image_studio.create_job( + project.id, + source_asset_id=source.id, + job_type=job_type, + prompt="历史图片", + generation_round_key=round_key, + generation_slot_index=slot_index, + path=db_path, + ) + return image_studio.update_job_status( + job.id, + status, + error="测试失败" if status == "failed" else None, + output_asset_id=asset.id if asset is not None else None, + path=db_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) @@ -1349,13 +1390,6 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): ) self.assertEqual("图片重试成功", messages[-1][0]) - state.show_history = True - history_ids = {job.id for job in tab._jobs_for_state(state)} - self.assertEqual( - {success_job.id, failed_job.id, retry_job.id}, - history_ids, - ) - self.assert_removed(temp_dir) def test_generation_round_restores_after_project_rebind_and_retry_keeps_slot(self): @@ -1572,7 +1606,7 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_retry_tracks_only_new_job_and_preserves_history_view(self): + def test_retry_tracks_only_new_job_and_keeps_current_results(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) @@ -1670,7 +1704,6 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): ) ) - state.show_history = True tab._load_state(state) with mock.patch.object( tab, @@ -1690,14 +1723,274 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): retry_job_id=failed_job.id, ) ) - self.assertTrue(state.show_history) - self.assertTrue(tab.history_button.isChecked()) + self.assertFalse(tab.history_button.isCheckable()) + self.assertEqual("历史生成", tab.history_button.text()) state.worker = None state.thread = None state.generation_run_token = "" self.assert_removed(temp_dir) + def test_history_dialog_groups_current_round_retries_and_paginates(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] + db_path = config["db_path"] + other_project = image_studio.create_or_get_project( + account_alias="其他店", + account_slug="other-shop", + item_id="51100639511", + path=db_path, + ) + other_source = image_studio.add_asset( + other_project.id, + image_studio.ASSET_KIND_ORIGINAL, + path=db_path, + ) + other_job = self._create_history_job( + other_project, + other_source, + db_path, + round_key="other-round", + slot_index=0, + ) + + legacy_job = self._create_history_job(project, source, db_path) + for index in range(20): + self._create_history_job( + project, + source, + db_path, + round_key="old-round-%02d" % index, + slot_index=0, + job_type="场景图", + ) + + current_key = "current-round" + self._create_history_job( + project, + source, + db_path, + round_key=current_key, + slot_index=0, + status="failed", + ) + missing_path = os.path.join(temp_dir, "missing-history-image.png") + retry_job = self._create_history_job( + project, + source, + db_path, + round_key=current_key, + slot_index=0, + local_path=missing_path, + ) + usable_path = os.path.join(temp_dir, "usable-history-image.png") + self._write_image(usable_path) + usable_job = self._create_history_job( + project, + source, + db_path, + round_key=current_key, + slot_index=1, + local_path=usable_path, + job_type="卖点图", + ) + image_studio.set_current_generation_round( + project.id, + current_key, + path=db_path, + ) + + dialog = ProductSuiteHistoryDialog(project.id, db_path=db_path) + dialog.show() + self.app.processEvents() + + self.assertIn("店铺:主店", dialog.context_label.text()) + self.assertIn("商品ID:51100639510", dialog.context_label.text()) + self.assertEqual(20, dialog._round_count) + self.assertEqual(1, dialog._available_image_count) + self.assertTrue(dialog.load_more_button.isVisible()) + self.assertTrue( + any(label.text() == "当前" for label in dialog.findChildren(QLabel)) + ) + + cards = dialog.findChildren(SuiteHistoryImageCard) + cards_by_job = {card.job.id: card for card in cards} + self.assertIn(retry_job.id, cards_by_job) + self.assertIn(usable_job.id, cards_by_job) + self.assertNotIn(other_job.id, cards_by_job) + self.assertNotIn(legacy_job.id, cards_by_job) + self.assertIn("本槽位已重试1次", cards_by_job[retry_job.id].toolTip()) + self.assertIn("本地图片文件不可用", cards_by_job[retry_job.id].toolTip()) + + dialog.load_more() + self.app.processEvents() + self.assertEqual(22, dialog._round_count) + self.assertFalse(dialog.load_more_button.isVisible()) + self.assertTrue( + any( + label.text() == "旧版历史记录" + for label in dialog.findChildren(QLabel) + ) + ) + cards_by_job = { + card.job.id: card + for card in dialog.findChildren(SuiteHistoryImageCard) + } + self.assertIn(legacy_job.id, cards_by_job) + + self.assert_removed(temp_dir) + + def test_history_dialog_actions_are_read_only(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) + image_path = os.path.join(temp_dir, "history-image.png") + self._write_image(image_path) + job = self._create_history_job( + project, + sources[0], + config["db_path"], + round_key="current-round", + slot_index=0, + local_path=image_path, + ) + image_studio.set_current_generation_round( + project.id, + "current-round", + path=config["db_path"], + ) + asset = image_studio.get_asset(job.output_asset_id, path=config["db_path"]) + dialog = ProductSuiteHistoryDialog(project.id, db_path=config["db_path"]) + + with mock.patch.object(ProductSuitePreviewDialog, "exec", return_value=0) as preview: + dialog._preview_job(job, asset) + preview.assert_called_once_with() + + class MenuAction: + def __init__(self, text): + self._text = text + + def text(self): + return self._text + + class MenuStub: + selected_text = "" + observed_actions = [] + + def __init__(self, parent=None): + self._actions = [] + + def addAction(self, text): + action = MenuAction(text) + self._actions.append(action) + return action + + def exec(self, _position): + type(self).observed_actions.extend( + action.text() for action in self._actions + ) + return next( + ( + action + for action in self._actions + if action.text() == type(self).selected_text + ), + None, + ) + + with mock.patch("app.gui.tabs.product_suite.QMenu", MenuStub): + dialog._show_job_menu(job, asset, None) + self.assertEqual( + ["预览", "复制路径", "打开所在文件夹"], + MenuStub.observed_actions, + ) + + MenuStub.selected_text = "复制路径" + with mock.patch("app.gui.tabs.product_suite.QMenu", MenuStub): + dialog._show_job_menu(job, asset, None) + self.assertEqual(image_path, QApplication.clipboard().text()) + + MenuStub.selected_text = "打开所在文件夹" + with mock.patch("app.gui.tabs.product_suite.QMenu", MenuStub), mock.patch( + "app.gui.tabs.product_suite.file_manager.open_in_file_manager" + ) as open_folder: + dialog._show_job_menu(job, asset, None) + open_folder.assert_called_once_with(os.path.dirname(image_path)) + + self.assert_removed(temp_dir) + + def test_history_button_reuses_project_dialog_and_closes_with_task(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) + image_path = os.path.join(temp_dir, "current-image.png") + self._write_image(image_path) + job = self._create_history_job( + project, + sources[0], + config["db_path"], + round_key="current-round", + slot_index=0, + local_path=image_path, + ) + image_studio.set_current_generation_round( + project.id, + "current-round", + 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 = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + state.current_generation_round_key = "current-round" + state.current_job_ids = [job.id] + tab._load_state(state) + tab.show() + self.app.processEvents() + + self.assertFalse(tab.history_button.isCheckable()) + self.assertEqual([job.id], [entry.id for entry in tab._jobs_for_state(state)]) + tab.open_history_dialog() + self.app.processEvents() + dialog = tab._history_dialog + self.assertIsInstance(dialog, ProductSuiteHistoryDialog) + 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.assert_removed(temp_dir) + + def test_history_button_explains_empty_project_without_opening_dialog(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + project, _ = self._create_project_with_assets(temp_dir, config, 0) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = 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.assertEqual("暂无历史生成记录", messages[-1][0]) + self.assertIsNone(tab._history_dialog) + + self.assert_removed(temp_dir) + def test_original_list_expands_without_internal_scrollbars(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir)