feat(product-suite): add grouped generation history

This commit is contained in:
chengma
2026-07-16 23:47:47 +08:00
parent f3defdeb95
commit cd150ef6c7
8 changed files with 879 additions and 35 deletions
+524 -22
View File
@@ -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()):
+1
View File
@@ -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]
+1
View File
@@ -443,6 +443,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # 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 不受历史窗口影响。
提示词管理:
+1
View File
@@ -410,6 +410,7 @@ export_project_selection(project_id, parent_dir, existing_mode="fail", path=None
- 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool/<slug>/<item_id>/` 下分 `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`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。
+1
View File
@@ -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` 留作代码兼容但不再作为主窗口入口。
+11 -2
View File
@@ -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`
+36
View File
@@ -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)
+304 -11
View File
@@ -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)