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]