diff --git a/app/gui/tabs/image_studio.py b/app/gui/tabs/image_studio.py index 4af6ee9..95f946c 100644 --- a/app/gui/tabs/image_studio.py +++ b/app/gui/tabs/image_studio.py @@ -303,6 +303,7 @@ class ImageStudioTab(QWidget): self._running_worker = None self._running_thread = None self._operation_running = False + self._operation_kind = "" self._worker_error_handled = False self._download_open_after = {} self._thumbnail_loader = thumbnail_loader or image_studio_images.ThumbnailLoader() @@ -811,6 +812,9 @@ class ImageStudioTab(QWidget): self.project_table.resizeColumnsToContents() def pull_main_images(self, checked=False): + if self._operation_running: + self._status("AI工场当前任务仍在进行,暂不能拉取蝦皮主图", "warning") + return alias = str(self.account_combo.currentData() or "").strip() item_id = self.item_id_edit.text().strip() if not alias or not item_id: @@ -845,7 +849,7 @@ 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._start_worker(worker, "AI工场拉取蝦皮主图", operation_kind="pull_images") self._append_log("[AI工场] 拉取蝦皮主图开始") def _local_original_count(self, project): @@ -1006,6 +1010,9 @@ class ImageStudioTab(QWidget): self._status(f"已打开项目文件夹:{opened}", "success") def delete_current_project(self, checked=False): + if self._operation_running: + self._status("AI工场当前任务仍在进行,暂不能删除项目", "warning") + return project = self.current_project if project is None: self._message("未选择商品", "请先从商品列表选择要删除的项目。") @@ -1518,10 +1525,13 @@ class ImageStudioTab(QWidget): 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) + has_project = self.current_project is not None + self.open_folder_button.setEnabled( + has_project and (not self._operation_running or self._is_generation_operation()) + ) self.delete_project_button.setEnabled( - enabled + has_project + and not self._operation_running and not self._has_original_download_activity( getattr(self.current_project, "id", None) ) @@ -1661,6 +1671,9 @@ class ImageStudioTab(QWidget): self.remove_asset(asset_id) def remove_asset(self, asset_id): + if self._operation_running: + self._status("AI工场当前任务仍在进行,暂不能移除照片", "warning") + return if not self._confirm("移除照片", "只从AI工场照片池移除记录,不删除本地图片文件。"): return try: @@ -1767,6 +1780,9 @@ class ImageStudioTab(QWidget): self._status(f"保存AI工场草稿提示词失败:{exc}", "warning") def start_generation(self, checked=False): + if self._operation_running: + self._status("AI工场当前任务仍在进行,请等待完成或停止后再生成", "warning") + return if self.current_project is None: self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。") return @@ -1801,7 +1817,7 @@ class ImageStudioTab(QWidget): worker.log.connect(self._append_log) worker.finished.connect(self._on_generation_finished) worker.failed.connect(self._on_worker_failed) - self._start_worker(worker, "AI工场生成图片") + self._start_worker(worker, "AI工场生成图片", operation_kind="generate") def stop_generation(self, checked=False): if self._running_worker is not None and hasattr(self._running_worker, "cancel"): @@ -1810,6 +1826,9 @@ class ImageStudioTab(QWidget): self._status("AI工场生成已请求停止", "warning") def resume_generation_jobs(self, checked=False): + if self._operation_running: + self._status("AI工场当前任务仍在进行,请等待完成或停止后再继续查询", "warning") + return if self.current_project is None: self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。") return @@ -1836,9 +1855,12 @@ class ImageStudioTab(QWidget): worker.log.connect(self._append_log) worker.finished.connect(self._on_generation_finished) worker.failed.connect(self._on_worker_failed) - self._start_worker(worker, "AI工场继续查询") + self._start_worker(worker, "AI工场继续查询", operation_kind="resume") def export_selections(self, checked=False): + if self._operation_running: + self._status("AI工场当前任务仍在进行,暂不能导出终选", "warning") + return if self.current_project is None: self._message("未选择商品", "请先从商品列表选择商品,或拉取蝦皮主图。") return @@ -1863,7 +1885,7 @@ class ImageStudioTab(QWidget): worker.log.connect(self._append_log) worker.finished.connect(self._on_export_finished) worker.failed.connect(self._on_worker_failed) - self._start_worker(worker, "AI工场导出终选") + self._start_worker(worker, "AI工场导出终选", operation_kind="export") def _export_existing_mode(self, parent_dir, main_count, detail_count): try: @@ -1963,14 +1985,18 @@ class ImageStudioTab(QWidget): self._on_worker_failed(-1, summary.get("error") or fallback_message) return True - def _start_worker(self, worker, thread_name): + def _start_worker(self, worker, thread_name, *, operation_kind="exclusive"): + if self._running_thread is not None: + self._status("AI工场上一项任务仍在收尾,请稍候", "warning") + return False thread = run_worker(worker, thread_name=thread_name, start=False) thread.finished.connect(lambda: self._forget_running_thread(thread)) self._running_worker = worker self._running_thread = thread self._worker_error_handled = False - self._set_running(True) + self._set_running(True, operation_kind=operation_kind) thread.start() + return True def _finish_worker(self): self._set_running(False) @@ -1980,27 +2006,38 @@ class ImageStudioTab(QWidget): self._running_thread = None self._running_worker = None - def _set_running(self, running): + def _is_generation_operation(self): + return self._operation_running and self._operation_kind in {"generate", "resume"} + + def _set_running(self, running, *, operation_kind="exclusive"): self._operation_running = bool(running) + self._operation_kind = str(operation_kind or "exclusive") if running else "" + generation_running = self._is_generation_operation() self.pull_images_button.setEnabled(not running) + self.account_combo.setEnabled(not running) + self.item_id_edit.setEnabled(not running) self.project_table.setEnabled(not running) self.original_grid.setEnabled(not running) - self.pool_grid.setEnabled(not running) - self.main_selection_list.setEnabled(not running) - self.detail_selection_list.setEnabled(not running) - self.template_combo.setEnabled(not running) - self.template_new_button.setEnabled(not running) - self.template_rename_button.setEnabled(not running) - self.template_save_button.setEnabled(not running) - self.template_delete_button.setEnabled(not running) - self.prompt_edit.setEnabled(not running) - self.job_type_combo.setEnabled(not running) - self.count_spin.setEnabled(not running) - self.aspect_combo.setEnabled(not running) + self.pool_grid.setEnabled(not running or generation_running) + self.main_selection_list.setEnabled(not running or generation_running) + self.detail_selection_list.setEnabled(not running or generation_running) + self.template_combo.setEnabled(not running or generation_running) + self.template_new_button.setEnabled(not running or generation_running) + self.template_rename_button.setEnabled(not running or generation_running) + self.template_save_button.setEnabled(not running or generation_running) + self.template_delete_button.setEnabled(not running or generation_running) + self.prompt_edit.setEnabled(not running or generation_running) + self.job_type_combo.setEnabled(not running or generation_running) + self.count_spin.setEnabled(not running or generation_running) + self.aspect_combo.setEnabled(not running or generation_running) self.start_button.setEnabled(not running) self.resume_button.setEnabled(not running) self.stop_button.setEnabled(running) self.export_button.setEnabled(not running) + if generation_running: + self.generation_guard_label.setText("本轮任务已固定;此处修改将在下一轮生成时生效。") + else: + self.generation_guard_label.setText("不会在失败后静默切换生成来源。") self._update_project_action_buttons() def _set_account_combo(self, alias): diff --git a/docs/routes.md b/docs/routes.md index d8fabef..2b2cf46 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -200,7 +200,7 @@ - 底部终选盘分为主图和详情图两列;照片池中已下载/已生成且本地文件可用的图片可拖入终选,落到已有位置时按插入顺延,同一类别内同一照片只能出现一次,主图和详情图之间允许复用同一照片。 - 终选列表内可拖动重排,Delete 或右键「移出终选」只移出终选,不删除照片池资产或本地文件;拖放/移出失败时刷新回 SQLite 中的持久化顺序。 - 主图推荐 1:1;比例不匹配只用黄色轻提示和 tooltip 提醒,不硬拦。文件缺失或尚未下载的照片不能拖入终选。 -- 拉主图、下载原图、生图 submit/poll/download 均通过 worker 执行,主线程只刷新 UI;运行中禁用项目切换、模板编辑、源图选择、终选拖放和生成设置,停止为协作式停止。 +- 拉主图、下载原图、生图 submit/poll/download 均通过 worker 执行,主线程只刷新 UI。拉主图、导出等互斥 worker 运行时禁用项目切换和工作区写操作;生图或继续查询任务运行时仍只允许一个耗时 worker,但当前项目的照片池浏览/源图选择、主图和详情图终选拖放、提示词模板编辑以及下一轮类型/数量/比例设置保持可用。这些修改只作用于下一轮,当前已提交任务的参数不变;此时仍禁用项目切换、原图区下载、拉主图、开始生成、继续查询、导出和删除项目,停止为协作式停止。 - 「导出终选」可在主图/详情图未满目标数量时导出当前终选;主图和详情图总数为 0 时阻断。用户选择导出父目录后,程序在其下创建商品 ID 子目录,按终选顺序转码为 `商品ID_主图_1.jpg`、`商品ID_详情图_1.jpg`,透明图铺白底输出真正 JPEG。 - 商品目录已存在时只提供三选:覆盖本软件导出的图片(仅删除匹配当前商品命名规则的旧主图/详情图,保留用户其它文件)、新建带时间目录、取消;不提供合并,也不递归清空用户目录。 - 导出前会预检所有终选源文件和图片解码,先写 staging,转码失败不创建商品目录、不留下半套新图;成功后中文提示实际目录和主图/详情图数量,并提供打开目录。 diff --git a/docs/tasks/T-612.md b/docs/tasks/T-612.md index 9fb47d6..dbcf74c 100644 --- a/docs/tasks/T-612.md +++ b/docs/tasks/T-612.md @@ -3,7 +3,7 @@ id: T-612 title: AI工场生图运行态分级,保留当前项目非冲突操作 phase: 7 deps: [T-594, T-604, T-608, T-609] -status: TODO +status: DONE created: 2026-07-13 --- @@ -92,4 +92,14 @@ git diff --check ## 执行记录 -(完成后记录实现、验证命令与人工验收结果。) +- 2026-07-13:完成 AI工场生图运行态分级。 + - `app/gui/tabs/image_studio.py`:为通用 worker 增加 `operation_kind`,区分生成/继续查询、拉主图、导出等操作;生成/继续查询期间保留照片池、终选、提示词模板/编辑和下一轮参数,仍禁用项目切换、原图区下载、拉图、开始/继续查询、导出、删除。 + - `app/gui/tabs/image_studio.py`:生成 worker 创建时固定使用当时的源图、提示词和参数;界面显示「本轮任务已固定;此处修改将在下一轮生成时生效」。打开项目文件夹在生成期间仍可用。 + - `app/gui/tabs/image_studio.py`:`_start_worker()` 在上一 QThread 尚未发出 `thread.finished` 时拒绝启动下一 worker;拉图、生成、继续查询、导出、删除和移除照片增加运行中保护,避免程序化调用绕过禁用按钮。 + - `docs/routes.md`:更新⑥ AI工场的生成运行态边界。 + - `tests/test_gui.py`:新增生成态控件清单/参数快照测试,以及线程收尾前禁止启动下一 worker 的测试。 +- 验证: + - 主工作区定向 GUI 测试通过:4 项,覆盖生成运行态、参数快照和线程生命周期。 + - 主工作区 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 通过。 + - 主工作区全量 unittest 因既有未提交默认提示词文件变更失败 3 项(`papa1` 被乱码默认模板替代),不属于本任务。 + - 干净 worktree `D:\chengma\cmshopee-t612-verify` 仅应用本任务差异后通过:`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(389 项)及 `git diff --check`。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 10c650a..affbb4c 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -996,6 +996,132 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_image_studio_generation_keeps_current_project_workspace_available(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_slug="alias_a", + item_id="51100639510", + path=cfg["db_path"], + ) + source = image_studio.add_asset( + project.id, + "original", + local_path=self.write_test_image(os.path.join(temp_dir, "source.jpg")), + path=cfg["db_path"], + ) + tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) + self.addCleanup(tab.close) + tab._select_project(project.id) + tab._select_source_asset(source) + tab.prompt_edit.setPlainText("第一轮提示词") + + class FakeStudioWorker: + def __init__(self): + self.progress = DummySignal() + self.log = DummySignal() + self.finished = DummySignal() + self.failed = DummySignal() + + def cancel(self): + pass + + fake_worker = FakeStudioWorker() + fake_thread = FakeThread() + with mock.patch( + "app.gui.tabs.image_studio.ImageStudioGenerateJobsWorker", + return_value=fake_worker, + ) as worker_factory, mock.patch( + "app.gui.tabs.image_studio.run_worker", return_value=fake_thread + ): + tab.start_generation() + + self.assertEqual("generate", tab._operation_kind) + self.assertTrue(tab.pool_grid.isEnabled()) + self.assertTrue(tab.main_selection_list.isEnabled()) + self.assertTrue(tab.detail_selection_list.isEnabled()) + self.assertTrue(tab.template_combo.isEnabled()) + self.assertTrue(tab.prompt_edit.isEnabled()) + self.assertTrue(tab.job_type_combo.isEnabled()) + self.assertTrue(tab.count_spin.isEnabled()) + self.assertTrue(tab.aspect_combo.isEnabled()) + self.assertTrue(tab.open_folder_button.isEnabled()) + self.assertFalse(tab.start_button.isEnabled()) + self.assertFalse(tab.resume_button.isEnabled()) + self.assertFalse(tab.pull_images_button.isEnabled()) + self.assertFalse(tab.project_table.isEnabled()) + self.assertFalse(tab.original_grid.isEnabled()) + self.assertFalse(tab.export_button.isEnabled()) + self.assertFalse(tab.delete_project_button.isEnabled()) + self.assertIn("下一轮生成", tab.generation_guard_label.text()) + + tab.prompt_edit.setPlainText("下一轮提示词") + tab.count_spin.setValue(3) + tab.job_type_combo.setCurrentIndex(tab.job_type_combo.findData("detail")) + tab.aspect_combo.setCurrentIndex(tab.aspect_combo.findData("3:4")) + args = worker_factory.call_args.args + self.assertEqual("第一轮提示词", args[2]) + self.assertEqual(4, args[3]) + self.assertEqual("main", worker_factory.call_args.kwargs["job_type"]) + self.assertEqual("1:1", worker_factory.call_args.kwargs["aspect_ratio"]) + + tab._finish_worker() + self.assertTrue(tab.start_button.isEnabled()) + self.assertEqual("", tab._operation_kind) + self.assertIn("不会在失败后", tab.generation_guard_label.text()) + + fake_thread.finished.emit() + self.assertIsNone(tab._running_worker) + self.assertIsNone(tab._running_thread) + + self.assert_removed(temp_dir) + + def test_image_studio_worker_waits_for_thread_cleanup_before_next_start(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"]) + self.addCleanup(tab.close) + first_worker = SimpleNamespace() + second_worker = SimpleNamespace() + first_thread = FakeThread() + second_thread = FakeThread() + + with mock.patch( + "app.gui.tabs.image_studio.run_worker", + side_effect=[first_thread, second_thread], + ) as run_worker: + self.assertTrue( + tab._start_worker( + first_worker, + "AI工场测试线程", + operation_kind="generate", + ) + ) + tab._finish_worker() + self.assertFalse( + tab._start_worker( + second_worker, + "AI工场测试线程", + operation_kind="generate", + ) + ) + self.assertEqual(1, run_worker.call_count) + first_thread.finished.emit() + self.assertTrue( + tab._start_worker( + second_worker, + "AI工场测试线程", + operation_kind="generate", + ) + ) + + self.assertEqual(2, run_worker.call_count) + second_thread.finished.emit() + + self.assert_removed(temp_dir) + def test_image_studio_worker_thread_is_kept_until_thread_finished(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)