diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index 346812b..7c08d7f 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -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: diff --git a/docs/tasks/T-605.md b/docs/tasks/T-605.md index 4a4f87d..c68285d 100644 --- a/docs/tasks/T-605.md +++ b/docs/tasks/T-605.md @@ -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` diff --git a/tests/test_gui.py b/tests/test_gui.py index 835885f..09fa9e9 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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):