feat(ai-studio): refresh workspace layout

This commit is contained in:
chengma
2026-07-11 15:41:38 +08:00
parent 5b4b9af678
commit 656f86632d
3 changed files with 373 additions and 57 deletions
+317 -52
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import os
from PySide6.QtCore import QMimeData
from PySide6.QtCore import QMimeData, QSize
from PySide6.QtWidgets import QListWidget, QListWidgetItem
from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, prompts
@@ -172,6 +172,8 @@ class ImageStudioSelectionList(QListWidget):
if item is None:
return
asset_id = item.data(Qt.UserRole)
if asset_id is None:
return
self.tab.remove_asset_from_selection(self.selection_type, asset_id)
def _show_context_menu(self, position):
@@ -188,7 +190,8 @@ class ImageStudioSelectionList(QListWidget):
def _row_for_asset(self, asset_id):
for row in range(self.count()):
item = self.item(row)
if item is not None and int(item.data(Qt.UserRole)) == int(asset_id):
item_asset_id = item.data(Qt.UserRole) if item is not None else None
if item_asset_id is not None and int(item_asset_id) == int(asset_id):
return row
return None
@@ -289,6 +292,8 @@ class ImageStudioTab(QWidget):
root.setContentsMargins(10, 8, 10, 8)
root.setSpacing(8)
root.addWidget(self._build_top_bar(), 0)
splitter = QSplitter(Qt.Horizontal)
splitter.setObjectName("imageStudioMainSplitter")
splitter.addWidget(self._build_project_panel())
@@ -299,6 +304,49 @@ class ImageStudioTab(QWidget):
splitter.setStretchFactor(2, 2)
root.addWidget(splitter, 1)
root.addWidget(self._build_final_panel(), 0)
self._apply_workspace_style()
self._update_generation_action_text()
def _build_top_bar(self):
panel = QWidget()
panel.setObjectName("imageStudioTopBar")
layout = QHBoxLayout(panel)
layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(8)
self.account_combo = QComboBox()
self.account_combo.setObjectName("imageStudioAccountCombo")
self.account_combo.setMinimumWidth(180)
self.item_id_edit = QLineEdit()
self.item_id_edit.setObjectName("imageStudioItemIdEdit")
self.item_id_edit.setPlaceholderText("商品ID")
self.item_id_edit.setMinimumWidth(160)
self.open_project_button = QPushButton("打开商品项目")
self.open_project_button.setObjectName("imageStudioOpenProjectButton")
self.current_project_label = QLabel("未打开商品项目")
self.current_project_label.setObjectName("imageStudioCurrentProjectLabel")
self.current_project_label.setMinimumWidth(220)
self.autosave_label = QLabel("选择账号并输入商品ID后打开")
self.autosave_label.setObjectName("imageStudioAutosaveLabel")
self.pull_images_button = QPushButton("拉取蝦皮主图")
self.pull_images_button.setObjectName("imageStudioPullImagesButton")
self.open_folder_button = QPushButton("打开项目文件夹")
self.open_folder_button.setObjectName("imageStudioOpenFolderButton")
layout.addWidget(QLabel("账号"))
layout.addWidget(self.account_combo)
layout.addWidget(QLabel("商品ID"))
layout.addWidget(self.item_id_edit)
layout.addWidget(self.open_project_button)
layout.addSpacing(8)
layout.addWidget(self.current_project_label, 1)
layout.addWidget(self.autosave_label)
layout.addStretch(1)
layout.addWidget(self.pull_images_button)
layout.addWidget(self.open_folder_button)
return panel
def _build_project_panel(self):
panel = QWidget()
@@ -307,28 +355,13 @@ class ImageStudioTab(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
project_form = QFormLayout()
project_form.setLabelAlignment(Qt.AlignRight)
self.account_combo = QComboBox()
self.account_combo.setObjectName("imageStudioAccountCombo")
self.item_id_edit = QLineEdit()
self.item_id_edit.setObjectName("imageStudioItemIdEdit")
self.item_id_edit.setPlaceholderText("商品ID")
project_form.addRow("账号", self.account_combo)
project_form.addRow("商品ID", self.item_id_edit)
layout.addLayout(project_form)
toolbar = QHBoxLayout()
self.open_project_button = QPushButton("打开项目")
self.open_project_button.setObjectName("imageStudioOpenProjectButton")
self.pull_images_button = QPushButton("拉取主图")
self.pull_images_button.setObjectName("imageStudioPullImagesButton")
self.open_folder_button = QPushButton("打开项目文件夹")
self.open_folder_button.setObjectName("imageStudioOpenFolderButton")
toolbar.addWidget(self.open_project_button)
toolbar.addWidget(self.pull_images_button)
toolbar.addWidget(self.open_folder_button)
layout.addLayout(toolbar)
title = QLabel("商品项目")
title.setObjectName("imageStudioSectionTitle")
hint = QLabel("点击切换项目,项目会自动保存")
hint.setObjectName("imageStudioMutedLabel")
hint.setWordWrap(True)
layout.addWidget(title)
layout.addWidget(hint)
self.project_table = QTableWidget(0, len(self.PROJECT_COLUMNS))
self.project_table.setObjectName("imageStudioProjectList")
@@ -338,6 +371,7 @@ class ImageStudioTab(QWidget):
self.project_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.project_table.horizontalHeader().setStretchLastSection(True)
self.project_table.verticalHeader().setVisible(False)
self.project_table.setMinimumWidth(220)
layout.addWidget(self.project_table, 1)
return panel
@@ -348,8 +382,15 @@ class ImageStudioTab(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self.workspace_empty_label = QLabel("选择账号并输入商品ID,点击「打开商品项目」开始。")
self.workspace_empty_label.setObjectName("imageStudioWorkspaceEmptyLabel")
self.workspace_empty_label.setWordWrap(True)
layout.addWidget(self.workspace_empty_label)
original_header = QHBoxLayout()
original_header.addWidget(QLabel("蝦皮原主图"))
original_title = QLabel("蝦皮原主图")
original_title.setObjectName("imageStudioSectionTitle")
original_header.addWidget(original_title)
original_header.addStretch(1)
self.original_hint_label = QLabel("单击下载并加入照片池,双击查看大图")
self.original_hint_label.setObjectName("imageStudioOriginalHintLabel")
@@ -364,10 +405,14 @@ class ImageStudioTab(QWidget):
self.original_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.original_table.horizontalHeader().setStretchLastSection(True)
self.original_table.verticalHeader().setVisible(False)
self.original_table.setIconSize(QSize(72, 72))
self.original_table.setAlternatingRowColors(False)
layout.addWidget(self.original_table, 1)
pool_header = QHBoxLayout()
pool_header.addWidget(QLabel("照片池"))
pool_title = QLabel("照片池")
pool_title.setObjectName("imageStudioSectionTitle")
pool_header.addWidget(pool_title)
pool_header.addStretch(1)
self.source_label = QLabel("源图:未选择")
self.source_label.setObjectName("imageStudioSourceLabel")
@@ -384,6 +429,8 @@ class ImageStudioTab(QWidget):
self.pool_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.pool_table.horizontalHeader().setStretchLastSection(True)
self.pool_table.verticalHeader().setVisible(False)
self.pool_table.setIconSize(QSize(86, 86))
self.pool_table.setAlternatingRowColors(False)
layout.addWidget(self.pool_table, 2)
return panel
@@ -394,7 +441,24 @@ class ImageStudioTab(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
title = QLabel("生成设置")
title.setObjectName("imageStudioSectionTitle")
layout.addWidget(title)
source_row = QHBoxLayout()
self.source_preview_label = QLabel("未选择源图")
self.source_preview_label.setObjectName("imageStudioSourcePreview")
self.source_preview_label.setAlignment(Qt.AlignCenter)
self.source_preview_label.setMinimumSize(86, 86)
source_note = QLabel("当前源图\n单击照片池图片设为源图,双击查看大图")
source_note.setObjectName("imageStudioMutedLabel")
source_note.setWordWrap(True)
source_row.addWidget(self.source_preview_label, 0)
source_row.addWidget(source_note, 1)
layout.addLayout(source_row)
template_layout = QGridLayout()
template_layout.setSpacing(6)
self.template_combo = QComboBox()
self.template_combo.setObjectName("imageStudioTemplateCombo")
self.template_new_button = QPushButton("新建")
@@ -405,11 +469,14 @@ class ImageStudioTab(QWidget):
self.template_save_button.setObjectName("imageStudioTemplateSaveButton")
self.template_delete_button = QPushButton("删除")
self.template_delete_button.setObjectName("imageStudioTemplateDeleteButton")
template_layout.addWidget(self.template_combo, 0, 0, 1, 2)
template_layout.addWidget(self.template_new_button, 0, 2)
template_layout.addWidget(self.template_rename_button, 1, 0)
template_layout.addWidget(self.template_save_button, 1, 1)
template_layout.addWidget(self.template_delete_button, 1, 2)
template_label = QLabel("提示词模板")
template_label.setObjectName("imageStudioMutedLabel")
template_layout.addWidget(template_label, 0, 0, 1, 3)
template_layout.addWidget(self.template_combo, 1, 0, 1, 3)
template_layout.addWidget(self.template_new_button, 2, 0)
template_layout.addWidget(self.template_rename_button, 2, 1)
template_layout.addWidget(self.template_save_button, 2, 2)
template_layout.addWidget(self.template_delete_button, 3, 2)
layout.addLayout(template_layout)
self.prompt_edit = QPlainTextEdit()
@@ -417,6 +484,10 @@ class ImageStudioTab(QWidget):
self.prompt_edit.setPlaceholderText("输入完整图片生成提示词")
self.prompt_edit.setMinimumHeight(160)
layout.addWidget(self.prompt_edit, 2)
prompt_hint = QLabel("程序原样提交完整提示词,不自动拆分,也不自动追加动作词。")
prompt_hint.setObjectName("imageStudioMutedLabel")
prompt_hint.setWordWrap(True)
layout.addWidget(prompt_hint)
form = QFormLayout()
self.job_type_combo = QComboBox()
@@ -440,9 +511,13 @@ class ImageStudioTab(QWidget):
self.billing_label.setObjectName("imageStudioBillingLabel")
self.billing_label.setWordWrap(True)
layout.addWidget(self.billing_label)
self.generation_guard_label = QLabel("不会在失败后静默切换生成来源。")
self.generation_guard_label.setObjectName("imageStudioMutedLabel")
self.generation_guard_label.setWordWrap(True)
layout.addWidget(self.generation_guard_label)
action_layout = QHBoxLayout()
self.start_button = QPushButton("开始生成")
self.start_button = QPushButton("生成 4 张主图")
self.start_button.setObjectName("imageStudioStartButton")
self.resume_button = QPushButton("继续查询任务")
self.resume_button.setObjectName("imageStudioResumeButton")
@@ -476,10 +551,14 @@ class ImageStudioTab(QWidget):
main_panel = QWidget()
main_layout = QVBoxLayout(main_panel)
main_layout.setContentsMargins(0, 0, 0, 0)
final_title = QLabel("终选与排序")
final_title.setObjectName("imageStudioSectionTitle")
main_layout.addWidget(final_title)
self.main_selection_label = QLabel("主图终选 0/9")
self.main_selection_label.setObjectName("imageStudioMainSelectionLabel")
self.main_selection_list = ImageStudioSelectionList("main", self)
self.main_selection_list.setObjectName("imageStudioMainSelectionList")
self.main_selection_list.setIconSize(QSize(48, 48))
self.main_selection_list.setMinimumHeight(96)
main_layout.addWidget(self.main_selection_label)
main_layout.addWidget(self.main_selection_list)
@@ -487,19 +566,22 @@ class ImageStudioTab(QWidget):
detail_panel = QWidget()
detail_layout = QVBoxLayout(detail_panel)
detail_layout.setContentsMargins(0, 0, 0, 0)
detail_layout.addWidget(QLabel(" "))
self.detail_selection_label = QLabel("详情图终选 0/12")
self.detail_selection_label.setObjectName("imageStudioDetailSelectionLabel")
self.detail_selection_list = ImageStudioSelectionList("detail", self)
self.detail_selection_list.setObjectName("imageStudioDetailSelectionList")
self.detail_selection_list.setIconSize(QSize(42, 42))
self.detail_selection_list.setMinimumHeight(96)
detail_layout.addWidget(self.detail_selection_label)
detail_layout.addWidget(self.detail_selection_list)
action_panel = QWidget()
action_layout = QVBoxLayout(action_panel)
action_layout.setContentsMargins(0, 0, 0, 0)
self.export_button = QPushButton("导出终选")
action_layout.addWidget(QLabel(" "))
self.export_button = QPushButton("导出到文件夹")
self.export_button.setObjectName("imageStudioExportButton")
self.export_hint_label = QLabel("可部分导出,不要求主图/详情图满额")
self.export_hint_label = QLabel("可部分导出;文件名按终选顺序连续,目标目录存在时可覆盖或新建时间目录。")
self.export_hint_label.setObjectName("imageStudioExportHintLabel")
self.export_hint_label.setWordWrap(True)
action_layout.addWidget(self.export_button)
@@ -510,6 +592,63 @@ class ImageStudioTab(QWidget):
layout.addWidget(action_panel, 0)
return panel
def _apply_workspace_style(self):
self.setStyleSheet(
"""
#imageStudioTopBar,
#imageStudioProjectPanel,
#imageStudioPoolPanel,
#imageStudioGenerationPanel,
#imageStudioFinalPanel {
background: #ffffff;
border: 1px solid #d8dee4;
border-radius: 6px;
}
#imageStudioTopBar {
background: #f6f8fa;
}
#imageStudioSectionTitle {
color: #202938;
font-weight: 600;
}
#imageStudioMutedLabel,
#imageStudioOriginalHintLabel,
#imageStudioAutosaveLabel,
#imageStudioWorkspaceEmptyLabel,
#imageStudioExportHintLabel {
color: #6e7781;
}
#imageStudioCurrentProjectLabel {
color: #24292f;
font-weight: 600;
}
#imageStudioSourcePreview {
border: 1px dashed #bfc8d3;
border-radius: 6px;
background: #fbfcfe;
color: #6e7781;
}
#imageStudioBillingLabel {
border: 1px solid #b9d7fa;
border-radius: 5px;
padding: 6px;
background: #eef6ff;
color: #24292f;
}
QTableWidget#imageStudioOriginalTable,
QTableWidget#imageStudioPoolTable,
QListWidget#imageStudioMainSelectionList,
QListWidget#imageStudioDetailSelectionList {
border: 1px solid #d8dee4;
border-radius: 5px;
background: #fbfcfe;
gridline-color: #eaeef2;
selection-background-color: #ddf4ff;
selection-color: #24292f;
}
"""
)
def _connect_signals(self):
self.open_project_button.clicked.connect(self.open_project)
self.pull_images_button.clicked.connect(self.pull_main_images)
@@ -526,6 +665,8 @@ class ImageStudioTab(QWidget):
self.template_save_button.clicked.connect(self.save_template)
self.template_delete_button.clicked.connect(self.delete_template)
self.prompt_edit.textChanged.connect(self._save_project_prompt)
self.job_type_combo.currentIndexChanged.connect(self._update_generation_action_text)
self.count_spin.valueChanged.connect(self._update_generation_action_text)
self.start_button.clicked.connect(self.start_generation)
self.resume_button.clicked.connect(self.resume_generation_jobs)
self.stop_button.clicked.connect(self.stop_generation)
@@ -558,6 +699,8 @@ class ImageStudioTab(QWidget):
self._select_project(self.projects[0].id)
elif self.current_project is not None:
self._select_project(self.current_project.id, quiet=True)
else:
self._refresh_project_summary()
def _fill_project_table(self):
self.project_table.setRowCount(len(self.projects))
@@ -572,6 +715,7 @@ class ImageStudioTab(QWidget):
item = QTableWidgetItem(str(value or ""))
item.setData(Qt.UserRole, int(project.id))
self.project_table.setItem(row, column, item)
self.project_table.setRowHeight(row, 44)
self.project_table.resizeColumnsToContents()
def open_project(self, checked=False):
@@ -591,21 +735,21 @@ class ImageStudioTab(QWidget):
path=self.db_path,
)
except Exception as exc:
self._message("打开项目失败", str(exc))
self._status(f"打开AI工场项目失败:{exc}", "danger")
self._message("打开商品项目失败", str(exc))
self._status(f"打开AI工场商品项目失败:{exc}", "danger")
return
self.current_project = project
self.item_id_edit.setText(project.item_id)
self._set_account_combo(project.account_alias)
self.refresh_projects()
self._select_project(project.id)
self._status("AI工场项目已打开", "success")
self._status("AI工场商品项目已打开", "success")
def pull_main_images(self, checked=False):
alias = str(self.account_combo.currentData() or "").strip()
item_id = self.item_id_edit.text().strip()
if not alias or not item_id:
self._message("项目未打开", "请先选择账号和商品ID并打开项目。")
self._message("商品项目未打开", "请先选择账号和商品ID并打开商品项目。")
return
worker = ImageStudioPullImagesWorker(
alias,
@@ -616,11 +760,11 @@ class ImageStudioTab(QWidget):
worker.log.connect(self._append_log)
worker.finished.connect(self._on_pull_finished)
worker.failed.connect(self._on_worker_failed)
self._start_worker(worker, "AI工场拉取主图")
self._append_log("[AI工场] 拉取主图开始")
self._start_worker(worker, "AI工场拉取蝦皮主图")
self._append_log("[AI工场] 拉取蝦皮主图开始")
def _on_pull_finished(self, summary):
if self._handle_finished_error(summary, "拉取主图失败"):
if self._handle_finished_error(summary, "拉取蝦皮主图失败"):
return
project = summary.get("project")
if project is not None:
@@ -635,7 +779,7 @@ class ImageStudioTab(QWidget):
def open_project_folder(self, checked=False):
if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。")
self._message("商品项目未打开", "请先打开一个AI工场商品项目。")
return
dirs = image_studio.default_project_image_dirs(self.current_project, self.config)
try:
@@ -674,8 +818,9 @@ class ImageStudioTab(QWidget):
self.selected_source_asset_id = None
self.refresh_project_assets()
self._sync_project_selection(project.id)
self._refresh_project_summary()
if not quiet:
self._status(f"当前AI工场项目:{project.account_alias} / {project.item_id}", "muted")
self._status(f"当前AI工场商品项目:{project.account_alias} / {project.item_id}", "muted")
def _sync_project_selection(self, project_id):
for row in range(self.project_table.rowCount()):
@@ -694,6 +839,7 @@ class ImageStudioTab(QWidget):
self.assets = image_studio.list_assets(self.current_project.id, path=self.db_path)
self.jobs = self._list_project_jobs(self.current_project.id)
self.selections = image_studio.list_selections(self.current_project.id, path=self.db_path)
self._refresh_project_summary()
self._fill_original_table()
self._fill_pool_table()
self._refresh_selection_labels()
@@ -703,15 +849,23 @@ class ImageStudioTab(QWidget):
originals = [asset for asset in self.assets if asset.kind == image_studio.ASSET_KIND_ORIGINAL]
self.original_table.setRowCount(len(originals))
for row, asset in enumerate(originals):
order = asset.source_order or row + 1
values = [
str(asset.source_order or row + 1),
f"主图 #{order}",
_asset_status_text(asset),
asset.remote_url or "",
]
for column, value in enumerate(values):
item = QTableWidgetItem(str(value or ""))
item.setData(Qt.UserRole, {"type": "asset", "asset_id": int(asset.id)})
if column == 0:
item.setIcon(_asset_icon(asset, "原"))
item.setToolTip("单击下载并加入照片池,双击查看大图")
self.original_table.setItem(row, column, item)
self.original_table.setRowHeight(row, 84)
if originals:
self.original_table.setColumnWidth(0, 132)
self.original_table.setColumnWidth(1, 96)
self.original_table.resizeColumnsToContents()
def _fill_pool_table(self):
@@ -730,7 +884,7 @@ class ImageStudioTab(QWidget):
if row_type == "asset":
draggable = _asset_is_usable(obj)
values = [
_asset_badge(obj.kind),
f"{_asset_badge(obj.kind)} #{obj.id}",
obj.aspect_ratio or "未知",
_asset_status_text(obj),
_source_text(obj, self.assets),
@@ -749,7 +903,24 @@ class ImageStudioTab(QWidget):
for column, value in enumerate(values):
item = QTableWidgetItem(str(value or ""))
item.setData(Qt.UserRole, data)
if row_type == "asset" and column == 0:
item.setIcon(_asset_icon(obj, _asset_badge(obj.kind)))
item.setToolTip("单击设为源图,双击查看大图,可拖入终选槽。")
if row_type == "job" and column == 0:
item.setIcon(_job_icon(obj.status))
item.setToolTip("cmhub 生图任务已保存,可继续查询。")
if row_type == "asset" and obj.id == self.selected_source_asset_id:
item.setBackground(QColor("#eaf2ff"))
if row_type == "job" and obj.status in {"failed", "expired"}:
item.setForeground(_qcolor(COLOR_DANGER))
elif row_type == "job" and obj.status in {"pending", "submitted", "running"}:
item.setForeground(_qcolor(COLOR_WARNING))
self.pool_table.setItem(row, column, item)
self.pool_table.setRowHeight(row, 104)
if rows:
self.pool_table.setColumnWidth(0, 150)
self.pool_table.setColumnWidth(1, 68)
self.pool_table.setColumnWidth(2, 170)
self.pool_table.resizeColumnsToContents()
def _selection_asset_ids(self, selection_type):
@@ -888,8 +1059,31 @@ class ImageStudioTab(QWidget):
asset = self._asset_by_id(self.selected_source_asset_id)
if asset is None:
self.source_label.setText("源图:未选择")
self.source_preview_label.setPixmap(QPixmap())
self.source_preview_label.setText("未选择源图")
return
self.source_label.setText(f"源图:{_asset_badge(asset.kind)} #{asset.id}")
self.source_preview_label.setText("")
self.source_preview_label.setPixmap(_asset_pixmap(asset, QSize(86, 86)))
def _refresh_project_summary(self):
if self.current_project is None:
self.current_project_label.setText("未打开商品项目")
self.autosave_label.setText("选择账号并输入商品ID后打开")
self.workspace_empty_label.setText("选择账号并输入商品ID,点击「打开商品项目」开始。")
return
alias = self.current_project.account_alias
item_id = self.current_project.item_id
self.current_project_label.setText(f"{alias} · {item_id}")
self.autosave_label.setText("项目已自动保存")
self.workspace_empty_label.setText(
f"当前商品:{alias} / {item_id}。按顺序拉取蝦皮主图、选择源图、生成并拖入终选。"
)
def _update_generation_action_text(self, *args):
count = self.count_spin.value() if hasattr(self, "count_spin") else 0
label = self.job_type_combo.currentText() if hasattr(self, "job_type_combo") else "图片"
self.start_button.setText(f"生成 {count} 张{label}")
def _open_preview(self, asset):
dialog = ImageStudioPreviewDialog(asset, self)
@@ -1023,7 +1217,7 @@ class ImageStudioTab(QWidget):
def start_generation(self, checked=False):
if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。")
self._message("商品项目未打开", "请先打开一个AI工场商品项目。")
return
source = self._asset_by_id(self.selected_source_asset_id)
if source is None:
@@ -1066,7 +1260,7 @@ class ImageStudioTab(QWidget):
def resume_generation_jobs(self, checked=False):
if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。")
self._message("商品项目未打开", "请先打开一个AI工场商品项目。")
return
resumable = image_studio.list_resumable_jobs(
path=self.db_path,
@@ -1095,7 +1289,7 @@ class ImageStudioTab(QWidget):
def export_selections(self, checked=False):
if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。")
self._message("商品项目未打开", "请先打开一个AI工场商品项目。")
return
main_count = len(self._selection_asset_ids("main"))
detail_count = len(self._selection_asset_ids("detail"))
@@ -1290,10 +1484,8 @@ class ImageStudioTab(QWidget):
def _fill_selection_list(self, widget, selection_type):
widget.clear()
for index, selection in enumerate(
[item for item in self.selections if item.selection_type == selection_type],
start=1,
):
selected = [item for item in self.selections if item.selection_type == selection_type]
for index, selection in enumerate(selected, start=1):
asset = self._asset_by_id(selection.asset_id)
if asset is None:
text = f"{index}. 缺失照片 #{selection.asset_id}"
@@ -1305,6 +1497,7 @@ class ImageStudioTab(QWidget):
continue
text = f"{index}. {_asset_badge(asset.kind)} #{asset.id} · {asset.aspect_ratio or '未知比例'}"
item = QListWidgetItem(text)
item.setIcon(_asset_icon(asset, str(index)))
item.setData(Qt.UserRole, int(asset.id))
tooltip = _selection_tooltip(selection_type, asset)
item.setToolTip(tooltip)
@@ -1315,6 +1508,15 @@ class ImageStudioTab(QWidget):
item.setForeground(_qcolor(COLOR_MUTED))
item.setToolTip(tooltip + "\n本地文件缺失,不能用于导出。")
widget.addItem(item)
target = self._selection_target_count(selection_type)
for index in range(len(selected) + 1, target + 1):
item = QListWidgetItem(f"{index}. 空位")
item.setIcon(QIcon(_placeholder_pixmap("空", QSize(48, 48), "#f6f8fa")))
item.setData(Qt.UserRole, None)
item.setToolTip(f"{_selection_label(selection_type)}空位,可从照片池拖入图片。")
item.setForeground(_qcolor(COLOR_MUTED))
item.setFlags(Qt.NoItemFlags)
widget.addItem(item)
def _asset_from_table_row(self, table, row):
data = self._row_data(table, row)
@@ -1405,6 +1607,69 @@ def _asset_is_usable(asset):
return bool(local_path and os.path.isfile(local_path))
def _asset_icon(asset, fallback_label):
return QIcon(_asset_pixmap(asset, QSize(86, 86), fallback_label=fallback_label))
def _job_icon(status):
label = {
"pending": "排",
"submitted": "提",
"running": "生",
"failed": "败",
"expired": "过",
"cancelled": "停",
}.get(str(status or ""), "任")
color = {
"failed": "#ffebe9",
"expired": "#ffebe9",
"cancelled": "#f6f8fa",
"running": "#fff8c5",
"submitted": "#fff8c5",
"pending": "#f6f8fa",
}.get(str(status or ""), "#f6f8fa")
return QIcon(_placeholder_pixmap(label, QSize(86, 86), color))
def _asset_pixmap(asset, size, fallback_label=None):
path = str(getattr(asset, "local_path", "") or "")
if path and os.path.isfile(path):
image = QImage(path)
if not image.isNull():
scaled = QPixmap.fromImage(image).scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
canvas = _placeholder_pixmap("", size, "#f6f8fa")
painter = QPainter(canvas)
painter.drawPixmap(
(size.width() - scaled.width()) // 2,
(size.height() - scaled.height()) // 2,
scaled,
)
painter.end()
return canvas
label = str(fallback_label or _asset_badge(getattr(asset, "kind", "")) or "图")[:2]
color = {
"original": "#dce9f7",
"generated_main": "#ddf4ff",
"generated_detail": "#dafbe1",
}.get(str(getattr(asset, "kind", "") or ""), "#f6f8fa")
if str(getattr(asset, "status", "") or "") == image_studio.ASSET_STATUS_MISSING:
color = "#ffebe9"
return _placeholder_pixmap(label, size, color)
def _placeholder_pixmap(label, size, color):
pixmap = QPixmap(size)
pixmap.fill(QColor(color))
painter = QPainter(pixmap)
painter.setPen(QColor("#d0d7de"))
painter.drawRect(0, 0, size.width() - 1, size.height() - 1)
if label:
painter.setPen(QColor("#57606a"))
painter.drawText(pixmap.rect(), Qt.AlignCenter, str(label))
painter.end()
return pixmap
def _source_text(asset, assets):
parent_id = getattr(asset, "parent_asset_id", None)
if not parent_id:
+25 -2
View File
@@ -3,7 +3,7 @@ id: T-605
title: AI工场参考 v3 效果图优化信息架构与界面展示
phase: 7
deps: [T-591, T-592, T-593, T-594, T-604]
status: TODO
status: DONE
created: 2026-07-11
---
@@ -192,4 +192,27 @@ git diff --check
## 执行记录
待执行。
- 2026-07-11:已完成。
- 代码:
- `app/gui/tabs/image_studio.py`:⑥AI工场改为顶部操作条 + 左侧商品项目 + 中央蝦皮原主图/照片池 + 右侧生成设置 + 底部终选与排序的工作台结构。
- 顶部入口文案改为「打开商品项目」「拉取蝦皮主图」,并新增当前商品、自动保存状态和未打开项目空状态。
- 原主图和照片池增加稳定缩略图/占位图、图片类型与任务状态图标、源图高亮,保留单击下载/选源图、双击预览、右键移除和拖入终选能力。
- 生成设置区新增源图预览、提示词提交说明、生成来源/扣点提示和「不会在失败后静默切换生成来源」说明;开始按钮文案会随类型和数量变化,如「生成 3 张详情图」。
- 底部终选区改为固定槽位显示:主图 9 个、详情图 12 个;空位只做视觉占位,真实保存顺序仍只来自 SQLite 终选记录。
- 不改 `app/image_studio.py`、`app/editor.py`、cmhub 生图/续查/导出业务逻辑。
- 测试:
- `tests/test_gui.py`:更新 AI工场构建测试,覆盖 v3 关键中文文案、顶部状态、拉取蝦皮主图、导出到文件夹、生成按钮动态文案、缩略图图标和不出现「导入本地图片」。
- `tests/test_gui.py`:更新终选排序测试,覆盖主图 9 槽、详情图 12 槽和空位占位不影响真实终选顺序。
- `tests/test_gui.py`:更新生成日志测试,覆盖选择源图后源图预览显示。
- 验证:
- 主工作区定向验证通过:
- `py -3.10 -m unittest tests.test_gui.GuiTests.test_image_studio_tab_builds_project_pool_and_template_controls tests.test_gui.GuiTests.test_image_studio_final_selection_order_and_guards tests.test_gui.GuiTests.test_image_studio_tab_shows_resume_and_job_billing_status tests.test_gui.GuiTests.test_image_studio_generation_log_uses_cmhub_tier_summary tests.test_gui.GuiTests.test_image_studio_worker_thread_is_kept_until_thread_finished tests.test_gui.GuiTests.test_image_studio_worker_failure_is_handled_once tests.test_gui.GuiTests.test_image_studio_pull_main_images_uses_current_item_input`
- `python -m ruff check app tests main.py`
- `py -3.10 -m compileall app main.py`
- `git diff --check -- app\gui\tabs\image_studio.py tests\test_gui.py docs\tasks\T-605.md`
- 主工作区全量 `py -3.10 -m unittest discover -s tests` 仍因既有未提交默认提示词改动失败 3 个用例,失败内容为 `papa1` 与乱码 `默认` 文件名不一致,不属于 T-605。
- 为满足提交前全绿验证,已在临时干净 worktree `D:\chengma\cmshopee-t605-verify` 只应用 T-605 diff 后通过:
- `python -m ruff check app tests main.py`
- `py -3.10 -m compileall app main.py`
- `py -3.10 -m unittest discover -s tests`(378 tests)
- `git diff --check -- app\gui\tabs\image_studio.py tests\test_gui.py docs\tasks\T-605.md`
+31 -3
View File
@@ -512,8 +512,23 @@ class GuiTests(TempDirMixin, unittest.TestCase):
"导入本地图片",
" ".join(button.text() for button in tab.findChildren(gui.QPushButton)),
)
self.assertEqual("导出终选", tab.export_button.text())
self.assertEqual("打开商品项目", tab.open_project_button.text())
self.assertEqual("拉取蝦皮主图", tab.pull_images_button.text())
self.assertEqual("导出到文件夹", tab.export_button.text())
self.assertIn("可部分导出", tab.export_hint_label.text())
self.assertIn("文件名按终选顺序连续", tab.export_hint_label.text())
self.assertEqual("未打开商品项目", tab.current_project_label.text())
self.assertIn("打开商品项目", tab.workspace_empty_label.text())
self.assertEqual("生成 4 张主图", tab.start_button.text())
tab.count_spin.setValue(3)
tab.job_type_combo.setCurrentIndex(tab.job_type_combo.findData("detail"))
self.assertEqual("生成 3 张详情图", tab.start_button.text())
label_texts = "\n".join(label.text() for label in tab.findChildren(gui.QLabel))
self.assertIn("商品项目", label_texts)
self.assertIn("蝦皮原主图", label_texts)
self.assertIn("照片池", label_texts)
self.assertIn("生成设置", label_texts)
self.assertIn("终选与排序", label_texts)
self.assertEqual("完整提示词", prompts.load_image_studio_template("工场模板", prompts_dir))
template_index = tab.template_combo.findData("工场模板")
self.assertGreaterEqual(template_index, 0)
@@ -526,6 +541,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsNotNone(tab.current_project)
self.assertEqual("alias-a", tab.current_project.account_alias)
self.assertEqual("51100639510", tab.current_project.item_id)
self.assertIn("alias-a", tab.current_project_label.text())
self.assertIn("51100639510", tab.current_project_label.text())
self.assertEqual("项目已自动保存", tab.autosave_label.text())
self.assertEqual(1, tab.project_table.rowCount())
original = image_studio.sync_original_asset_urls(
@@ -536,10 +554,12 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.refresh_project_assets()
self.assertEqual(1, tab.original_table.rowCount())
self.assertEqual("1", tab.original_table.item(0, 0).text())
self.assertEqual("主图 #1", tab.original_table.item(0, 0).text())
self.assertFalse(tab.original_table.item(0, 0).icon().isNull())
self.assertEqual("远程待下载", tab.original_table.item(0, 1).text())
self.assertEqual(1, tab.pool_table.rowCount())
self.assertEqual("原图", tab.pool_table.item(0, 0).text())
self.assertEqual(f"原图 #{original.id}", tab.pool_table.item(0, 0).text())
self.assertFalse(tab.pool_table.item(0, 0).icon().isNull())
self.assertEqual("远程待下载", tab.pool_table.item(0, 2).text())
self.assertEqual(original.id, tab.pool_table.item(0, 0).data(gui.Qt.UserRole)["asset_id"])
@@ -584,7 +604,10 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual([second.id, first.id], [
tab.main_selection_list.item(row).data(gui.Qt.UserRole)
for row in range(tab.main_selection_list.count())
if tab.main_selection_list.item(row).data(gui.Qt.UserRole) is not None
])
self.assertEqual(9, tab.main_selection_list.count())
self.assertIn("空位", tab.main_selection_list.item(2).text())
self.assertEqual("#fff8c5", tab.main_selection_list.item(0).background().color().name())
self.assertFalse(tab.add_asset_to_selection("main", first.id))
@@ -592,6 +615,8 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertTrue(tab.add_asset_to_selection("detail", first.id))
detail = image_studio.list_selections(project.id, "detail", path=cfg["db_path"])
self.assertEqual([first.id], [selection.asset_id for selection in detail])
self.assertEqual(12, tab.detail_selection_list.count())
self.assertIn("空位", tab.detail_selection_list.item(1).text())
self.assertTrue(tab.move_selection_asset("main", 1, 0))
main = image_studio.list_selections(project.id, "main", path=cfg["db_path"])
@@ -674,6 +699,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab._select_project(project.id)
tab._select_source_asset(source)
tab.prompt_edit.setPlainText("生成商品主图")
self.assertIn(f"原图 #{source.id}", tab.source_label.text())
self.assertEqual("", tab.source_preview_label.text())
self.assertFalse(tab.source_preview_label.pixmap().isNull())
class FakeStudioWorker:
def __init__(self):