feat(product-suite): add global generation history

This commit is contained in:
chengma
2026-07-17 09:59:55 +08:00
parent f1dc7f28dc
commit e232097ba5
13 changed files with 1409 additions and 52 deletions
+1
View File
@@ -31,6 +31,7 @@ if QT_IMPORT_ERROR is None:
ImageStudioResumeJobsWorker,
ProductSuiteAiWriteWorker,
ProductSuiteGenerateWorker,
ProductSuiteHistoryExportWorker,
ProductSuiteImportImagesWorker,
WriteBackWorker,
)
+617 -39
View File
@@ -71,6 +71,7 @@ from ..workers import (
ImageStudioPullImagesWorker,
ProductSuiteAiWriteWorker,
ProductSuiteGenerateWorker,
ProductSuiteHistoryExportWorker,
ProductSuiteImportImagesWorker,
)
@@ -1132,6 +1133,609 @@ class ProductSuiteHistoryDialog(QDialog):
super().closeEvent(event)
class ProductSuiteRoundPreviewDialog(ImagePreviewDialog):
"""Browse the usable output images that belong to one generation round."""
def __init__(self, entries, start_index=0, title="套图原图预览", parent=None):
self._entries = [
(str(path or ""), str(label or "生成图片"))
for path, label in list(entries or [])
if str(path or "")
]
if not self._entries:
self._entries = [("", "生成图片")]
self._entry_index = max(0, min(int(start_index or 0), len(self._entries) - 1))
self._title_prefix = str(title or "套图原图预览")
path, label = self._entries[self._entry_index]
super().__init__(path, self._entry_title(label), parent)
self.setObjectName("suiteHistoryRoundPreviewDialog")
navigation = QHBoxLayout()
navigation.setSpacing(6)
self.previous_button = QToolButton()
self.previous_button.setObjectName("suiteHistoryPreviewPreviousButton")
self.previous_button.setText("上一张")
self.previous_button.setToolTip("查看上一张生成图片")
self.previous_button.clicked.connect(self.show_previous)
navigation.addWidget(self.previous_button)
self.index_label = QLabel()
self.index_label.setObjectName("suiteHistoryPreviewIndexLabel")
self.index_label.setAlignment(Qt.AlignCenter)
self.index_label.setMinimumWidth(86)
navigation.addWidget(self.index_label)
self.next_button = QToolButton()
self.next_button.setObjectName("suiteHistoryPreviewNextButton")
self.next_button.setText("下一张")
self.next_button.setToolTip("查看下一张生成图片")
self.next_button.clicked.connect(self.show_next)
navigation.addWidget(self.next_button)
navigation.addStretch(1)
self.layout().insertLayout(1, navigation)
self._update_navigation()
def _entry_title(self, label):
return "%s · %s" % (self._title_prefix, str(label or "生成图片"))
def _update_navigation(self):
total = len(self._entries)
self.index_label.setText("%d / %d" % (self._entry_index + 1, total))
self.previous_button.setEnabled(total > 1 and self._entry_index > 0)
self.next_button.setEnabled(total > 1 and self._entry_index < total - 1)
def show_previous(self, checked=False):
if self._entry_index > 0:
self._show_entry(self._entry_index - 1)
def show_next(self, checked=False):
if self._entry_index < len(self._entries) - 1:
self._show_entry(self._entry_index + 1)
def _show_entry(self, index):
self._entry_index = max(0, min(int(index), len(self._entries) - 1))
path, label = self._entries[self._entry_index]
self._base_title = self._entry_title(label)
self._source = self._load_source(path)
self.fit_to_window = True
self._display_size = QSize()
self.setWindowTitle(self._window_title())
self._update_navigation()
self._render()
class SuiteGlobalHistoryThumbnail(QLabel):
"""Fixed-size thumbnail that opens its source image on double-click."""
previewRequested = Signal(int)
def __init__(self, job, asset, entry_index, parent=None):
super().__init__(parent)
self.entry_index = int(entry_index)
self._available = asset is not None and _asset_usable(asset)
self.setObjectName("suiteGlobalHistoryThumbnail")
self.setAlignment(Qt.AlignCenter)
self.setFixedSize(104, 78)
self.setToolTip(
"%s · %s"
% (
str(getattr(job, "job_type", "套图") or "套图"),
SuiteHistoryImageCard._status_text(getattr(job, "status", "")),
)
)
if self._available:
self.setPixmap(_image_pixmap(asset.local_path, QSize(104, 78)))
self.setCursor(Qt.PointingHandCursor)
else:
self.setPixmap(
_placeholder_pixmap(
"图片不可用",
QSize(104, 78),
"#fff8c5",
)
)
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.LeftButton and self._available:
self.previewRequested.emit(self.entry_index)
super().mouseDoubleClickEvent(event)
class SuiteGlobalHistoryRoundRow(QFrame):
"""One fixed-height, cross-project history row for a generation round."""
previewRequested = Signal(object, int)
exportRequested = Signal(object)
MAX_THUMBNAILS = 5
def __init__(self, round_info, jobs, assets_by_job, parent=None):
super().__init__(parent)
self.round_info = round_info
self.jobs = list(jobs or [])
self.assets_by_job = dict(assets_by_job or {})
self._usable_entries = [
(job, self.assets_by_job.get(int(getattr(job, "id", 0) or 0)))
for job in self.jobs
if self.assets_by_job.get(int(getattr(job, "id", 0) or 0)) is not None
and _asset_usable(self.assets_by_job.get(int(getattr(job, "id", 0) or 0)))
]
self.setObjectName("suiteGlobalHistoryRoundRow")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setFixedHeight(118)
self.setStyleSheet(
"QFrame#suiteGlobalHistoryRoundRow {"
"border: 1px solid #d8dee4; border-radius: 6px; background: #ffffff;"
"}"
)
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(10)
meta = QVBoxLayout()
meta.setSpacing(3)
meta.setContentsMargins(0, 0, 0, 0)
time_label = QLabel(
"旧版历史记录"
if bool(getattr(round_info, "is_legacy", False))
else "生成于 %s" % _history_time_text(getattr(round_info, "created_at", ""))
)
time_label.setObjectName("suiteGlobalHistoryTime")
time_label.setStyleSheet("font-weight: 600; color: #24292f;")
meta.addWidget(time_label)
account = str(
getattr(round_info, "account_name", "")
or getattr(round_info, "account_alias", "")
or "未命名店铺"
)
item_text = (
"临时草稿"
if str(getattr(round_info, "binding_state", ""))
== image_studio.PROJECT_BINDING_DRAFT
else "商品ID:%s" % str(getattr(round_info, "item_id", "") or "未填写")
)
meta.addWidget(QLabel("店铺:%s" % account))
meta.addWidget(QLabel(item_text))
layout.addLayout(meta, 0)
thumbnails = QHBoxLayout()
thumbnails.setSpacing(6)
thumbnails.setContentsMargins(0, 0, 0, 0)
shown_entries = self._usable_entries[: self.MAX_THUMBNAILS]
for entry_index, (job, asset) in enumerate(shown_entries):
thumbnail = SuiteGlobalHistoryThumbnail(job, asset, entry_index)
thumbnail.previewRequested.connect(self._emit_preview)
thumbnails.addWidget(thumbnail)
if not shown_entries:
placeholder = QLabel("本轮没有可预览图片")
placeholder.setObjectName("suiteGlobalHistoryNoImage")
placeholder.setAlignment(Qt.AlignCenter)
placeholder.setFixedSize(150, 78)
placeholder.setStyleSheet("color: #6b7280; background: #f6f8fa;")
thumbnails.addWidget(placeholder)
remaining = max(0, len(self._usable_entries) - len(shown_entries))
if remaining:
more = QLabel("+%d" % remaining)
more.setObjectName("suiteGlobalHistoryMoreImages")
more.setAlignment(Qt.AlignCenter)
more.setFixedSize(42, 78)
more.setToolTip("本轮还有%d张可预览生成图片" % remaining)
more.setStyleSheet("color: #57606a; background: #f6f8fa; border-radius: 4px;")
thumbnails.addWidget(more)
thumbnails.addStretch(1)
layout.addLayout(thumbnails, 1)
badges = QVBoxLayout()
badges.setSpacing(5)
badges.setContentsMargins(0, 0, 0, 0)
if bool(getattr(round_info, "is_current", False)):
current = QLabel("当前")
current.setObjectName("suiteGlobalHistoryCurrentBadge")
current.setAlignment(Qt.AlignCenter)
current.setStyleSheet(
"color: #0969da; background: #ddf4ff; border: 1px solid #54aeff; "
"border-radius: 5px; padding: 2px 6px; font-weight: 600;"
)
badges.addWidget(current)
elif bool(getattr(round_info, "is_legacy", False)):
legacy = QLabel("旧版")
legacy.setAlignment(Qt.AlignCenter)
legacy.setStyleSheet(
"color: #57606a; background: #f6f8fa; border: 1px solid #d8dee4; "
"border-radius: 5px; padding: 2px 6px;"
)
badges.addWidget(legacy)
stats = QLabel(self._stats_text(round_info))
stats.setObjectName("suiteGlobalHistoryStats")
stats.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
stats.setStyleSheet("color: #57606a;")
badges.addWidget(stats)
badges.addStretch(1)
self.export_button = QPushButton("导出本轮")
self.export_button.setObjectName("suiteGlobalHistoryExportButton")
self.export_button.setEnabled(bool(getattr(round_info, "succeeded_count", 0)))
self.export_button.setToolTip("复制本轮成功生成的图片到所选目录")
self.export_button.clicked.connect(lambda: self.exportRequested.emit(self))
badges.addWidget(self.export_button)
layout.addLayout(badges, 0)
@staticmethod
def _stats_text(round_info):
parts = ["成功 %d" % int(getattr(round_info, "succeeded_count", 0) or 0)]
failed = int(getattr(round_info, "failed_count", 0) or 0)
cancelled = int(getattr(round_info, "cancelled_count", 0) or 0)
active = int(getattr(round_info, "active_count", 0) or 0)
retry = int(getattr(round_info, "retry_count", 0) or 0)
if failed:
parts.append("失败 %d" % failed)
if cancelled:
parts.append("停止 %d" % cancelled)
if active:
parts.append("进行中 %d" % active)
if retry:
parts.append("重试 %d" % retry)
return " · ".join(parts)
def set_exporting(self, exporting):
self.export_button.setEnabled(
not bool(exporting) and bool(getattr(self.round_info, "succeeded_count", 0))
)
self.export_button.setText("正在导出" if exporting else "导出本轮")
def _emit_preview(self, entry_index):
self.previewRequested.emit(self, int(entry_index))
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.LeftButton and self._usable_entries:
self._emit_preview(0)
super().mouseDoubleClickEvent(event)
class ProductSuiteGlobalHistoryDialog(QDialog):
"""Read-only global product-suite history with per-round preview and export."""
PAGE_SIZE = 30
def __init__(self, *, current_project_id=None, db_path=None, parent=None):
super().__init__(parent)
self.db_path = db_path
self.current_project_id = None
self._offset = 0
self._has_more = False
self._round_count = 0
self._available_image_count = 0
self._history_rows = []
self._asset_cache = {}
self._export_worker = None
self._export_thread = None
self._export_error_handled = False
self.setObjectName("suiteGlobalHistoryDialog")
self.setWindowTitle("套图历史生成记录")
self.setModal(False)
self.setAttribute(Qt.WA_DeleteOnClose, True)
self.setMinimumSize(940, 560)
self.resize(1160, 720)
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 14, 14, 14)
layout.setSpacing(10)
header = QHBoxLayout()
title_layout = QVBoxLayout()
title_layout.setSpacing(2)
self.context_label = QLabel("全部商品的套图生成历史")
self.context_label.setObjectName("suiteGlobalHistoryContextLabel")
self.context_label.setStyleSheet("font-weight: 600; color: #24292f;")
self.summary_label = QLabel()
self.summary_label.setObjectName("suiteGlobalHistorySummaryLabel")
self.summary_label.setStyleSheet("color: #57606a;")
title_layout.addWidget(self.context_label)
title_layout.addWidget(self.summary_label)
header.addLayout(title_layout, 1)
self.refresh_button = QPushButton("刷新")
self.refresh_button.setObjectName("suiteGlobalHistoryRefreshButton")
self.refresh_button.setToolTip("重新读取套图历史生成记录")
self.refresh_button.clicked.connect(self.refresh_history)
header.addWidget(self.refresh_button)
layout.addLayout(header)
filters = QHBoxLayout()
filters.setSpacing(8)
filters.addWidget(QLabel("店铺"))
self.account_filter_edit = QLineEdit()
self.account_filter_edit.setObjectName("suiteGlobalHistoryAccountFilter")
self.account_filter_edit.setPlaceholderText("店铺或账号")
self.account_filter_edit.setClearButtonEnabled(True)
self.account_filter_edit.returnPressed.connect(self.refresh_history)
filters.addWidget(self.account_filter_edit, 1)
filters.addWidget(QLabel("商品ID"))
self.item_filter_edit = QLineEdit()
self.item_filter_edit.setObjectName("suiteGlobalHistoryItemFilter")
self.item_filter_edit.setPlaceholderText("输入商品ID")
self.item_filter_edit.setClearButtonEnabled(True)
self.item_filter_edit.returnPressed.connect(self.refresh_history)
filters.addWidget(self.item_filter_edit, 1)
self.current_project_checkbox = QCheckBox("仅当前商品")
self.current_project_checkbox.setObjectName("suiteGlobalHistoryCurrentProjectFilter")
self.current_project_checkbox.toggled.connect(self.refresh_history)
filters.addWidget(self.current_project_checkbox)
self.filter_button = QPushButton("筛选")
self.filter_button.setObjectName("suiteGlobalHistoryFilterButton")
self.filter_button.clicked.connect(self.refresh_history)
filters.addWidget(self.filter_button)
layout.addLayout(filters)
self.notice_label = QLabel()
self.notice_label.setObjectName("suiteGlobalHistoryNoticeLabel")
self.notice_label.setWordWrap(True)
self.notice_label.hide()
layout.addWidget(self.notice_label)
self.scroll = QScrollArea()
self.scroll.setObjectName("suiteGlobalHistoryScrollArea")
self.scroll.setWidgetResizable(True)
self.history_content = QWidget()
self.history_content.setObjectName("suiteGlobalHistoryContent")
self.history_layout = QVBoxLayout(self.history_content)
self.history_layout.setContentsMargins(0, 0, 0, 0)
self.history_layout.setSpacing(7)
self.history_layout.setAlignment(Qt.AlignTop)
self.scroll.setWidget(self.history_content)
layout.addWidget(self.scroll, 1)
self.load_more_button = QPushButton("加载更多")
self.load_more_button.setObjectName("suiteGlobalHistoryLoadMoreButton")
self.load_more_button.clicked.connect(self.load_more)
self.load_more_button.hide()
layout.addWidget(self.load_more_button, 0, Qt.AlignHCenter)
self.set_current_project(current_project_id, refresh=False)
self.refresh_history()
def set_current_project(self, project_id, *, refresh=True):
try:
normalized = int(project_id) if project_id is not None else None
except (TypeError, ValueError):
normalized = None
changed = normalized != self.current_project_id
self.current_project_id = normalized
previous = self.current_project_checkbox.blockSignals(True)
self.current_project_checkbox.setEnabled(normalized is not None)
self.current_project_checkbox.setToolTip(
"只显示当前商品项目的历史生成记录"
if normalized is not None
else "当前没有可筛选的商品项目"
)
if normalized is None:
self.current_project_checkbox.setChecked(False)
self.current_project_checkbox.blockSignals(previous)
if refresh and (changed or self.current_project_checkbox.isChecked()):
self.refresh_history()
def refresh_history(self, checked=False):
scroll_value = self.scroll.verticalScrollBar().value()
self._clear_history_content()
self._offset = 0
self._has_more = False
self._round_count = 0
self._available_image_count = 0
self._asset_cache = {}
if not self._load_next_page():
return
QTimer.singleShot(
0,
lambda: self.scroll.verticalScrollBar().setValue(
min(scroll_value, self.scroll.verticalScrollBar().maximum())
),
)
def load_more(self, checked=False):
if self._has_more:
self._load_next_page()
def _load_next_page(self):
try:
project_id = (
self.current_project_id
if self.current_project_checkbox.isChecked()
else None
)
rounds = image_studio.list_global_generation_rounds(
account_query=self.account_filter_edit.text(),
item_query=self.item_filter_edit.text(),
project_id=project_id,
limit=self.PAGE_SIZE,
offset=self._offset,
path=self.db_path,
)
except Exception as exc:
self._set_error("历史记录读取失败:%s" % _user_error(exc))
return False
self.notice_label.hide()
if not rounds and self._offset == 0:
self._show_empty_state()
else:
for round_info in rounds:
self._add_round(round_info)
self._offset += len(rounds)
self._has_more = len(rounds) == self.PAGE_SIZE
self.load_more_button.setVisible(self._has_more)
self.load_more_button.setEnabled(self._has_more)
self._update_summary()
return True
def _add_round(self, round_info):
try:
jobs = image_studio.list_generation_round_current_jobs(
round_info.project_id,
round_info.generation_round_key,
path=self.db_path,
)
assets_by_job = {}
for job in jobs:
asset_id = getattr(job, "output_asset_id", None)
if not asset_id:
continue
asset_id = int(asset_id)
if asset_id not in self._asset_cache:
self._asset_cache[asset_id] = image_studio.get_asset(
asset_id,
path=self.db_path,
)
assets_by_job[int(job.id)] = self._asset_cache[asset_id]
except Exception as exc:
self._set_notice("部分历史记录无法读取:%s" % _user_error(exc), "#cf222e")
return
row = SuiteGlobalHistoryRoundRow(round_info, jobs, assets_by_job)
row.previewRequested.connect(self._preview_round)
row.exportRequested.connect(self._export_round)
self.history_layout.addWidget(row)
self._history_rows.append(row)
self._round_count += 1
self._available_image_count += len(row._usable_entries)
def _preview_round(self, row, start_index):
entries = [
(
asset.local_path,
str(getattr(job, "job_type", "套图") or "套图"),
)
for job, asset in row._usable_entries
]
if not entries:
self._set_notice("本轮没有可预览的本地生成图片。", "#9a6700")
return
title = "商品ID %s" % str(getattr(row.round_info, "item_id", "") or "临时草稿")
ProductSuiteRoundPreviewDialog(
entries,
start_index=start_index,
title=title,
parent=self,
).exec()
def _export_round(self, row):
if self._export_worker is not None:
self._set_notice("正在导出另一轮图片,请稍候。", "#9a6700")
return
parent_dir = QFileDialog.getExistingDirectory(self, "选择导出父目录")
if not parent_dir:
return
worker = ProductSuiteHistoryExportWorker(
row.round_info.project_id,
row.round_info.generation_round_key,
parent_dir,
db_path=self.db_path,
)
thread = run_worker(worker, thread_name="商品套图历史导出", start=False)
token = id(thread)
_PRODUCT_SUITE_THREAD_REFS[token] = (thread, worker)
thread.finished.connect(lambda token=token: _PRODUCT_SUITE_THREAD_REFS.pop(token, None))
worker.finished.connect(self._on_export_finished)
worker.cancelled.connect(self._on_export_cancelled)
worker.failed.connect(self._on_export_failed)
self._export_worker = worker
self._export_thread = thread
self._export_error_handled = False
self._set_export_controls(True)
thread.start()
def _on_export_finished(self, summary):
if dict(summary or {}).get("ok") is False:
if not self._export_error_handled:
self._on_export_failed(-1, dict(summary or {}).get("error") or "导出失败")
return
self._reset_export_state()
summary = dict(summary or {})
if summary.get("cancelled"):
self._set_notice("导出已停止,已完成的图片保留在导出目录中。", "#9a6700")
return
file_count = int(summary.get("file_count", 0) or 0)
skipped_count = int(summary.get("skipped_count", 0) or 0)
message = "本轮已导出%d张生成图片" % file_count
if skipped_count:
message += ",略过%d张不可用图片" % skipped_count
self._set_notice(message, "#1a7f37")
target_dir = str(summary.get("target_dir") or "")
if not target_dir:
return
box = QMessageBox(self)
box.setWindowTitle("导出完成")
box.setText(message)
open_button = box.addButton("打开目录", QMessageBox.AcceptRole)
box.addButton("确定", QMessageBox.RejectRole)
box.setDefaultButton(open_button)
box.exec()
if box.clickedButton() is open_button:
try:
file_manager.open_in_file_manager(target_dir)
except Exception as exc:
self._set_notice("打开导出目录失败:%s" % _user_error(exc), "#cf222e")
def _on_export_cancelled(self, summary):
self._reset_export_state()
self._set_notice("导出已停止,已完成的图片保留在导出目录中。", "#9a6700")
def _on_export_failed(self, _row, error):
if self._export_error_handled:
return
self._export_error_handled = True
self._reset_export_state()
self._set_notice("导出本轮失败:%s" % _user_error(error), "#cf222e")
def _reset_export_state(self):
self._export_worker = None
self._export_thread = None
self._set_export_controls(False)
def _set_export_controls(self, exporting):
for row in list(self._history_rows):
try:
row.set_exporting(exporting)
except RuntimeError:
continue
def _update_summary(self):
refreshed = time.strftime("%H:%M:%S")
self.summary_label.setText(
"已加载 %d 轮 · 可预览图片 %d 张 · 最近刷新 %s"
% (self._round_count, self._available_image_count, refreshed)
)
def _clear_history_content(self):
self._history_rows = []
while self.history_layout.count():
item = self.history_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
def _set_error(self, message):
self._clear_history_content()
self._set_notice(message, "#cf222e")
self.load_more_button.hide()
self.refresh_button.setEnabled(True)
def _set_notice(self, text, color="#57606a"):
self.notice_label.setText(str(text))
self.notice_label.setStyleSheet("color: %s; padding: 8px 0;" % color)
self.notice_label.show()
def _show_empty_state(self):
empty = QLabel("暂无套图历史生成记录,完成套图生成后会自动出现在这里")
empty.setObjectName("suiteGlobalHistoryEmptyLabel")
empty.setAlignment(Qt.AlignCenter)
empty.setStyleSheet("color: #6b7280; padding: 56px;")
self.history_layout.addWidget(empty)
def closeEvent(self, event):
if self._export_worker is not None:
self._export_worker.cancel()
self._clear_history_content()
super().closeEvent(event)
@dataclass
class SuiteTaskState:
key: int
@@ -1855,8 +2459,6 @@ class ProductSuiteTab(QWidget):
self._generation_run_states.pop(state.generation_run_token, None)
if state.pull_run_token:
self._pull_run_states.pop(state.pull_run_token, None)
if state.project_id is not None:
self._close_history_dialog_for_project(state.project_id)
self._retired_states.append(state)
self._states.pop(state.key, None)
self.task_tabs.removeTab(index)
@@ -4310,48 +4912,23 @@ class ProductSuiteTab(QWidget):
def open_history_dialog(self, checked=False):
state = self._displayed_state
if state is None or state.project_id is None:
self._message(
"未创建商品套图",
"请先选择账号并添加本地图片,完成生成后再查看历史记录。",
)
return
try:
project = image_studio.get_project(state.project_id, path=self.db_path)
rounds = image_studio.list_generation_rounds(
state.project_id,
limit=1,
path=self.db_path,
)
except Exception as exc:
self._message("读取历史生成失败", _user_error(exc))
return
if project is None:
self._message("商品项目不存在", "当前商品套图项目已经不存在或已删除。")
return
if not rounds:
self._message(
"暂无历史生成记录",
"完成套图生成后会自动出现在历史生成记录中。",
)
return
current_project_id = state.project_id if state is not None else None
dialog = self._history_dialog
if dialog is not None:
try:
if dialog.project_id == int(project.id):
dialog.refresh_history()
dialog.show()
dialog.raise_()
dialog.activateWindow()
return
dialog.close()
dialog.set_current_project(current_project_id, refresh=False)
dialog.refresh_history()
dialog.show()
dialog.raise_()
dialog.activateWindow()
return
except RuntimeError:
pass
self._history_dialog = None
dialog = ProductSuiteHistoryDialog(
project.id,
dialog = ProductSuiteGlobalHistoryDialog(
current_project_id=current_project_id,
db_path=self.db_path,
parent=self,
)
@@ -4368,13 +4945,14 @@ class ProductSuiteTab(QWidget):
self._history_dialog = None
def _close_history_dialog_for_project(self, project_id=None):
if project_id is not None:
return
dialog = self._history_dialog
if dialog is None:
return
try:
if project_id is None or dialog.project_id == int(project_id):
dialog.close()
self._history_dialog = None
dialog.close()
self._history_dialog = None
except RuntimeError:
self._history_dialog = None
+28
View File
@@ -418,6 +418,34 @@ class ProductSuiteGenerateWorker(BaseWorker):
return summary
class ProductSuiteHistoryExportWorker(BaseWorker):
"""Copy one product-suite generation round outside the GUI thread."""
def __init__(self, project_id, generation_round_key, parent_dir, *, db_path=None):
super().__init__()
self.project_id = int(project_id)
self.generation_round_key = generation_round_key
self.parent_dir = str(parent_dir or "")
self.db_path = db_path
def execute(self):
self.log.emit("[商品套图] 导出历史套图:开始")
result = image_studio_export.export_generation_round(
self.project_id,
self.generation_round_key,
self.parent_dir,
path=self.db_path,
should_stop=self.should_cancel,
)
self.log.emit("[商品套图] 导出历史套图:完成")
return {
"target_dir": result.target_dir,
"file_count": len(result.files),
"skipped_count": int(result.skipped_count),
"cancelled": bool(result.cancelled),
}
class ProductSuiteAiWriteWorker(BaseWorker):
"""Analyze local product images without blocking the suite workspace."""
+164
View File
@@ -128,6 +128,29 @@ class ImageStudioGenerationRound:
is_legacy: bool
@dataclass(frozen=True)
class ImageStudioHistoryRound:
"""A persisted generation round with enough project context for global history."""
project_id: int
account_alias: str
account_name: Optional[str]
item_id: str
binding_state: str
generation_round_key: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
job_count: int
slot_count: int
succeeded_count: int
failed_count: int
cancelled_count: int
active_count: int
retry_count: int
is_current: bool
is_legacy: bool
class ImageStudioError(RuntimeError):
"""Raised when the AI image studio service cannot complete an operation."""
@@ -1296,6 +1319,147 @@ def list_generation_rounds(project_id, *, limit=None, offset=0, path=None, conn=
return rounds
def list_global_generation_rounds(
*,
account_query="",
item_query="",
project_id=None,
limit=None,
offset=0,
path=None,
conn=None,
):
"""List persisted generation rounds across active projects without loading assets."""
try:
offset = max(0, int(offset or 0))
except (TypeError, ValueError) as exc:
raise db.DbError("全局历史偏移量无效") from exc
if limit is not None:
try:
limit = int(limit)
except (TypeError, ValueError) as exc:
raise db.DbError("全局历史数量无效") from exc
if limit <= 0:
return []
clauses = ["projects.deleted_at IS NULL"]
params = []
if project_id is not None:
try:
clauses.append("projects.id = ?")
params.append(int(project_id))
except (TypeError, ValueError) as exc:
raise db.DbError("当前商品项目无效") from exc
account_text = str(account_query or "").strip()
if account_text:
pattern = "%%%s%%" % account_text
clauses.append(
"(projects.account_alias LIKE ? OR COALESCE(projects.account_name, '') LIKE ?)"
)
params.extend([pattern, pattern])
item_text = str(item_query or "").strip()
if item_text:
clauses.append("projects.item_id LIKE ?")
params.append("%%%s%%" % item_text)
sql = """
WITH latest_round_jobs AS (
SELECT project_id,
generation_round_key,
generation_slot_index,
MAX(id) AS latest_id
FROM image_studio_jobs
WHERE generation_round_key IS NOT NULL
GROUP BY project_id, generation_round_key, generation_slot_index
),
effective_jobs AS (
SELECT jobs.*
FROM image_studio_jobs AS jobs
LEFT JOIN latest_round_jobs AS latest
ON latest.latest_id = jobs.id
WHERE jobs.generation_round_key IS NULL OR latest.latest_id IS NOT NULL
),
round_attempts AS (
SELECT project_id,
generation_round_key,
COUNT(*) AS attempt_count
FROM image_studio_jobs
GROUP BY project_id, generation_round_key
)
SELECT projects.id AS project_id,
projects.account_alias AS account_alias,
projects.account_name AS account_name,
projects.item_id AS item_id,
projects.binding_state AS binding_state,
jobs.generation_round_key AS generation_round_key,
MIN(jobs.created_at) AS created_at,
MAX(jobs.updated_at) AS updated_at,
COUNT(*) AS job_count,
COUNT(DISTINCT jobs.generation_slot_index) AS slot_count,
MAX(attempts.attempt_count) AS attempt_count,
SUM(CASE WHEN jobs.status = 'succeeded' THEN 1 ELSE 0 END) AS succeeded_count,
SUM(CASE WHEN jobs.status IN ('failed', 'expired') THEN 1 ELSE 0 END) AS failed_count,
SUM(CASE WHEN jobs.status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_count,
SUM(CASE WHEN jobs.status IN ('pending', 'submitted', 'running') THEN 1 ELSE 0 END) AS active_count,
CASE
WHEN jobs.generation_round_key IS NOT NULL
AND jobs.generation_round_key = projects.current_generation_round_key
THEN 1 ELSE 0
END AS is_current,
CASE WHEN jobs.generation_round_key IS NULL THEN 1 ELSE 0 END AS is_legacy
FROM image_studio_projects AS projects
INNER JOIN effective_jobs AS jobs ON jobs.project_id = projects.id
INNER JOIN round_attempts AS attempts
ON attempts.project_id = jobs.project_id
AND attempts.generation_round_key IS jobs.generation_round_key
WHERE %s
GROUP BY projects.id, jobs.generation_round_key
ORDER BY MIN(jobs.created_at) DESC,
MAX(jobs.id) DESC
""" % " AND ".join(clauses)
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
elif offset:
sql += " LIMIT -1 OFFSET ?"
params.append(offset)
with _connection(conn, path) as database:
rows = database.execute(sql, params).fetchall()
rounds = []
for row in rows:
round_key = row["generation_round_key"]
job_count = int(row["job_count"] or 0)
slot_count = int(row["slot_count"] or 0)
rounds.append(
ImageStudioHistoryRound(
project_id=int(row["project_id"]),
account_alias=str(row["account_alias"] or ""),
account_name=row["account_name"],
item_id=str(row["item_id"] or ""),
binding_state=str(row["binding_state"] or ""),
generation_round_key=round_key,
created_at=row["created_at"],
updated_at=row["updated_at"],
job_count=job_count,
slot_count=slot_count,
succeeded_count=int(row["succeeded_count"] or 0),
failed_count=int(row["failed_count"] or 0),
cancelled_count=int(row["cancelled_count"] or 0),
active_count=int(row["active_count"] or 0),
retry_count=(
max(0, int(row["attempt_count"] or 0) - job_count)
if round_key
else 0
),
is_current=bool(row["is_current"]),
is_legacy=bool(row["is_legacy"]),
)
)
return rounds
def list_generation_round_current_jobs(project_id, generation_round_key, path=None, conn=None):
project_id = int(project_id)
if generation_round_key is None:
+158
View File
@@ -43,6 +43,23 @@ class ExportResult:
existing_mode: str
@dataclass(frozen=True)
class GenerationRoundExportedFile:
job_id: int
asset_id: int
job_type: str
source_path: str
output_path: str
@dataclass(frozen=True)
class GenerationRoundExportResult:
target_dir: str
files: tuple[GenerationRoundExportedFile, ...]
skipped_count: int
cancelled: bool
def target_dir_for_project(project, parent_dir, suffix=None):
parent = _existing_parent_dir(parent_dir)
item = _safe_item_id(getattr(project, "item_id", ""))
@@ -114,6 +131,79 @@ def export_project_selection(
shutil.rmtree(staging, ignore_errors=True)
def export_generation_round(
project_id,
generation_round_key,
parent_dir,
*,
path=None,
should_stop=None,
):
"""Copy usable successful output images from one persisted generation round."""
project = image_studio.get_project(project_id, path=path)
if project is None:
raise ImageStudioExportError("商品套图项目不存在,无法导出历史记录")
try:
parent = _existing_parent_dir(parent_dir)
except ImageStudioExportError as exc:
raise ImageStudioExportError("导出父目录不可用") from exc
jobs = image_studio.list_generation_round_current_jobs(
project.id,
generation_round_key,
path=path,
)
planned, skipped_count = _planned_generation_round_files(jobs, path)
if not planned:
raise ImageStudioExportError("本轮没有可导出的成功生成图片")
target = _create_generation_round_target_dir(project, parent, jobs)
copied = []
cancelled = False
try:
for item in planned:
if callable(should_stop) and should_stop():
cancelled = True
break
output_path = os.path.join(target, item["filename"])
try:
shutil.copy2(item["source_path"], output_path)
except OSError:
skipped_count += 1
if os.path.isfile(output_path):
try:
os.remove(output_path)
except OSError:
pass
continue
copied.append(
GenerationRoundExportedFile(
job_id=int(item["job_id"]),
asset_id=int(item["asset_id"]),
job_type=str(item["job_type"]),
source_path=item["source_path"],
output_path=output_path,
)
)
except Exception as exc:
raise ImageStudioExportError("导出本轮生成图片失败") from exc
if not copied:
try:
os.rmdir(target)
except OSError:
pass
if cancelled:
return GenerationRoundExportResult("", (), skipped_count, True)
raise ImageStudioExportError("本轮生成图片导出失败")
return GenerationRoundExportResult(
target_dir=target,
files=tuple(copied),
skipped_count=skipped_count,
cancelled=cancelled,
)
def _choose_target_dir(project, parent_dir, existing_mode, timestamp=None):
target = target_dir_for_project(project, parent_dir)
if not os.path.exists(target):
@@ -157,6 +247,66 @@ def _planned_files(project, db_path):
return planned
def _planned_generation_round_files(jobs, db_path):
planned = []
skipped_count = 0
for index, job in enumerate(jobs, 1):
if str(getattr(job, "status", "") or "") != "succeeded":
continue
asset_id = getattr(job, "output_asset_id", None)
asset = image_studio.get_asset(asset_id, path=db_path) if asset_id else None
source_path = str(getattr(asset, "local_path", "") or "")
if (
asset is None
or str(getattr(asset, "status", "") or "")
== image_studio.ASSET_STATUS_MISSING
or not source_path
or not os.path.isfile(source_path)
):
skipped_count += 1
continue
suffix = os.path.splitext(source_path)[1].lower()
if not suffix or len(suffix) > 8:
suffix = ".jpg"
job_type = _safe_export_component(
getattr(job, "job_type", ""),
fallback="套图",
)
planned.append(
{
"job_id": int(job.id),
"asset_id": int(asset.id),
"job_type": str(getattr(job, "job_type", "") or "套图"),
"source_path": os.path.abspath(source_path),
"filename": "%02d_%s%s" % (index, job_type, suffix),
}
)
return planned, skipped_count
def _create_generation_round_target_dir(project, parent_dir, jobs):
account = _safe_export_component(
getattr(project, "account_name", "") or getattr(project, "account_alias", ""),
fallback="未命名店铺",
)
item = _safe_export_component(getattr(project, "item_id", ""), fallback="临时草稿")
created_at = str(getattr(jobs[0], "created_at", "") or "") if jobs else ""
digits = "".join(character for character in created_at if character.isdigit())[:14]
stamp = digits or datetime.now().strftime("%Y%m%d_%H%M%S")
base = os.path.join(parent_dir, "%s_%s_%s" % (account, item, stamp))
candidate = base
index = 2
while True:
try:
os.makedirs(candidate, exist_ok=False)
return candidate
except FileExistsError:
candidate = "%s_%d" % (base, index)
index += 1
except OSError as exc:
raise ImageStudioExportError("无法创建导出目录") from exc
def _save_jpeg(source_path, output_path, quality):
try:
from PIL import Image
@@ -208,3 +358,11 @@ def _safe_item_id(item_id):
if not safe:
raise ImageStudioExportError("商品ID不能作为目录名")
return safe
def _safe_export_component(value, *, fallback):
text = str(value or "").strip()
safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in text).strip("_")
if safe:
return safe
return str(fallback)