From 82e7e1bf1458d95b833a2e65085d1b0884587456 Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 11 Jul 2026 16:23:22 +0800 Subject: [PATCH] feat(ai-studio): streamline project workspace --- app/gui/tabs/image_studio.py | 162 ++++++++++++++++++++++------------- docs/tasks/T-606.md | 23 ++++- tests/test_gui.py | 102 ++++++++++++++++++++-- 3 files changed, 219 insertions(+), 68 deletions(-) diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index 7c08d7f..f1b1302 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -238,7 +238,7 @@ class ImageStudioPreviewDialog(QDialog): class ImageStudioTab(QWidget): """Sixth tab: project-based AI image studio.""" - PROJECT_COLUMNS = ["项目", "账号", "商品ID", "更新时间"] + PROJECT_COLUMNS = ["店铺", "商品ID", "更新时间"] ORIGINAL_COLUMNS = ["序号", "状态", "远程地址"] POOL_COLUMNS = ["类型", "比例", "状态", "来源", "本地文件"] JOB_STATUS_LABELS = { @@ -277,6 +277,7 @@ class ImageStudioTab(QWidget): self.selected_source_asset_id = None self._running_worker = None self._running_thread = None + self._operation_running = False self._worker_error_handled = False self._download_open_after = {} @@ -321,31 +322,30 @@ class ImageStudioTab(QWidget): 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 = QLabel("未选择商品") self.current_project_label.setObjectName("imageStudioCurrentProjectLabel") self.current_project_label.setMinimumWidth(220) - self.autosave_label = QLabel("选择账号并输入商品ID后打开") + 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") + self.delete_project_button = QPushButton("删除项目") + self.delete_project_button.setObjectName("imageStudioDeleteProjectButton") 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.pull_images_button) layout.addWidget(self.autosave_label) layout.addStretch(1) - layout.addWidget(self.pull_images_button) layout.addWidget(self.open_folder_button) + layout.addWidget(self.delete_project_button) return panel def _build_project_panel(self): @@ -355,7 +355,7 @@ class ImageStudioTab(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) - title = QLabel("商品项目") + title = QLabel("商品列表") title.setObjectName("imageStudioSectionTitle") hint = QLabel("点击切换项目,项目会自动保存") hint.setObjectName("imageStudioMutedLabel") @@ -382,7 +382,7 @@ class ImageStudioTab(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) - self.workspace_empty_label = QLabel("选择账号并输入商品ID,点击「打开商品项目」开始。") + self.workspace_empty_label = QLabel("选择店铺并输入商品ID后,点击「拉取蝦皮主图」开始。") self.workspace_empty_label.setObjectName("imageStudioWorkspaceEmptyLabel") self.workspace_empty_label.setWordWrap(True) layout.addWidget(self.workspace_empty_label) @@ -635,6 +635,9 @@ class ImageStudioTab(QWidget): background: #eef6ff; color: #24292f; } + #imageStudioDeleteProjectButton { + color: #b42318; + } QTableWidget#imageStudioOriginalTable, QTableWidget#imageStudioPoolTable, QListWidget#imageStudioMainSelectionList, @@ -650,9 +653,9 @@ class ImageStudioTab(QWidget): ) def _connect_signals(self): - self.open_project_button.clicked.connect(self.open_project) self.pull_images_button.clicked.connect(self.pull_main_images) self.open_folder_button.clicked.connect(self.open_project_folder) + self.delete_project_button.clicked.connect(self.delete_current_project) self.project_table.itemSelectionChanged.connect(self._on_project_selection_changed) self.original_table.cellClicked.connect(self._on_original_clicked) self.original_table.cellDoubleClicked.connect(self._on_original_double_clicked) @@ -695,18 +698,18 @@ class ImageStudioTab(QWidget): self.projects = [] self._status(f"AI工场项目读取失败:{exc}", "danger") self._fill_project_table() - if self.current_project is None and self.projects: - self._select_project(self.projects[0].id) - elif self.current_project is not None: + active_ids = {int(project.id) for project in self.projects} + if self.current_project is not None and int(self.current_project.id) in active_ids: self._select_project(self.current_project.id, quiet=True) + elif self.projects: + self._select_project(self.projects[0].id) else: - self._refresh_project_summary() + self._clear_current_project() def _fill_project_table(self): self.project_table.setRowCount(len(self.projects)) for row, project in enumerate(self.projects): values = [ - f"{project.account_alias} / {project.item_id}", project.account_name or project.account_alias, project.item_id, project.updated_at, @@ -718,38 +721,11 @@ class ImageStudioTab(QWidget): self.project_table.setRowHeight(row, 44) self.project_table.resizeColumnsToContents() - def open_project(self, checked=False): - alias = str(self.account_combo.currentData() or "").strip() - item_id = self.item_id_edit.text().strip() - if not alias: - self._message("账号未选择", "请先在④账号管理添加并选择账号。") - return - if not item_id: - self._message("商品ID不能为空", "请输入要打开的蝦皮商品ID。") - return - account = accounts.get_account(alias, path=self.db_path, config=self.config) - try: - project = image_studio.create_or_get_project( - account, - item_id=item_id, - path=self.db_path, - ) - except Exception as exc: - 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") - 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, @@ -779,7 +755,7 @@ class ImageStudioTab(QWidget): def open_project_folder(self, checked=False): if self.current_project is None: - self._message("商品项目未打开", "请先打开一个AI工场商品项目。") + self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。") return dirs = image_studio.default_project_image_dirs(self.current_project, self.config) try: @@ -791,6 +767,44 @@ class ImageStudioTab(QWidget): return self._status(f"已打开项目文件夹:{opened}", "success") + def delete_current_project(self, checked=False): + project = self.current_project + if project is None: + self._message("未选择商品", "请先从商品列表选择要删除的项目。") + return + active_jobs = [ + job + for job in self._list_project_jobs(project.id) + if job.status in {"pending", "submitted", "running"} + ] + if active_jobs: + self._message( + "暂不能删除项目", + "该商品还有未完成的图片任务。请先等待任务完成、停止本轮或继续查询任务后再删除。", + ) + return + display_name = _project_display_name(project) + if not self._confirm( + "删除项目", + f"确定从AI工场商品列表删除“{display_name}”吗?\n" + "不会删除蝦皮商品,也不会删除本地图片。再次拉取同一商品会恢复原项目记录。", + ): + return + try: + image_studio.soft_delete_project( + project.id, + reason="用户从AI工场商品列表删除", + path=self.db_path, + ) + except Exception as exc: + message = diagnostics.redact_log_text(str(exc or "未知错误")) + self._message("删除项目失败", f"无法从AI工场商品列表删除:{message}") + self._status(f"删除AI工场项目失败:{message}", "danger") + return + self._clear_current_project() + self.refresh_projects() + self._status(f"已从AI工场商品列表删除:{display_name}", "success") + def _on_project_selection_changed(self): items = self.project_table.selectedItems() if not items: @@ -820,7 +834,7 @@ class ImageStudioTab(QWidget): 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_display_name(project)}", "muted") def _sync_project_selection(self, project_id): for row in range(self.project_table.rowCount()): @@ -1068,17 +1082,42 @@ class ImageStudioTab(QWidget): 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,点击「打开商品项目」开始。") + self.current_project_label.setText("未选择商品") + self.current_project_label.setToolTip("") + self.autosave_label.setText("选择店铺并输入商品ID后拉取") + self.workspace_empty_label.setText("选择店铺并输入商品ID后,点击「拉取蝦皮主图」开始。") + self._update_project_action_buttons() return - alias = self.current_project.account_alias - item_id = self.current_project.item_id - self.current_project_label.setText(f"{alias} · {item_id}") + self.current_project_label.setText(_project_display_name(self.current_project)) + self.current_project_label.setToolTip(f"账号别名:{self.current_project.account_alias}") self.autosave_label.setText("项目已自动保存") self.workspace_empty_label.setText( - f"当前商品:{alias} / {item_id}。按顺序拉取蝦皮主图、选择源图、生成并拖入终选。" + f"当前商品:{_project_display_name(self.current_project)}。按顺序拉取蝦皮主图、选择源图、生成并拖入终选。" ) + self._update_project_action_buttons() + + def _clear_current_project(self): + self.current_project = None + self.assets = [] + self.jobs = [] + self.selections = [] + self.selected_source_asset_id = None + self.item_id_edit.clear() + self.prompt_edit.blockSignals(True) + try: + self.prompt_edit.clear() + finally: + self.prompt_edit.blockSignals(False) + self._fill_original_table() + self._fill_pool_table() + self._refresh_selection_labels() + self._refresh_source_label() + self._refresh_project_summary() + + def _update_project_action_buttons(self): + enabled = self.current_project is not None and not self._operation_running + self.open_folder_button.setEnabled(enabled) + self.delete_project_button.setEnabled(enabled) def _update_generation_action_text(self, *args): count = self.count_spin.value() if hasattr(self, "count_spin") else 0 @@ -1217,7 +1256,7 @@ class ImageStudioTab(QWidget): def start_generation(self, checked=False): if self.current_project is None: - self._message("商品项目未打开", "请先打开一个AI工场商品项目。") + self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。") return source = self._asset_by_id(self.selected_source_asset_id) if source is None: @@ -1260,7 +1299,7 @@ class ImageStudioTab(QWidget): def resume_generation_jobs(self, checked=False): if self.current_project is None: - self._message("商品项目未打开", "请先打开一个AI工场商品项目。") + self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。") return resumable = image_studio.list_resumable_jobs( path=self.db_path, @@ -1289,7 +1328,7 @@ class ImageStudioTab(QWidget): def export_selections(self, checked=False): if self.current_project is None: - self._message("商品项目未打开", "请先打开一个AI工场商品项目。") + self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。") return main_count = len(self._selection_asset_ids("main")) detail_count = len(self._selection_asset_ids("detail")) @@ -1430,9 +1469,8 @@ class ImageStudioTab(QWidget): self._running_worker = None def _set_running(self, running): - self.open_project_button.setEnabled(not running) + self._operation_running = bool(running) self.pull_images_button.setEnabled(not running) - self.open_folder_button.setEnabled(not running) self.project_table.setEnabled(not running) self.original_table.setEnabled(not running) self.pool_table.setEnabled(not running) @@ -1451,6 +1489,7 @@ class ImageStudioTab(QWidget): self.resume_button.setEnabled(not running) self.stop_button.setEnabled(running) self.export_button.setEnabled(not running) + self._update_project_action_buttons() def _set_account_combo(self, alias): index = self.account_combo.findData(alias) @@ -1586,6 +1625,13 @@ def _asset_badge(kind): }.get(str(kind or ""), str(kind or "图片")) +def _project_display_name(project): + account_name = str(getattr(project, "account_name", "") or "").strip() + account_alias = str(getattr(project, "account_alias", "") or "").strip() + item_id = str(getattr(project, "item_id", "") or "").strip() + return f"{account_name or account_alias or '未命名店铺'} · {item_id or '未填写商品ID'}" + + def _asset_status_text(asset): status = str(getattr(asset, "status", "") or "") local_path = str(getattr(asset, "local_path", "") or "") diff --git a/docs/tasks/T-606.md b/docs/tasks/T-606.md index c0737c5..75c4fb8 100644 --- a/docs/tasks/T-606.md +++ b/docs/tasks/T-606.md @@ -3,7 +3,7 @@ id: T-606 title: AI工场项目入口、商品列表与安全删除优化 phase: 7 deps: [T-605] -status: TODO +status: DONE created: 2026-07-11 --- @@ -99,4 +99,23 @@ git diff --check ## 执行记录 -- 待执行。 +- 2026-07-11:已完成。 +- 代码: + - `app/gui/tabs/image_studio.py`:移除可见的「打开商品项目」入口;顶部改为店铺/商品ID输入、当前「店铺名 · 商品ID」、紧邻的「拉取蝦皮主图」以及「打开项目文件夹」「删除项目」操作组。 + - `app/gui/tabs/image_studio.py`:左侧改为「商品列表」,展示店铺、商品ID、更新时间三列;当前项目标题优先显示店铺名,账号别名仅作为 tooltip。 + - `app/gui/tabs/image_studio.py`:新增「删除项目」确认和软删除入口,明确不删除蝦皮商品或本地图片;删除前拦截 `pending/submitted/running` 任务,删除后显式清理当前项目 UI 并切换到剩余项目或空状态。 + - `app/gui/tabs/image_studio.py`:统一未选择项目时的中文提示,移除旧按钮依赖,并让项目文件夹/删除按钮随当前项目和后台运行状态正确启用或禁用。 +- 测试: + - `tests/test_gui.py`:更新 AI工场构建测试,覆盖去除旧入口、三列商品列表、顶部中文空状态和项目操作按钮状态。 + - `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_deletes_selected_project_only_after_safe_confirmation tests.test_gui.GuiTests.test_image_studio_blocks_deletion_with_active_remote_job tests.test_gui.GuiTests.test_image_studio_pull_main_images_uses_current_item_input tests.test_gui.GuiTests.test_image_studio_worker_thread_is_kept_until_thread_finished` + - `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-606.md` + - 主工作区存在未提交默认提示词文件改动;为隔离无关改动,在临时干净 worktree `D:\chengma\cmshopee-t606-verify` 应用本任务 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`(380 tests) + - `git diff --check` diff --git a/tests/test_gui.py b/tests/test_gui.py index 09fa9e9..8bb00f4 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -494,7 +494,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) prompts_dir = os.path.join(temp_dir, "prompts", "image_studio") - accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + account = accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) prompts.save_image_studio_template("工场模板", "完整提示词", prompts_dir) tab = ImageStudioTab( @@ -512,19 +512,25 @@ class GuiTests(TempDirMixin, unittest.TestCase): "导入本地图片", " ".join(button.text() for button in tab.findChildren(gui.QPushButton)), ) - self.assertEqual("打开商品项目", tab.open_project_button.text()) + self.assertNotIn( + "打开商品项目", + " ".join(button.text() for button in tab.findChildren(gui.QPushButton)), + ) self.assertEqual("拉取蝦皮主图", tab.pull_images_button.text()) + self.assertEqual("删除项目", tab.delete_project_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("未选择商品", tab.current_project_label.text()) + self.assertIn("拉取蝦皮主图", tab.workspace_empty_label.text()) + self.assertFalse(tab.open_folder_button.isEnabled()) + self.assertFalse(tab.delete_project_button.isEnabled()) 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) @@ -536,15 +542,26 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab.load_selected_template() self.assertEqual("完整提示词", tab.prompt_edit.toPlainText()) - tab.item_id_edit.setText("51100639510") - tab.open_project() + project = image_studio.create_or_get_project( + account, + item_id="51100639510", + path=cfg["db_path"], + ) + tab.refresh_projects() 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.assertEqual(project.id, tab.current_project.id) + self.assertIn("主店", 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(["店铺", "商品ID", "更新时间"], [ + tab.project_table.horizontalHeaderItem(column).text() + for column in range(tab.project_table.columnCount()) + ]) + self.assertTrue(tab.open_folder_button.isEnabled()) + self.assertTrue(tab.delete_project_button.isEnabled()) original = image_studio.sync_original_asset_urls( tab.current_project.id, @@ -565,6 +582,75 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_image_studio_deletes_selected_project_only_after_safe_confirmation(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + first = image_studio.create_or_get_project( + account_alias="alias-a", + account_name="主店", + account_slug="alias_a", + item_id="51100639510", + path=cfg["db_path"], + ) + second = image_studio.create_or_get_project( + account_alias="alias-a", + account_name="主店", + account_slug="alias_a", + item_id="51100639511", + path=cfg["db_path"], + ) + tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) + self.addCleanup(tab.close) + tab._select_project(first.id) + + confirmations = [] + tab._confirm = lambda title, text: confirmations.append((title, text)) or False + tab.delete_current_project() + self.assertIsNotNone(image_studio.get_project(first.id, path=cfg["db_path"])) + self.assertEqual(first.id, tab.current_project.id) + self.assertIn("不会删除蝦皮商品", confirmations[0][1]) + self.assertIn("不会删除本地图片", confirmations[0][1]) + + tab._confirm = lambda title, text: True + tab.delete_current_project() + self.assertIsNone(image_studio.get_project(first.id, path=cfg["db_path"])) + deleted = image_studio.get_project(first.id, path=cfg["db_path"], include_deleted=True) + self.assertIsNotNone(deleted) + self.assertEqual("用户从AI工场商品列表删除", deleted.deleted_reason) + self.assertIsNotNone(tab.current_project) + self.assertEqual(second.id, tab.current_project.id) + self.assertEqual(1, tab.project_table.rowCount()) + + self.assert_removed(temp_dir) + + def test_image_studio_blocks_deletion_with_active_remote_job(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + db.init_db(cfg["db_path"]) + project = image_studio.create_or_get_project( + account_alias="alias-a", + account_name="主店", + account_slug="alias_a", + item_id="51100639510", + path=cfg["db_path"], + ) + image_studio.create_job(project.id, path=cfg["db_path"]) + tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) + self.addCleanup(tab.close) + tab._select_project(project.id) + messages = [] + tab._message = lambda title, text: messages.append((title, text)) + tab._confirm = lambda title, text: self.fail("有未完成任务时不应出现删除确认") + + tab.delete_current_project() + + self.assertIsNotNone(image_studio.get_project(project.id, path=cfg["db_path"])) + self.assertEqual("暂不能删除项目", messages[0][0]) + self.assertIn("未完成的图片任务", messages[0][1]) + + self.assert_removed(temp_dir) + def test_image_studio_final_selection_order_and_guards(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)