feat(ai-studio): refresh workspace layout
This commit is contained in:
+317
-52
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user