feat(ai-studio): separate image pool from task states

This commit is contained in:
chengma
2026-07-13 09:54:08 +08:00
parent 42fe233ed9
commit b1c050f633
9 changed files with 500 additions and 59 deletions
+144 -40
View File
@@ -7,7 +7,7 @@ import os
from PySide6.QtCore import QObject, QMimeData, QSize, Signal
from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem, QSizePolicy
from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, image_studio_images, prompts
from ... import accounts, appconfig, cmhub_models, db, diagnostics, image_studio, image_studio_export, image_studio_images, prompts
from .. import file_manager
from ..widgets import *
from ..workers import (
@@ -266,12 +266,12 @@ class ImageStudioTab(QWidget):
PROJECT_COLUMNS = ["店铺", "商品ID", "更新时间"]
JOB_STATUS_LABELS = {
"pending": "排队中",
"pending": "等待提交",
"submitted": "已提交",
"running": "生成中",
"succeeded": "成功",
"failed": "失败",
"expired": "已过期",
"failed": "生成失败",
"expired": "任务过期",
"cancelled": "已停止",
}
@@ -454,6 +454,9 @@ class ImageStudioTab(QWidget):
pool_title = QLabel("照片池")
pool_title.setObjectName("imageStudioSectionTitle")
pool_header.addWidget(pool_title)
self.pool_summary_label = QLabel("可用图片 0 张")
self.pool_summary_label.setObjectName("imageStudioMutedLabel")
pool_header.addWidget(self.pool_summary_label)
pool_header.addStretch(1)
self.source_label = QLabel("源图:未选择")
self.source_label.setObjectName("imageStudioSourceLabel")
@@ -466,6 +469,30 @@ class ImageStudioTab(QWidget):
self.pool_grid.setGridSize(QSize(108, 124))
self.pool_grid.setContextMenuPolicy(Qt.CustomContextMenu)
layout.addWidget(self.pool_grid, 2)
self.job_section = QWidget()
self.job_section.setObjectName("imageStudioJobSection")
job_layout = QVBoxLayout(self.job_section)
job_layout.setContentsMargins(0, 0, 0, 0)
job_layout.setSpacing(4)
job_header = QHBoxLayout()
job_title = QLabel("生成任务")
job_title.setObjectName("imageStudioSectionTitle")
job_header.addWidget(job_title)
self.job_summary_label = QLabel("未完成/异常任务 0 个")
self.job_summary_label.setObjectName("imageStudioMutedLabel")
job_header.addWidget(self.job_summary_label)
job_header.addStretch(1)
job_layout.addLayout(job_header)
self.job_grid = ImageStudioThumbnailGrid(parent=self.job_section)
self.job_grid.setObjectName("imageStudioJobGrid")
self.job_grid.setIconSize(QSize(42, 42))
self.job_grid.setGridSize(QSize(162, 94))
self.job_grid.setMinimumHeight(100)
self.job_grid.setMaximumHeight(112)
job_layout.addWidget(self.job_grid)
self.job_section.setVisible(False)
layout.addWidget(self.job_section, 0)
return panel
def _build_generation_panel(self):
@@ -1110,6 +1137,7 @@ class ImageStudioTab(QWidget):
self._refresh_project_summary()
self._fill_original_grid()
self._fill_pool_grid()
self._fill_job_grid()
self._refresh_selection_labels()
self._refresh_source_label()
@@ -1148,40 +1176,71 @@ class ImageStudioTab(QWidget):
self._refresh_original_download_label()
def _fill_pool_grid(self):
rows = []
for asset in self.assets:
if asset.kind not in {"original", "generated_main", "generated_detail"} or not _asset_is_usable(asset):
continue
rows.append(("asset", asset))
for job in self.jobs:
if job.status in {"pending", "submitted", "running", "failed", "expired", "cancelled"}:
rows.append(("job", job))
assets = [
asset
for asset in self.assets
if asset.kind in {"original", "generated_main", "generated_detail"} and _asset_is_usable(asset)
]
self.pool_grid.clear()
for row_type, obj in rows:
if row_type == "asset":
draggable = _asset_is_usable(obj)
data = {"type": "asset", "asset_id": int(obj.id), "draggable": draggable}
text = f"{_asset_badge(obj.kind)} #{obj.id}\n{obj.aspect_ratio or '比例未知'}"
tooltip = "单击设为源图,双击查看大图,可拖入终选槽。"
else:
data = {"type": "job", "job_id": int(obj.id)}
text = f"任务\n{_job_status_text(obj, self.JOB_STATUS_LABELS)}"
tooltip = "生图任务已保存,可继续查询。"
for asset in assets:
draggable = _asset_is_usable(asset)
data = {"type": "asset", "asset_id": int(asset.id), "draggable": draggable}
text = f"{_asset_badge(asset.kind)} #{asset.id}\n{asset.aspect_ratio or '比例未知'}"
tooltip = "单击设为源图,双击查看大图,可拖入终选槽。"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, data)
item.setSizeHint(QSize(108, 124))
item.setToolTip(tooltip)
if row_type == "asset":
item.setIcon(_asset_icon(obj, _asset_badge(obj.kind), size=QSize(86, 86)))
if obj.id == self.selected_source_asset_id:
item.setBackground(QColor("#eaf2ff"))
else:
item.setIcon(_job_icon(obj.status))
if obj.status in {"failed", "expired"}:
item.setForeground(_qcolor(COLOR_DANGER))
elif obj.status in {"pending", "submitted", "running"}:
item.setForeground(_qcolor(COLOR_WARNING))
item.setIcon(_asset_icon(asset, _asset_badge(asset.kind), size=QSize(86, 86)))
if asset.id == self.selected_source_asset_id:
item.setBackground(QColor("#eaf2ff"))
self.pool_grid.addItem(item)
self.pool_summary_label.setText(f"可用图片 {len(assets)} 张")
def _fill_job_grid(self):
usable_asset_ids = {
int(asset.id)
for asset in self.assets
if _asset_is_usable(asset)
}
jobs = [
job
for job in self.jobs
if job.status in {"pending", "submitted", "running", "failed", "expired", "cancelled"}
and int(job.output_asset_id or 0) not in usable_asset_ids
]
self.job_grid.clear()
self.job_summary_label.setText(f"未完成/异常任务 {len(jobs)} 个")
self.job_section.setVisible(bool(jobs))
for job in jobs:
status_text = self.JOB_STATUS_LABELS.get(job.status, "任务状态未知")
reason = _job_error_summary(job)
recovery = _job_recovery_summary(job)
billing = _job_billing_text(job)
headline = status_text if not reason else f"{status_text}:{reason}"
lines = [headline, recovery]
if billing:
lines.append(billing)
item = QListWidgetItem("\n".join(lines))
item.setData(Qt.UserRole, {"type": "job", "job_id": int(job.id), "draggable": False})
item.setSizeHint(QSize(162, 94))
tooltip_lines = [f"状态:{status_text}", recovery]
if reason:
tooltip_lines.append(f"原因:{reason}")
if billing:
tooltip_lines.append(billing)
item.setToolTip("\n".join(tooltip_lines))
item.setIcon(_job_icon(job.status, size=QSize(42, 42)))
if job.status in {"failed", "expired"}:
item.setForeground(_qcolor(COLOR_DANGER))
item.setBackground(QColor("#ffebe9"))
elif job.status in {"pending", "submitted", "running"}:
item.setForeground(_qcolor(COLOR_WARNING))
item.setBackground(QColor("#fff8c5"))
else:
item.setForeground(_qcolor(COLOR_MUTED))
item.setBackground(QColor("#f6f8fa"))
self.job_grid.addItem(item)
def _selection_asset_ids(self, selection_type):
return [
@@ -1520,6 +1579,7 @@ class ImageStudioTab(QWidget):
self.prompt_edit.blockSignals(False)
self._fill_original_grid()
self._fill_pool_grid()
self._fill_job_grid()
self._refresh_selection_labels()
self._refresh_source_label()
self._refresh_project_summary()
@@ -2019,6 +2079,7 @@ class ImageStudioTab(QWidget):
self.project_table.setEnabled(not running)
self.original_grid.setEnabled(not running)
self.pool_grid.setEnabled(not running or generation_running)
self.job_grid.setEnabled(not running or generation_running)
self.main_selection_list.setEnabled(not running or generation_running)
self.detail_selection_list.setEnabled(not running or generation_running)
self.template_combo.setEnabled(not running or generation_running)
@@ -2229,15 +2290,15 @@ def _asset_icon(asset, fallback_label, size=None, cached_pixmap=None):
)
def _job_icon(status):
def _job_icon(status, size=None):
label = {
"pending": "排",
"submitted": "提",
"running": "生",
"failed": "败",
"expired": "过",
"pending": "…",
"submitted": "…",
"running": "…",
"failed": "!",
"expired": "!",
"cancelled": "停",
}.get(str(status or ""), "任")
}.get(str(status or ""), "?")
color = {
"failed": "#ffebe9",
"expired": "#ffebe9",
@@ -2246,7 +2307,7 @@ def _job_icon(status):
"submitted": "#fff8c5",
"pending": "#f6f8fa",
}.get(str(status or ""), "#f6f8fa")
return QIcon(_placeholder_pixmap(label, QSize(86, 86), color))
return QIcon(_placeholder_pixmap(label, size or QSize(42, 42), color))
def _asset_pixmap(asset, size, fallback_label=None, cached_pixmap=None):
@@ -2337,6 +2398,20 @@ def _selection_tooltip(selection_type, asset):
def _job_status_text(job, labels):
parts = [labels.get(job.status, job.status)]
recovery = _job_recovery_summary(job)
if recovery:
parts.append(recovery)
error = _job_error_summary(job)
if error:
parts.append(error)
billing = _job_billing_text(job)
if billing:
parts.append(billing)
return ",".join(parts)
def _job_billing_text(job):
parts = []
if job.points_cost is not None:
parts.append(f"扣点{job.points_cost}")
if job.points_balance is not None:
@@ -2344,3 +2419,32 @@ def _job_status_text(job, labels):
if job.call_id:
parts.append(f"call_id={job.call_id}")
return ",".join(parts)
def _job_recovery_summary(job):
if getattr(job, "recovery_action", "") == image_studio.JOB_RECOVERY_RESUME:
return "可继续查询,不会重复扣点"
if job.status == "pending":
return "等待本轮提交"
if job.status == "submitted":
return "等待任务结果"
if job.status == "running":
return "正在生成,可稍后继续查询"
return "需要重新生成,可能再次扣点"
def _job_error_summary(job):
raw = " ".join(str(getattr(job, "error", "") or "").split())
if not raw:
return ""
text = diagnostics.redact_log_text(raw)
lowered = text.lower()
if (
"http://" in lowered
or "https://" in lowered
or "/api/" in lowered
or "traceback" in lowered
or any(char.isascii() and char.isalpha() for char in text)
):
return "任务未完成,请按恢复方式处理"
return text[:24] + ("…" if len(text) > 24 else "")