From 5b5ee119ad67acbddae146a3ab673008061084e1 Mon Sep 17 00:00:00 2001 From: ila Date: Mon, 22 Jun 2026 17:49:59 +0800 Subject: [PATCH] feat: add outfit output directory --- src/app/widgets/ai_outfit_panel.py | 16 ++++--- src/services/file_service.py | 25 ++++++++++- tasks.md | 10 ++--- tests/test_ai_outfit.py | 2 +- tests/test_ai_outfit_panel.py | 69 ++++++++++++++++++++++++++++++ tests/test_file_service.py | 58 +++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 tests/test_ai_outfit_panel.py diff --git a/src/app/widgets/ai_outfit_panel.py b/src/app/widgets/ai_outfit_panel.py index 564bd2d..55afd0e 100644 --- a/src/app/widgets/ai_outfit_panel.py +++ b/src/app/widgets/ai_outfit_panel.py @@ -245,7 +245,7 @@ class AiOutfitPanel(QWidget): self._excel_edit.setPlaceholderText("选择商品表 .xlsx") col.addLayout(self._inline_path_row("Excel", self._excel_edit, self._browse_excel)) self._output_edit = QLineEdit() - self._output_edit.setPlaceholderText("默认:程序旁的「合并后的图片」") + self._output_edit.setPlaceholderText("默认:程序旁的「穿搭图片」") col.addLayout(self._inline_path_row("输出", self._output_edit, self._browse_output)) # 穿搭生成话术(多套模板;加大,随窗口高度拉伸) @@ -467,7 +467,11 @@ class AiOutfitPanel(QWidget): def apply_config(self, config): """Populate widgets from the merged app config + load models/prompt.""" self._excel_edit.setText(config.get("outfit_excel", "")) - self._output_edit.setText(config.get("outfit_output_dir", "")) + outfit_output_dir = str(config.get("outfit_output_dir", "") or "").strip() + if not outfit_output_dir: + from services.file_service import get_outfit_output_dir + outfit_output_dir = str(get_outfit_output_dir()) + self._output_edit.setText(outfit_output_dir) self._concurrency.setValue(int(config.get("outfit_concurrency", 1) or 1)) self._interval.setValue(float(config.get("outfit_request_interval", 2.0) or 0.0)) self._cooldown.setValue(float(config.get("outfit_task_cooldown", 1.0) or 0.0)) @@ -787,8 +791,8 @@ class AiOutfitPanel(QWidget): output_dir = self._output_edit.text().strip() if not output_dir: - from services.file_service import get_output_dir - output_dir = str(get_output_dir()) + from services.file_service import get_outfit_output_dir + output_dir = str(get_outfit_output_dir()) from core.outfit_batch import OutfitBatchOptions options = OutfitBatchOptions( @@ -965,8 +969,8 @@ class AiOutfitPanel(QWidget): def _open_output_dir(self): path = self._output_edit.text().strip() if not path: - from services.file_service import get_output_dir - path = str(get_output_dir()) + from services.file_service import get_outfit_output_dir + path = str(get_outfit_output_dir()) QDesktopServices.openUrl(QUrl.fromLocalFile(path)) def _export_failures(self): diff --git a/src/services/file_service.py b/src/services/file_service.py index 916f733..0ff2f08 100644 --- a/src/services/file_service.py +++ b/src/services/file_service.py @@ -91,8 +91,9 @@ def _is_dir_writable(path): return False -# Default export folder name, placed next to Launcher.exe in the packaged layout. +# Default export folder names, placed next to Launcher.exe in the packaged layout. _OUTPUT_DIR_NAME = "合并后的图片" +_OUTFIT_OUTPUT_DIR_NAME = "穿搭图片" def get_output_dir(): @@ -114,6 +115,28 @@ def get_output_dir(): return d +def get_outfit_output_dir(): + """Return the default AI outfit output directory as a Path. + + Packaged: \\穿搭图片 (next to Launcher.exe, not inside app\\). + Falls back to /output/穿搭图片 if the preferred location is not + writable. Development: /穿搭图片. + + User-selected outfit_output_dir still takes precedence in the UI. + """ + if getattr(sys, "frozen", False): + candidate = get_app_dir().parent / _OUTFIT_OUTPUT_DIR_NAME + else: + candidate = get_app_dir() / _OUTFIT_OUTPUT_DIR_NAME + + if _is_dir_writable(candidate): + return candidate + + d = get_data_dir() / "output" / _OUTFIT_OUTPUT_DIR_NAME + d.mkdir(parents=True, exist_ok=True) + return d + + # --------------------------------------------------------------------------- # 文件夹扫描 # --------------------------------------------------------------------------- diff --git a/tasks.md b/tasks.md index a954d6b..54a52b2 100644 --- a/tasks.md +++ b/tasks.md @@ -1210,8 +1210,8 @@ 设计取舍:新增 AI 穿搭专用默认输出目录辅助函数,不改变添加印花页的 `get_output_dir()` 行为;用户已手动选择的 `outfit_output_dir` 继续优先,只有为空时才使用新默认目录。 - [x] 文档已更新:`docs/11-ai-outfit.md` / `docs/10-lan-update.md` / `docs/ui-ai-outfit.html` 均指向 `穿搭图片\` -- [ ] `file_service.py` 增加 AI 穿搭默认输出目录辅助函数(如 `get_outfit_output_dir()`):打包态优先 `<安装根>\穿搭图片`,不可写回退 `get_data_dir()/output/穿搭图片`,开发态用项目目录下 `穿搭图片` -- [ ] `ai_outfit_panel.py` 默认输出目录改用该 helper;`outfit_output_dir` 非空时仍使用用户保存值 -- [ ] `core/ai_outfit.py` 目录行输出保持 `AI 穿搭输出目录/<目录叶子名>/<源图名>.jpg`,单文件行仍按当前输出目录落盘 -- [ ] 补测试:默认目录路径、不可写回退、AI 穿搭面板默认值、目录行输出到 `穿搭图片/<目录名>/` -- [ ] 验证:相关单测和全套 `python -m unittest discover -s tests` 通过;离屏启动 AI 穿搭页时输出框默认显示 `穿搭图片` +- [x] `file_service.py` 增加 AI 穿搭默认输出目录辅助函数(如 `get_outfit_output_dir()`):打包态优先 `<安装根>\穿搭图片`,不可写回退 `get_data_dir()/output/穿搭图片`,开发态用项目目录下 `穿搭图片` +- [x] `ai_outfit_panel.py` 默认输出目录改用该 helper;`outfit_output_dir` 非空时仍使用用户保存值 +- [x] `core/ai_outfit.py` 目录行输出保持 `AI 穿搭输出目录/<目录叶子名>/<源图名>.jpg`,单文件行仍按当前输出目录落盘 +- [x] 补测试:默认目录路径、不可写回退、AI 穿搭面板默认值、目录行输出到 `穿搭图片/<目录名>/` +- [~] 验证:相关单测通过,离屏启动 AI 穿搭页时输出框默认显示 `穿搭图片`;全套 `python -m unittest discover -s tests` 当前被工作区未提交的 `packaging/default_config/ai_models.json` 改动阻塞(模型顺序/API key 与出厂模板规范不一致),待清理该文件后重跑 diff --git a/tests/test_ai_outfit.py b/tests/test_ai_outfit.py index 1d4281a..e2cdeaa 100644 --- a/tests/test_ai_outfit.py +++ b/tests/test_ai_outfit.py @@ -185,7 +185,7 @@ class TestAiOutfitCore(unittest.TestCase): from core.ai_outfit import generate_outfit_image d = self._make_dir_with_images("a") - out = self.tmp / "out" + out = self.tmp / "穿搭图片" client = _RecordingClient(self._image_bytes()) result = generate_outfit_image( diff --git a/tests/test_ai_outfit_panel.py b/tests/test_ai_outfit_panel.py new file mode 100644 index 0000000..64bfa36 --- /dev/null +++ b/tests/test_ai_outfit_panel.py @@ -0,0 +1,69 @@ +"""Smoke tests for AI outfit panel defaults (offscreen Qt).""" +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import services.file_service as fs +from services.config_service import DEFAULT_CONFIG + + +class TestAiOutfitPanelDefaults(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self._env = os.environ.get("CMBOT_DATA_DIR") + os.environ["CMBOT_DATA_DIR"] = str(self.tmp / "data") + self.app_dir = self.tmp / "app" + self.app_dir.mkdir(parents=True) + self._orig_get_app_dir = fs.get_app_dir + fs.get_app_dir = lambda: self.app_dir + self.app = self._app() + + def tearDown(self): + fs.get_app_dir = self._orig_get_app_dir + if self._env is None: + os.environ.pop("CMBOT_DATA_DIR", None) + else: + os.environ["CMBOT_DATA_DIR"] = self._env + shutil.rmtree(str(self.tmp), ignore_errors=True) + + def _app(self): + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + def _panel(self): + from app.widgets.ai_outfit_panel import AiOutfitPanel + + return AiOutfitPanel() + + def test_empty_config_defaults_to_outfit_output_dir(self): + panel = self._panel() + cfg = dict(DEFAULT_CONFIG) + cfg["outfit_output_dir"] = "" + + panel.apply_config(cfg) + + self.assertEqual(Path(panel._output_edit.text()), self.app_dir / "穿搭图片") + + def test_saved_outfit_output_dir_takes_precedence(self): + panel = self._panel() + saved = self.tmp / "custom-output" + cfg = dict(DEFAULT_CONFIG) + cfg["outfit_output_dir"] = str(saved) + + panel.apply_config(cfg) + + self.assertEqual(Path(panel._output_edit.text()), saved) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_file_service.py b/tests/test_file_service.py index 002dc38..2a352ca 100644 --- a/tests/test_file_service.py +++ b/tests/test_file_service.py @@ -103,5 +103,63 @@ class TestGetOutputDir(unittest.TestCase): fs._is_dir_writable = orig +class TestGetOutfitOutputDir(unittest.TestCase): + """get_outfit_output_dir() default location (docs/11 §9).""" + + def setUp(self): + import tempfile + self.tmp = Path(tempfile.mkdtemp()) + self._env = os.environ.get("CMBOT_DATA_DIR") + self._frozen = getattr(sys, "frozen", None) + os.environ["CMBOT_DATA_DIR"] = str(self.tmp / "data") + if hasattr(sys, "frozen"): + del sys.frozen + self._orig_app_dir = fs.get_app_dir + + def tearDown(self): + import shutil + fs.get_app_dir = self._orig_app_dir + if self._env is None: + os.environ.pop("CMBOT_DATA_DIR", None) + else: + os.environ["CMBOT_DATA_DIR"] = self._env + if self._frozen is None: + if hasattr(sys, "frozen"): + del sys.frozen + else: + sys.frozen = self._frozen + shutil.rmtree(str(self.tmp), ignore_errors=True) + + def test_dev_uses_project_outfit_folder(self): + app = self.tmp / "project" + app.mkdir() + fs.get_app_dir = lambda: app + + self.assertEqual(fs.get_outfit_output_dir(), app / "穿搭图片") + + def test_frozen_uses_install_root_outfit_folder(self): + app = self.tmp / "install" / "app" + app.mkdir(parents=True) + fs.get_app_dir = lambda: app + sys.frozen = True + + self.assertEqual(fs.get_outfit_output_dir(), self.tmp / "install" / "穿搭图片") + + def test_frozen_falls_back_when_not_writable(self): + app = self.tmp / "install" / "app" + app.mkdir(parents=True) + fs.get_app_dir = lambda: app + sys.frozen = True + orig = fs._is_dir_writable + fs._is_dir_writable = lambda p: False + try: + self.assertEqual( + fs.get_outfit_output_dir(), + Path(os.environ["CMBOT_DATA_DIR"]) / "output" / "穿搭图片", + ) + finally: + fs._is_dir_writable = orig + + if __name__ == "__main__": unittest.main()