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
+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