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 import os
from PySide6.QtCore import QMimeData from PySide6.QtCore import QMimeData, QSize
from PySide6.QtWidgets import QListWidget, QListWidgetItem from PySide6.QtWidgets import QListWidget, QListWidgetItem
from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, prompts from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, prompts
@@ -172,6 +172,8 @@ class ImageStudioSelectionList(QListWidget):
if item is None: if item is None:
return return
asset_id = item.data(Qt.UserRole) asset_id = item.data(Qt.UserRole)
if asset_id is None:
return
self.tab.remove_asset_from_selection(self.selection_type, asset_id) self.tab.remove_asset_from_selection(self.selection_type, asset_id)
def _show_context_menu(self, position): def _show_context_menu(self, position):
@@ -188,7 +190,8 @@ class ImageStudioSelectionList(QListWidget):
def _row_for_asset(self, asset_id): def _row_for_asset(self, asset_id):
for row in range(self.count()): for row in range(self.count()):
item = self.item(row) 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 row
return None return None
@@ -289,6 +292,8 @@ class ImageStudioTab(QWidget):
root.setContentsMargins(10, 8, 10, 8) root.setContentsMargins(10, 8, 10, 8)
root.setSpacing(8) root.setSpacing(8)
root.addWidget(self._build_top_bar(), 0)
splitter = QSplitter(Qt.Horizontal) splitter = QSplitter(Qt.Horizontal)
splitter.setObjectName("imageStudioMainSplitter") splitter.setObjectName("imageStudioMainSplitter")
splitter.addWidget(self._build_project_panel()) splitter.addWidget(self._build_project_panel())
@@ -299,6 +304,49 @@ class ImageStudioTab(QWidget):
splitter.setStretchFactor(2, 2) splitter.setStretchFactor(2, 2)
root.addWidget(splitter, 1) root.addWidget(splitter, 1)
root.addWidget(self._build_final_panel(), 0) 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): def _build_project_panel(self):
panel = QWidget() panel = QWidget()
@@ -307,28 +355,13 @@ class ImageStudioTab(QWidget):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8) layout.setSpacing(8)
project_form = QFormLayout() title = QLabel("商品项目")
project_form.setLabelAlignment(Qt.AlignRight) title.setObjectName("imageStudioSectionTitle")
self.account_combo = QComboBox() hint = QLabel("点击切换项目,项目会自动保存")
self.account_combo.setObjectName("imageStudioAccountCombo") hint.setObjectName("imageStudioMutedLabel")
self.item_id_edit = QLineEdit() hint.setWordWrap(True)
self.item_id_edit.setObjectName("imageStudioItemIdEdit") layout.addWidget(title)
self.item_id_edit.setPlaceholderText("商品ID") layout.addWidget(hint)
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)
self.project_table = QTableWidget(0, len(self.PROJECT_COLUMNS)) self.project_table = QTableWidget(0, len(self.PROJECT_COLUMNS))
self.project_table.setObjectName("imageStudioProjectList") self.project_table.setObjectName("imageStudioProjectList")
@@ -338,6 +371,7 @@ class ImageStudioTab(QWidget):
self.project_table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.project_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.project_table.horizontalHeader().setStretchLastSection(True) self.project_table.horizontalHeader().setStretchLastSection(True)
self.project_table.verticalHeader().setVisible(False) self.project_table.verticalHeader().setVisible(False)
self.project_table.setMinimumWidth(220)
layout.addWidget(self.project_table, 1) layout.addWidget(self.project_table, 1)
return panel return panel
@@ -348,8 +382,15 @@ class ImageStudioTab(QWidget):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8) 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 = QHBoxLayout()
original_header.addWidget(QLabel("蝦皮原主图")) original_title = QLabel("蝦皮原主图")
original_title.setObjectName("imageStudioSectionTitle")
original_header.addWidget(original_title)
original_header.addStretch(1) original_header.addStretch(1)
self.original_hint_label = QLabel("单击下载并加入照片池,双击查看大图") self.original_hint_label = QLabel("单击下载并加入照片池,双击查看大图")
self.original_hint_label.setObjectName("imageStudioOriginalHintLabel") self.original_hint_label.setObjectName("imageStudioOriginalHintLabel")
@@ -364,10 +405,14 @@ class ImageStudioTab(QWidget):
self.original_table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.original_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.original_table.horizontalHeader().setStretchLastSection(True) self.original_table.horizontalHeader().setStretchLastSection(True)
self.original_table.verticalHeader().setVisible(False) self.original_table.verticalHeader().setVisible(False)
self.original_table.setIconSize(QSize(72, 72))
self.original_table.setAlternatingRowColors(False)
layout.addWidget(self.original_table, 1) layout.addWidget(self.original_table, 1)
pool_header = QHBoxLayout() pool_header = QHBoxLayout()
pool_header.addWidget(QLabel("照片池")) pool_title = QLabel("照片池")
pool_title.setObjectName("imageStudioSectionTitle")
pool_header.addWidget(pool_title)
pool_header.addStretch(1) pool_header.addStretch(1)
self.source_label = QLabel("源图:未选择") self.source_label = QLabel("源图:未选择")
self.source_label.setObjectName("imageStudioSourceLabel") self.source_label.setObjectName("imageStudioSourceLabel")
@@ -384,6 +429,8 @@ class ImageStudioTab(QWidget):
self.pool_table.setContextMenuPolicy(Qt.CustomContextMenu) self.pool_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.pool_table.horizontalHeader().setStretchLastSection(True) self.pool_table.horizontalHeader().setStretchLastSection(True)
self.pool_table.verticalHeader().setVisible(False) self.pool_table.verticalHeader().setVisible(False)
self.pool_table.setIconSize(QSize(86, 86))
self.pool_table.setAlternatingRowColors(False)
layout.addWidget(self.pool_table, 2) layout.addWidget(self.pool_table, 2)
return panel return panel
@@ -394,7 +441,24 @@ class ImageStudioTab(QWidget):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8) 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 = QGridLayout()
template_layout.setSpacing(6)
self.template_combo = QComboBox() self.template_combo = QComboBox()
self.template_combo.setObjectName("imageStudioTemplateCombo") self.template_combo.setObjectName("imageStudioTemplateCombo")
self.template_new_button = QPushButton("新建") self.template_new_button = QPushButton("新建")
@@ -405,11 +469,14 @@ class ImageStudioTab(QWidget):
self.template_save_button.setObjectName("imageStudioTemplateSaveButton") self.template_save_button.setObjectName("imageStudioTemplateSaveButton")
self.template_delete_button = QPushButton("删除") self.template_delete_button = QPushButton("删除")
self.template_delete_button.setObjectName("imageStudioTemplateDeleteButton") self.template_delete_button.setObjectName("imageStudioTemplateDeleteButton")
template_layout.addWidget(self.template_combo, 0, 0, 1, 2) template_label = QLabel("提示词模板")
template_layout.addWidget(self.template_new_button, 0, 2) template_label.setObjectName("imageStudioMutedLabel")
template_layout.addWidget(self.template_rename_button, 1, 0) template_layout.addWidget(template_label, 0, 0, 1, 3)
template_layout.addWidget(self.template_save_button, 1, 1) template_layout.addWidget(self.template_combo, 1, 0, 1, 3)
template_layout.addWidget(self.template_delete_button, 1, 2) 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) layout.addLayout(template_layout)
self.prompt_edit = QPlainTextEdit() self.prompt_edit = QPlainTextEdit()
@@ -417,6 +484,10 @@ class ImageStudioTab(QWidget):
self.prompt_edit.setPlaceholderText("输入完整图片生成提示词") self.prompt_edit.setPlaceholderText("输入完整图片生成提示词")
self.prompt_edit.setMinimumHeight(160) self.prompt_edit.setMinimumHeight(160)
layout.addWidget(self.prompt_edit, 2) layout.addWidget(self.prompt_edit, 2)
prompt_hint = QLabel("程序原样提交完整提示词,不自动拆分,也不自动追加动作词。")
prompt_hint.setObjectName("imageStudioMutedLabel")
prompt_hint.setWordWrap(True)
layout.addWidget(prompt_hint)
form = QFormLayout() form = QFormLayout()
self.job_type_combo = QComboBox() self.job_type_combo = QComboBox()
@@ -440,9 +511,13 @@ class ImageStudioTab(QWidget):
self.billing_label.setObjectName("imageStudioBillingLabel") self.billing_label.setObjectName("imageStudioBillingLabel")
self.billing_label.setWordWrap(True) self.billing_label.setWordWrap(True)
layout.addWidget(self.billing_label) 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() action_layout = QHBoxLayout()
self.start_button = QPushButton("开始生成") self.start_button = QPushButton("生成 4 张主图")
self.start_button.setObjectName("imageStudioStartButton") self.start_button.setObjectName("imageStudioStartButton")
self.resume_button = QPushButton("继续查询任务") self.resume_button = QPushButton("继续查询任务")
self.resume_button.setObjectName("imageStudioResumeButton") self.resume_button.setObjectName("imageStudioResumeButton")
@@ -476,10 +551,14 @@ class ImageStudioTab(QWidget):
main_panel = QWidget() main_panel = QWidget()
main_layout = QVBoxLayout(main_panel) main_layout = QVBoxLayout(main_panel)
main_layout.setContentsMargins(0, 0, 0, 0) 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 = QLabel("主图终选 0/9")
self.main_selection_label.setObjectName("imageStudioMainSelectionLabel") self.main_selection_label.setObjectName("imageStudioMainSelectionLabel")
self.main_selection_list = ImageStudioSelectionList("main", self) self.main_selection_list = ImageStudioSelectionList("main", self)
self.main_selection_list.setObjectName("imageStudioMainSelectionList") self.main_selection_list.setObjectName("imageStudioMainSelectionList")
self.main_selection_list.setIconSize(QSize(48, 48))
self.main_selection_list.setMinimumHeight(96) self.main_selection_list.setMinimumHeight(96)
main_layout.addWidget(self.main_selection_label) main_layout.addWidget(self.main_selection_label)
main_layout.addWidget(self.main_selection_list) main_layout.addWidget(self.main_selection_list)
@@ -487,19 +566,22 @@ class ImageStudioTab(QWidget):
detail_panel = QWidget() detail_panel = QWidget()
detail_layout = QVBoxLayout(detail_panel) detail_layout = QVBoxLayout(detail_panel)
detail_layout.setContentsMargins(0, 0, 0, 0) detail_layout.setContentsMargins(0, 0, 0, 0)
detail_layout.addWidget(QLabel(" "))
self.detail_selection_label = QLabel("详情图终选 0/12") self.detail_selection_label = QLabel("详情图终选 0/12")
self.detail_selection_label.setObjectName("imageStudioDetailSelectionLabel") self.detail_selection_label.setObjectName("imageStudioDetailSelectionLabel")
self.detail_selection_list = ImageStudioSelectionList("detail", self) self.detail_selection_list = ImageStudioSelectionList("detail", self)
self.detail_selection_list.setObjectName("imageStudioDetailSelectionList") self.detail_selection_list.setObjectName("imageStudioDetailSelectionList")
self.detail_selection_list.setIconSize(QSize(42, 42))
self.detail_selection_list.setMinimumHeight(96) self.detail_selection_list.setMinimumHeight(96)
detail_layout.addWidget(self.detail_selection_label) detail_layout.addWidget(self.detail_selection_label)
detail_layout.addWidget(self.detail_selection_list) detail_layout.addWidget(self.detail_selection_list)
action_panel = QWidget() action_panel = QWidget()
action_layout = QVBoxLayout(action_panel) action_layout = QVBoxLayout(action_panel)
action_layout.setContentsMargins(0, 0, 0, 0) 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_button.setObjectName("imageStudioExportButton")
self.export_hint_label = QLabel("可部分导出,不要求主图/详情图满额") self.export_hint_label = QLabel("可部分导出;文件名按终选顺序连续,目标目录存在时可覆盖或新建时间目录。")
self.export_hint_label.setObjectName("imageStudioExportHintLabel") self.export_hint_label.setObjectName("imageStudioExportHintLabel")
self.export_hint_label.setWordWrap(True) self.export_hint_label.setWordWrap(True)
action_layout.addWidget(self.export_button) action_layout.addWidget(self.export_button)
@@ -510,6 +592,63 @@ class ImageStudioTab(QWidget):
layout.addWidget(action_panel, 0) layout.addWidget(action_panel, 0)
return panel 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): def _connect_signals(self):
self.open_project_button.clicked.connect(self.open_project) self.open_project_button.clicked.connect(self.open_project)
self.pull_images_button.clicked.connect(self.pull_main_images) 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_save_button.clicked.connect(self.save_template)
self.template_delete_button.clicked.connect(self.delete_template) self.template_delete_button.clicked.connect(self.delete_template)
self.prompt_edit.textChanged.connect(self._save_project_prompt) 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.start_button.clicked.connect(self.start_generation)
self.resume_button.clicked.connect(self.resume_generation_jobs) self.resume_button.clicked.connect(self.resume_generation_jobs)
self.stop_button.clicked.connect(self.stop_generation) self.stop_button.clicked.connect(self.stop_generation)
@@ -558,6 +699,8 @@ class ImageStudioTab(QWidget):
self._select_project(self.projects[0].id) self._select_project(self.projects[0].id)
elif self.current_project is not None: elif self.current_project is not None:
self._select_project(self.current_project.id, quiet=True) self._select_project(self.current_project.id, quiet=True)
else:
self._refresh_project_summary()
def _fill_project_table(self): def _fill_project_table(self):
self.project_table.setRowCount(len(self.projects)) self.project_table.setRowCount(len(self.projects))
@@ -572,6 +715,7 @@ class ImageStudioTab(QWidget):
item = QTableWidgetItem(str(value or "")) item = QTableWidgetItem(str(value or ""))
item.setData(Qt.UserRole, int(project.id)) item.setData(Qt.UserRole, int(project.id))
self.project_table.setItem(row, column, item) self.project_table.setItem(row, column, item)
self.project_table.setRowHeight(row, 44)
self.project_table.resizeColumnsToContents() self.project_table.resizeColumnsToContents()
def open_project(self, checked=False): def open_project(self, checked=False):
@@ -591,21 +735,21 @@ class ImageStudioTab(QWidget):
path=self.db_path, path=self.db_path,
) )
except Exception as exc: except Exception as exc:
self._message("打开项目失败", str(exc)) self._message("打开商品项目失败", str(exc))
self._status(f"打开AI工场项目失败:{exc}", "danger") self._status(f"打开AI工场商品项目失败:{exc}", "danger")
return return
self.current_project = project self.current_project = project
self.item_id_edit.setText(project.item_id) self.item_id_edit.setText(project.item_id)
self._set_account_combo(project.account_alias) self._set_account_combo(project.account_alias)
self.refresh_projects() self.refresh_projects()
self._select_project(project.id) self._select_project(project.id)
self._status("AI工场项目已打开", "success") self._status("AI工场商品项目已打开", "success")
def pull_main_images(self, checked=False): def pull_main_images(self, checked=False):
alias = str(self.account_combo.currentData() or "").strip() alias = str(self.account_combo.currentData() or "").strip()
item_id = self.item_id_edit.text().strip() item_id = self.item_id_edit.text().strip()
if not alias or not item_id: if not alias or not item_id:
self._message("项目未打开", "请先选择账号和商品ID并打开项目。") self._message("商品项目未打开", "请先选择账号和商品ID并打开商品项目。")
return return
worker = ImageStudioPullImagesWorker( worker = ImageStudioPullImagesWorker(
alias, alias,
@@ -616,11 +760,11 @@ class ImageStudioTab(QWidget):
worker.log.connect(self._append_log) worker.log.connect(self._append_log)
worker.finished.connect(self._on_pull_finished) worker.finished.connect(self._on_pull_finished)
worker.failed.connect(self._on_worker_failed) worker.failed.connect(self._on_worker_failed)
self._start_worker(worker, "AI工场拉取主图") self._start_worker(worker, "AI工场拉取蝦皮主图")
self._append_log("[AI工场] 拉取主图开始") self._append_log("[AI工场] 拉取蝦皮主图开始")
def _on_pull_finished(self, summary): def _on_pull_finished(self, summary):
if self._handle_finished_error(summary, "拉取主图失败"): if self._handle_finished_error(summary, "拉取蝦皮主图失败"):
return return
project = summary.get("project") project = summary.get("project")
if project is not None: if project is not None:
@@ -635,7 +779,7 @@ class ImageStudioTab(QWidget):
def open_project_folder(self, checked=False): def open_project_folder(self, checked=False):
if self.current_project is None: if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。") self._message("商品项目未打开", "请先打开一个AI工场商品项目。")
return return
dirs = image_studio.default_project_image_dirs(self.current_project, self.config) dirs = image_studio.default_project_image_dirs(self.current_project, self.config)
try: try:
@@ -674,8 +818,9 @@ class ImageStudioTab(QWidget):
self.selected_source_asset_id = None self.selected_source_asset_id = None
self.refresh_project_assets() self.refresh_project_assets()
self._sync_project_selection(project.id) self._sync_project_selection(project.id)
self._refresh_project_summary()
if not quiet: 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): def _sync_project_selection(self, project_id):
for row in range(self.project_table.rowCount()): 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.assets = image_studio.list_assets(self.current_project.id, path=self.db_path)
self.jobs = self._list_project_jobs(self.current_project.id) 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.selections = image_studio.list_selections(self.current_project.id, path=self.db_path)
self._refresh_project_summary()
self._fill_original_table() self._fill_original_table()
self._fill_pool_table() self._fill_pool_table()
self._refresh_selection_labels() 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] originals = [asset for asset in self.assets if asset.kind == image_studio.ASSET_KIND_ORIGINAL]
self.original_table.setRowCount(len(originals)) self.original_table.setRowCount(len(originals))
for row, asset in enumerate(originals): for row, asset in enumerate(originals):
order = asset.source_order or row + 1
values = [ values = [
str(asset.source_order or row + 1), f"主图 #{order}",
_asset_status_text(asset), _asset_status_text(asset),
asset.remote_url or "", asset.remote_url or "",
] ]
for column, value in enumerate(values): for column, value in enumerate(values):
item = QTableWidgetItem(str(value or "")) item = QTableWidgetItem(str(value or ""))
item.setData(Qt.UserRole, {"type": "asset", "asset_id": int(asset.id)}) 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.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() self.original_table.resizeColumnsToContents()
def _fill_pool_table(self): def _fill_pool_table(self):
@@ -730,7 +884,7 @@ class ImageStudioTab(QWidget):
if row_type == "asset": if row_type == "asset":
draggable = _asset_is_usable(obj) draggable = _asset_is_usable(obj)
values = [ values = [
_asset_badge(obj.kind), f"{_asset_badge(obj.kind)} #{obj.id}",
obj.aspect_ratio or "未知", obj.aspect_ratio or "未知",
_asset_status_text(obj), _asset_status_text(obj),
_source_text(obj, self.assets), _source_text(obj, self.assets),
@@ -749,7 +903,24 @@ class ImageStudioTab(QWidget):
for column, value in enumerate(values): for column, value in enumerate(values):
item = QTableWidgetItem(str(value or "")) item = QTableWidgetItem(str(value or ""))
item.setData(Qt.UserRole, data) 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.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() self.pool_table.resizeColumnsToContents()
def _selection_asset_ids(self, selection_type): def _selection_asset_ids(self, selection_type):
@@ -888,8 +1059,31 @@ class ImageStudioTab(QWidget):
asset = self._asset_by_id(self.selected_source_asset_id) asset = self._asset_by_id(self.selected_source_asset_id)
if asset is None: if asset is None:
self.source_label.setText("源图:未选择") self.source_label.setText("源图:未选择")
self.source_preview_label.setPixmap(QPixmap())
self.source_preview_label.setText("未选择源图")
return return
self.source_label.setText(f"源图:{_asset_badge(asset.kind)} #{asset.id}") 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): def _open_preview(self, asset):
dialog = ImageStudioPreviewDialog(asset, self) dialog = ImageStudioPreviewDialog(asset, self)
@@ -1023,7 +1217,7 @@ class ImageStudioTab(QWidget):
def start_generation(self, checked=False): def start_generation(self, checked=False):
if self.current_project is None: if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。") self._message("商品项目未打开", "请先打开一个AI工场商品项目。")
return return
source = self._asset_by_id(self.selected_source_asset_id) source = self._asset_by_id(self.selected_source_asset_id)
if source is None: if source is None:
@@ -1066,7 +1260,7 @@ class ImageStudioTab(QWidget):
def resume_generation_jobs(self, checked=False): def resume_generation_jobs(self, checked=False):
if self.current_project is None: if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。") self._message("商品项目未打开", "请先打开一个AI工场商品项目。")
return return
resumable = image_studio.list_resumable_jobs( resumable = image_studio.list_resumable_jobs(
path=self.db_path, path=self.db_path,
@@ -1095,7 +1289,7 @@ class ImageStudioTab(QWidget):
def export_selections(self, checked=False): def export_selections(self, checked=False):
if self.current_project is None: if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。") self._message("商品项目未打开", "请先打开一个AI工场商品项目。")
return return
main_count = len(self._selection_asset_ids("main")) main_count = len(self._selection_asset_ids("main"))
detail_count = len(self._selection_asset_ids("detail")) detail_count = len(self._selection_asset_ids("detail"))
@@ -1290,10 +1484,8 @@ class ImageStudioTab(QWidget):
def _fill_selection_list(self, widget, selection_type): def _fill_selection_list(self, widget, selection_type):
widget.clear() widget.clear()
for index, selection in enumerate( selected = [item for item in self.selections if item.selection_type == selection_type]
[item for item in self.selections if item.selection_type == selection_type], for index, selection in enumerate(selected, start=1):
start=1,
):
asset = self._asset_by_id(selection.asset_id) asset = self._asset_by_id(selection.asset_id)
if asset is None: if asset is None:
text = f"{index}. 缺失照片 #{selection.asset_id}" text = f"{index}. 缺失照片 #{selection.asset_id}"
@@ -1305,6 +1497,7 @@ class ImageStudioTab(QWidget):
continue continue
text = f"{index}. {_asset_badge(asset.kind)} #{asset.id} · {asset.aspect_ratio or '未知比例'}" text = f"{index}. {_asset_badge(asset.kind)} #{asset.id} · {asset.aspect_ratio or '未知比例'}"
item = QListWidgetItem(text) item = QListWidgetItem(text)
item.setIcon(_asset_icon(asset, str(index)))
item.setData(Qt.UserRole, int(asset.id)) item.setData(Qt.UserRole, int(asset.id))
tooltip = _selection_tooltip(selection_type, asset) tooltip = _selection_tooltip(selection_type, asset)
item.setToolTip(tooltip) item.setToolTip(tooltip)
@@ -1315,6 +1508,15 @@ class ImageStudioTab(QWidget):
item.setForeground(_qcolor(COLOR_MUTED)) item.setForeground(_qcolor(COLOR_MUTED))
item.setToolTip(tooltip + "\n本地文件缺失,不能用于导出。") item.setToolTip(tooltip + "\n本地文件缺失,不能用于导出。")
widget.addItem(item) 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): def _asset_from_table_row(self, table, row):
data = self._row_data(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)) 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): def _source_text(asset, assets):
parent_id = getattr(asset, "parent_asset_id", None) parent_id = getattr(asset, "parent_asset_id", None)
if not parent_id: if not parent_id:
+25 -2
View File
@@ -3,7 +3,7 @@ id: T-605
title: AI工场参考 v3 效果图优化信息架构与界面展示 title: AI工场参考 v3 效果图优化信息架构与界面展示
phase: 7 phase: 7
deps: [T-591, T-592, T-593, T-594, T-604] deps: [T-591, T-592, T-593, T-594, T-604]
status: TODO status: DONE
created: 2026-07-11 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)), " ".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.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)) self.assertEqual("完整提示词", prompts.load_image_studio_template("工场模板", prompts_dir))
template_index = tab.template_combo.findData("工场模板") template_index = tab.template_combo.findData("工场模板")
self.assertGreaterEqual(template_index, 0) self.assertGreaterEqual(template_index, 0)
@@ -526,6 +541,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsNotNone(tab.current_project) self.assertIsNotNone(tab.current_project)
self.assertEqual("alias-a", tab.current_project.account_alias) self.assertEqual("alias-a", tab.current_project.account_alias)
self.assertEqual("51100639510", tab.current_project.item_id) 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()) self.assertEqual(1, tab.project_table.rowCount())
original = image_studio.sync_original_asset_urls( original = image_studio.sync_original_asset_urls(
@@ -536,10 +554,12 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.refresh_project_assets() tab.refresh_project_assets()
self.assertEqual(1, tab.original_table.rowCount()) 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("远程待下载", tab.original_table.item(0, 1).text())
self.assertEqual(1, tab.pool_table.rowCount()) 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("远程待下载", tab.pool_table.item(0, 2).text())
self.assertEqual(original.id, tab.pool_table.item(0, 0).data(gui.Qt.UserRole)["asset_id"]) 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], [ self.assertEqual([second.id, first.id], [
tab.main_selection_list.item(row).data(gui.Qt.UserRole) tab.main_selection_list.item(row).data(gui.Qt.UserRole)
for row in range(tab.main_selection_list.count()) 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.assertEqual("#fff8c5", tab.main_selection_list.item(0).background().color().name())
self.assertFalse(tab.add_asset_to_selection("main", first.id)) 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)) self.assertTrue(tab.add_asset_to_selection("detail", first.id))
detail = image_studio.list_selections(project.id, "detail", path=cfg["db_path"]) 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([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)) self.assertTrue(tab.move_selection_asset("main", 1, 0))
main = image_studio.list_selections(project.id, "main", path=cfg["db_path"]) 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_project(project.id)
tab._select_source_asset(source) tab._select_source_asset(source)
tab.prompt_edit.setPlainText("生成商品主图") 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: class FakeStudioWorker:
def __init__(self): def __init__(self):