feat(product-suite): add global generation history
This commit is contained in:
@@ -31,6 +31,7 @@ if QT_IMPORT_ERROR is None:
|
||||
ImageStudioResumeJobsWorker,
|
||||
ProductSuiteAiWriteWorker,
|
||||
ProductSuiteGenerateWorker,
|
||||
ProductSuiteHistoryExportWorker,
|
||||
ProductSuiteImportImagesWorker,
|
||||
WriteBackWorker,
|
||||
)
|
||||
|
||||
+617
-39
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user