feat(product-suite): add grouped generation history
This commit is contained in:
+524
-22
@@ -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()):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 不受历史窗口影响。
|
||||
|
||||
提示词管理:
|
||||
|
||||
|
||||
@@ -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`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user