feat(ai-outfit): AI 穿搭 UI tab (§19.3) + config plumbing (§19.4)
- main_window: wrap workflow in a QStackedWidget (page 0 = print, page 1 = AI outfit); enable tab 2「AI 穿搭」, switch pages on tab change. - app/widgets/ai_outfit_panel.py: three-column page per docs/11 §10 — left settings (Excel/output/model/prompt editor+save+insert+preview dialog/batch options), center (recent-results thumbnails + detail table), right (progress/stats/start/stop/export failures/log). - Threading: QThread + _OutfitWorker(QObject) wraps OutfitBatchRunner; queued signals refresh UI, each row written back to Excel on the worker thread; finish summary + failure-list CSV export. - config_service: load_ai_models()/load_outfit_prompt()/save_outfit_prompt() + outfit_* keys in app_config; panel persists via config_changed signal. - tests/test_config_service.py: 8 cases for the new helpers. Full suite (12 files) green on Python 3.7; offscreen MainWindow smoke test passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+39
-7
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSplitter,
|
||||
QStackedWidget,
|
||||
QTabBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -23,6 +24,7 @@ from version import APP_NAME, APP_VERSION
|
||||
from core.models import BatchMode, TransformState
|
||||
from services.config_service import load_config, save_config
|
||||
from services.update_service import check_for_update
|
||||
from app.widgets.ai_outfit_panel import AiOutfitPanel
|
||||
from app.widgets.export_panel import ExportPanel
|
||||
from app.widgets.image_canvas import ImageCanvas
|
||||
from app.widgets.image_list_panel import ImageListPanel
|
||||
@@ -80,8 +82,12 @@ class MainWindow(QMainWindow):
|
||||
# shows the app name and version, so an in-content header would just
|
||||
# duplicate it. Start straight from the workflow tab bar.
|
||||
layout.addWidget(self._create_tab_bar())
|
||||
layout.addWidget(self._create_work_area(), stretch=1)
|
||||
layout.addWidget(self._create_queue_panel())
|
||||
|
||||
# Swappable workflow pages: 0 = 添加印花 (canvas + queue), 1 = AI 穿搭.
|
||||
self._stack = QStackedWidget()
|
||||
self._stack.addWidget(self._create_print_page())
|
||||
self._stack.addWidget(self._create_ai_outfit_page())
|
||||
layout.addWidget(self._stack, stretch=1)
|
||||
|
||||
self.setCentralWidget(root)
|
||||
self._apply_stylesheet()
|
||||
@@ -104,8 +110,7 @@ class MainWindow(QMainWindow):
|
||||
self._tab_bar.addTab("1 添加印花")
|
||||
self._tab_bar.addTab("2 AI 穿搭")
|
||||
self._tab_bar.addTab("3 导出上架")
|
||||
# Only first tab is implemented in phase 1
|
||||
self._tab_bar.setTabEnabled(1, False)
|
||||
# Tabs 1 (添加印花) and 2 (AI 穿搭) are implemented; 3 (导出上架) is not yet.
|
||||
self._tab_bar.setTabEnabled(2, False)
|
||||
self._tab_bar.currentChanged.connect(self._on_tab_changed)
|
||||
|
||||
@@ -189,6 +194,22 @@ class MainWindow(QMainWindow):
|
||||
self._update_hint.setToolTip("点击进入设置更新")
|
||||
self._update_hint.setVisible(bool(available))
|
||||
|
||||
def _create_print_page(self):
|
||||
"""Page 0: the print workflow — work area (canvas) above the queue."""
|
||||
page = QWidget()
|
||||
col = QVBoxLayout(page)
|
||||
col.setContentsMargins(0, 0, 0, 0)
|
||||
col.setSpacing(0)
|
||||
col.addWidget(self._create_work_area(), stretch=1)
|
||||
col.addWidget(self._create_queue_panel())
|
||||
return page
|
||||
|
||||
def _create_ai_outfit_page(self):
|
||||
"""Page 1: the AI outfit panel (docs/11)."""
|
||||
self.ai_outfit_panel = AiOutfitPanel()
|
||||
self.ai_outfit_panel.config_changed.connect(self._on_outfit_config_changed)
|
||||
return self.ai_outfit_panel
|
||||
|
||||
def _create_work_area(self):
|
||||
"""Horizontal splitter: left material panel | canvas | right params."""
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
@@ -300,9 +321,12 @@ class MainWindow(QMainWindow):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_tab_changed(self, index):
|
||||
"""Keep focus on tab 0; notify user for unimplemented tabs."""
|
||||
if index != 0:
|
||||
self._tab_bar.setCurrentIndex(0)
|
||||
"""Switch the stacked page for implemented tabs; block the rest."""
|
||||
if index in (0, 1):
|
||||
self._stack.setCurrentIndex(index)
|
||||
else:
|
||||
# 导出上架 not implemented — snap back to the current page's tab.
|
||||
self._tab_bar.setCurrentIndex(self._stack.currentIndex())
|
||||
self.statusBar().showMessage("该功能暂未开放")
|
||||
|
||||
def _toggle_queue(self):
|
||||
@@ -530,6 +554,9 @@ class MainWindow(QMainWindow):
|
||||
self.image_list_panel.set_garment_start_dir(self._config.get("last_garment_dir", ""))
|
||||
self.image_list_panel.set_print_start_dir(self._config.get("last_print_dir", ""))
|
||||
|
||||
# AI outfit panel: inject last paths/settings + load models/prompt
|
||||
self.ai_outfit_panel.apply_config(self._config)
|
||||
|
||||
# Persist future changes (connected after restore to avoid echo writes)
|
||||
self.template_panel.template_changed.connect(self._on_template_persist)
|
||||
self.queue_panel.batch_mode_changed.connect(self._on_batch_mode_persist)
|
||||
@@ -552,6 +579,11 @@ class MainWindow(QMainWindow):
|
||||
self._config["last_print_dir"] = path
|
||||
save_config(self._config)
|
||||
|
||||
def _on_outfit_config_changed(self, changes):
|
||||
"""Persist AI-outfit settings (last Excel/output/model + batch options)."""
|
||||
self._config.update(changes)
|
||||
save_config(self._config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Style (ref: docs/07-ui-design.md §10)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
"""AI 穿搭页签(docs/11 §10)。
|
||||
|
||||
以 Excel 为数据源,逐行调 AI 图像 API 生成「人物穿着该衣服」的效果图,
|
||||
写回 Excel D/E/F。界面分三栏:左设置 / 中(最近结果 + 处理明细)/ 右运行日志。
|
||||
|
||||
后台用 QThread + Worker(QObject) 包住 core.outfit_batch.OutfitBatchRunner,
|
||||
通过 Qt signal 回主线程刷新 UI(子线程不直接碰控件)。
|
||||
"""
|
||||
import logging
|
||||
|
||||
from PySide6.QtCore import QObject, QSize, Qt, QThread, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSpinBox,
|
||||
QSplitter,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from services.config_service import (
|
||||
load_ai_models,
|
||||
load_outfit_prompt,
|
||||
save_outfit_prompt,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RESOLUTIONS = ["512", "1K", "2K", "4K"]
|
||||
_QUALITIES = ["小文件", "均衡", "高清"]
|
||||
_COLS = ["行", "标题", "货号", "衣服图", "状态", "结果 / 原因"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background worker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _OutfitWorker(QObject):
|
||||
"""Runs the batch on a QThread; reports back via queued signals."""
|
||||
|
||||
tasks_loaded = Signal(object) # List[OutfitTask]
|
||||
log = Signal(str)
|
||||
progress = Signal(int, int, object) # completed, total, OutfitResult
|
||||
finished = Signal(object) # OutfitBatchSummary
|
||||
failed = Signal(str) # fatal pre-run error (e.g. Excel locked)
|
||||
|
||||
def __init__(self, excel_path, output_dir, model_config, prompt,
|
||||
options, resolution, quality, retry_failed):
|
||||
super().__init__()
|
||||
self._excel_path = excel_path
|
||||
self._output_dir = output_dir
|
||||
self._model_config = model_config
|
||||
self._prompt = prompt
|
||||
self._options = options
|
||||
self._resolution = resolution
|
||||
self._quality = quality
|
||||
self._retry_failed = retry_failed
|
||||
self._runner = None
|
||||
|
||||
def stop(self):
|
||||
if self._runner is not None:
|
||||
self._runner.stop()
|
||||
|
||||
def run(self):
|
||||
# Imported lazily so the UI thread never pulls in Pillow/requests at import.
|
||||
from core.ai_outfit import generate_outfit_image
|
||||
from core.outfit_batch import OutfitBatchRunner, OutfitBatchSummary
|
||||
from services.excel_service import (
|
||||
ensure_excel_writable,
|
||||
load_outfit_tasks,
|
||||
write_outfit_result,
|
||||
)
|
||||
|
||||
try:
|
||||
ensure_excel_writable(self._excel_path)
|
||||
tasks = load_outfit_tasks(self._excel_path, retry_failed=self._retry_failed)
|
||||
except Exception as exc: # noqa: BLE001 - report to UI
|
||||
self.failed.emit(str(exc))
|
||||
return
|
||||
|
||||
self.tasks_loaded.emit(tasks)
|
||||
if not tasks:
|
||||
self.finished.emit(OutfitBatchSummary(total=0))
|
||||
return
|
||||
|
||||
def gen(task):
|
||||
return generate_outfit_image(
|
||||
task, self._prompt, self._output_dir, self._model_config,
|
||||
quality=self._quality, resolution=self._resolution,
|
||||
)
|
||||
|
||||
def on_progress(completed, total, result):
|
||||
try:
|
||||
write_outfit_result(self._excel_path, result)
|
||||
except Exception as exc: # noqa: BLE001 - keep going
|
||||
self.log.emit("⚠ 写回 Excel 失败(第 {} 行):{}".format(
|
||||
result.task.row_index, exc))
|
||||
self.progress.emit(completed, total, result)
|
||||
|
||||
self._runner = OutfitBatchRunner(
|
||||
tasks, gen, options=self._options,
|
||||
progress_callback=on_progress, log_callback=self.log.emit,
|
||||
)
|
||||
try:
|
||||
summary = self._runner.run()
|
||||
except Exception as exc: # noqa: BLE001 - report to UI
|
||||
self.failed.emit(str(exc))
|
||||
return
|
||||
self.finished.emit(summary)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview dialog (最终提示词预览)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _PromptPreviewDialog(QDialog):
|
||||
def __init__(self, parent, template, tasks):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("最终提示词预览")
|
||||
self.resize(560, 420)
|
||||
self._template = template
|
||||
self._tasks = tasks
|
||||
|
||||
col = QVBoxLayout(self)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(QLabel("样本行:"))
|
||||
self._combo = QComboBox()
|
||||
for t in tasks:
|
||||
self._combo.addItem("第 {} 行 · {} · {}".format(
|
||||
t.row_index, t.product_id, t.title), t)
|
||||
self._combo.currentIndexChanged.connect(self._render)
|
||||
row.addWidget(self._combo, stretch=1)
|
||||
col.addLayout(row)
|
||||
|
||||
self._text = QPlainTextEdit()
|
||||
self._text.setReadOnly(True)
|
||||
col.addWidget(self._text, stretch=1)
|
||||
|
||||
self._warn = QLabel("")
|
||||
self._warn.setStyleSheet("color:#b87a00;")
|
||||
self._warn.setVisible(False)
|
||||
col.addWidget(self._warn)
|
||||
|
||||
buttons = QDialogButtonBox()
|
||||
copy_btn = buttons.addButton("复制", QDialogButtonBox.ActionRole)
|
||||
buttons.addButton("关闭", QDialogButtonBox.RejectRole)
|
||||
copy_btn.clicked.connect(self._copy)
|
||||
buttons.rejected.connect(self.reject)
|
||||
col.addWidget(buttons)
|
||||
|
||||
if "{title}" not in (template or ""):
|
||||
self._warn.setText("⚠ 话术中未检测到 {title} 占位符")
|
||||
self._warn.setVisible(True)
|
||||
self._render()
|
||||
|
||||
def _render(self):
|
||||
from core.ai_outfit import render_prompt
|
||||
task = self._combo.currentData()
|
||||
if task is None:
|
||||
self._text.setPlainText(self._template or "")
|
||||
return
|
||||
self._text.setPlainText(render_prompt(self._template or "", task))
|
||||
|
||||
def _copy(self):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
QApplication.clipboard().setText(self._text.toPlainText())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main panel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AiOutfitPanel(QWidget):
|
||||
"""The "2 AI 穿搭" page."""
|
||||
|
||||
# Emitted when an outfit setting changes; MainWindow merges + persists it.
|
||||
config_changed = Signal(dict)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._models = [] # list of model dicts from ai_models.json
|
||||
self._thread = None
|
||||
self._worker = None
|
||||
self._row_to_table = {} # excel row_index -> table row
|
||||
self._failures = [] # list of OutfitResult (failed)
|
||||
self._build_ui()
|
||||
|
||||
# -- construction ---------------------------------------------------
|
||||
|
||||
def _build_ui(self):
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
splitter.setHandleWidth(1)
|
||||
splitter.addWidget(self._build_left())
|
||||
splitter.addWidget(self._build_center())
|
||||
splitter.addWidget(self._build_right())
|
||||
splitter.setStretchFactor(0, 0)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
splitter.setStretchFactor(2, 0)
|
||||
splitter.setSizes([430, 760, 300])
|
||||
outer.addWidget(splitter)
|
||||
|
||||
def _build_left(self):
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll.setMinimumWidth(360)
|
||||
inner = QWidget()
|
||||
col = QVBoxLayout(inner)
|
||||
col.setContentsMargins(12, 12, 12, 12)
|
||||
col.setSpacing(12)
|
||||
|
||||
# 数据源
|
||||
src = QGroupBox("数据源(Excel)")
|
||||
sv = QVBoxLayout(src)
|
||||
self._excel_edit = QLineEdit()
|
||||
self._excel_edit.setPlaceholderText("选择商品表 .xlsx")
|
||||
sv.addLayout(self._path_row(self._excel_edit, self._browse_excel))
|
||||
col.addWidget(src)
|
||||
|
||||
# 输出 + 模型
|
||||
out = QGroupBox("输出")
|
||||
ov = QVBoxLayout(out)
|
||||
self._output_edit = QLineEdit()
|
||||
self._output_edit.setPlaceholderText("默认:程序旁的「合并后的图片」")
|
||||
ov.addLayout(self._path_row(self._output_edit, self._browse_output))
|
||||
ov.addWidget(QLabel("AI 模型"))
|
||||
self._model_combo = QComboBox()
|
||||
ov.addWidget(self._model_combo)
|
||||
col.addWidget(out)
|
||||
|
||||
# 通用话术
|
||||
prm = QGroupBox("通用话术")
|
||||
pv = QVBoxLayout(prm)
|
||||
self._prompt_edit = QPlainTextEdit()
|
||||
self._prompt_edit.setFixedHeight(130)
|
||||
pv.addWidget(self._prompt_edit)
|
||||
prow = QHBoxLayout()
|
||||
for text, ph in (("插入标题", "{title}"), ("插入货号", "{product_id}")):
|
||||
b = QPushButton(text)
|
||||
b.clicked.connect(lambda _=False, p=ph: self._prompt_edit.insertPlainText(p))
|
||||
prow.addWidget(b)
|
||||
preview_btn = QPushButton("预览最终提示词")
|
||||
preview_btn.clicked.connect(self._preview_prompt)
|
||||
prow.addWidget(preview_btn)
|
||||
pv.addLayout(prow)
|
||||
save_btn = QPushButton("保存话术")
|
||||
save_btn.clicked.connect(self._save_prompt)
|
||||
pv.addWidget(save_btn)
|
||||
col.addWidget(prm)
|
||||
|
||||
# 生成设置
|
||||
gen = QGroupBox("生成设置")
|
||||
gv = QVBoxLayout(gen)
|
||||
self._retry_failed_chk = QCheckBox("重试上次失败的行")
|
||||
gv.addWidget(self._retry_failed_chk)
|
||||
|
||||
from PySide6.QtWidgets import QFormLayout
|
||||
form = QFormLayout()
|
||||
self._concurrency = QSpinBox()
|
||||
self._concurrency.setRange(1, 16)
|
||||
self._interval = QDoubleSpinBox()
|
||||
self._interval.setRange(0.0, 60.0)
|
||||
self._interval.setSuffix(" 秒")
|
||||
self._cooldown = QDoubleSpinBox()
|
||||
self._cooldown.setRange(0.0, 60.0)
|
||||
self._cooldown.setSuffix(" 秒")
|
||||
self._retry_count = QSpinBox()
|
||||
self._retry_count.setRange(0, 10)
|
||||
self._resolution = QComboBox()
|
||||
self._resolution.addItems(_RESOLUTIONS)
|
||||
self._quality = QComboBox()
|
||||
self._quality.addItems(_QUALITIES)
|
||||
form.addRow("并发数", self._concurrency)
|
||||
form.addRow("新请求间隔", self._interval)
|
||||
form.addRow("单任务冷却", self._cooldown)
|
||||
form.addRow("失败重试", self._retry_count)
|
||||
form.addRow("分辨率", self._resolution)
|
||||
form.addRow("JPG 质量", self._quality)
|
||||
gv.addLayout(form)
|
||||
col.addWidget(gen)
|
||||
|
||||
col.addStretch()
|
||||
scroll.setWidget(inner)
|
||||
return scroll
|
||||
|
||||
def _path_row(self, line_edit, on_browse):
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(line_edit, stretch=1)
|
||||
btn = QPushButton("浏览…")
|
||||
btn.clicked.connect(on_browse)
|
||||
row.addWidget(btn)
|
||||
return row
|
||||
|
||||
def _build_center(self):
|
||||
wrap = QWidget()
|
||||
col = QVBoxLayout(wrap)
|
||||
col.setContentsMargins(12, 12, 12, 12)
|
||||
col.setSpacing(10)
|
||||
|
||||
col.addWidget(QLabel("最近结果(单击查看大图)"))
|
||||
self._results = QListWidget()
|
||||
self._results.setViewMode(QListWidget.IconMode)
|
||||
self._results.setFlow(QListWidget.LeftToRight)
|
||||
self._results.setWrapping(False)
|
||||
self._results.setMovement(QListWidget.Static)
|
||||
self._results.setIconSize(QSize(96, 120))
|
||||
self._results.setFixedHeight(168)
|
||||
self._results.itemClicked.connect(self._open_result)
|
||||
col.addWidget(self._results)
|
||||
|
||||
col.addWidget(QLabel("处理明细(按 Excel 行顺序,每完成一行即写回并保存)"))
|
||||
self._table = QTableWidget(0, len(_COLS))
|
||||
self._table.setHorizontalHeaderLabels(_COLS)
|
||||
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
header = self._table.horizontalHeader()
|
||||
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(5, QHeaderView.Stretch)
|
||||
col.addWidget(self._table, stretch=1)
|
||||
return wrap
|
||||
|
||||
def _build_right(self):
|
||||
wrap = QWidget()
|
||||
wrap.setMinimumWidth(280)
|
||||
col = QVBoxLayout(wrap)
|
||||
col.setContentsMargins(12, 12, 12, 12)
|
||||
col.setSpacing(10)
|
||||
|
||||
col.addWidget(QLabel("本次进度"))
|
||||
self._progress = QProgressBar()
|
||||
self._progress.setValue(0)
|
||||
col.addWidget(self._progress)
|
||||
self._stats = QLabel("完成 0 · 失败 0 · 待处理 0")
|
||||
col.addWidget(self._stats)
|
||||
|
||||
self._start_btn = QPushButton("开始生成")
|
||||
self._start_btn.setObjectName("primaryBtn")
|
||||
self._start_btn.clicked.connect(self._start)
|
||||
col.addWidget(self._start_btn)
|
||||
self._stop_btn = QPushButton("停止生成")
|
||||
self._stop_btn.setEnabled(False)
|
||||
self._stop_btn.clicked.connect(self._stop)
|
||||
col.addWidget(self._stop_btn)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self._export_fail_btn = QPushButton("导出失败清单")
|
||||
self._export_fail_btn.setEnabled(False)
|
||||
self._export_fail_btn.clicked.connect(self._export_failures)
|
||||
row.addWidget(self._export_fail_btn)
|
||||
open_btn = QPushButton("打开输出目录")
|
||||
open_btn.clicked.connect(self._open_output_dir)
|
||||
row.addWidget(open_btn)
|
||||
col.addLayout(row)
|
||||
|
||||
col.addWidget(QLabel("实时日志"))
|
||||
self._log = QPlainTextEdit()
|
||||
self._log.setReadOnly(True)
|
||||
col.addWidget(self._log, stretch=1)
|
||||
return wrap
|
||||
|
||||
# -- config wiring --------------------------------------------------
|
||||
|
||||
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", ""))
|
||||
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))
|
||||
self._retry_count.setValue(int(config.get("outfit_retry_count", 2) or 0))
|
||||
self._retry_failed_chk.setChecked(bool(config.get("outfit_retry_failed", False)))
|
||||
self._set_combo(self._resolution, config.get("outfit_resolution", "1K"))
|
||||
self._set_combo(self._quality, config.get("outfit_quality", "均衡"))
|
||||
|
||||
self._prompt_edit.setPlainText(load_outfit_prompt())
|
||||
|
||||
self._models = load_ai_models()
|
||||
self._model_combo.clear()
|
||||
if not self._models:
|
||||
self._model_combo.addItem("(未配置模型,请在 ai_models.json 添加)")
|
||||
self._model_combo.setEnabled(False)
|
||||
else:
|
||||
self._model_combo.setEnabled(True)
|
||||
for m in self._models:
|
||||
self._model_combo.addItem(m.get("name") or m.get("model") or "(未命名)")
|
||||
self._set_combo(self._model_combo, config.get("outfit_model", ""))
|
||||
|
||||
def _set_combo(self, combo, value):
|
||||
idx = combo.findText(str(value))
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
def _emit_config(self):
|
||||
self.config_changed.emit({
|
||||
"outfit_excel": self._excel_edit.text(),
|
||||
"outfit_output_dir": self._output_edit.text(),
|
||||
"outfit_model": self._model_combo.currentText() if self._models else "",
|
||||
"outfit_concurrency": self._concurrency.value(),
|
||||
"outfit_request_interval": self._interval.value(),
|
||||
"outfit_task_cooldown": self._cooldown.value(),
|
||||
"outfit_retry_count": self._retry_count.value(),
|
||||
"outfit_resolution": self._resolution.currentText(),
|
||||
"outfit_quality": self._quality.currentText(),
|
||||
"outfit_retry_failed": self._retry_failed_chk.isChecked(),
|
||||
})
|
||||
|
||||
# -- left actions ---------------------------------------------------
|
||||
|
||||
def _browse_excel(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "选择 Excel 文件", self._excel_edit.text(), "Excel 文件 (*.xlsx)")
|
||||
if path:
|
||||
self._excel_edit.setText(path)
|
||||
self._emit_config()
|
||||
|
||||
def _browse_output(self):
|
||||
path = QFileDialog.getExistingDirectory(
|
||||
self, "选择输出目录", self._output_edit.text())
|
||||
if path:
|
||||
self._output_edit.setText(path)
|
||||
self._emit_config()
|
||||
|
||||
def _save_prompt(self):
|
||||
save_outfit_prompt(self._prompt_edit.toPlainText())
|
||||
self.statusBar_message("话术已保存")
|
||||
|
||||
def _preview_prompt(self):
|
||||
tasks = self._peek_tasks()
|
||||
if tasks is None:
|
||||
return
|
||||
dlg = _PromptPreviewDialog(self, self._prompt_edit.toPlainText(), tasks)
|
||||
dlg.exec()
|
||||
|
||||
def _peek_tasks(self):
|
||||
"""Load a few tasks just for preview; returns [] if none, None on error."""
|
||||
excel = self._excel_edit.text().strip()
|
||||
if not excel:
|
||||
QMessageBox.information(self, "提示", "请先选择 Excel 文件再预览。")
|
||||
return None
|
||||
try:
|
||||
from services.excel_service import load_outfit_tasks
|
||||
return load_outfit_tasks(excel, retry_failed=self._retry_failed_chk.isChecked())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.warning(self, "读取失败", "无法读取 Excel:{}".format(exc))
|
||||
return None
|
||||
|
||||
# -- run control ----------------------------------------------------
|
||||
|
||||
def _start(self):
|
||||
if self._thread is not None:
|
||||
return
|
||||
excel = self._excel_edit.text().strip()
|
||||
if not excel:
|
||||
QMessageBox.information(self, "提示", "请先选择 Excel 文件。")
|
||||
return
|
||||
|
||||
model_config = self._selected_model_config()
|
||||
if model_config is None:
|
||||
return
|
||||
|
||||
prompt = self._prompt_edit.toPlainText()
|
||||
if "{title}" not in prompt:
|
||||
answer = QMessageBox.question(
|
||||
self, "缺少占位符",
|
||||
"话术中没有 {title} 占位符,生成时不会带入商品标题。仍要继续吗?",
|
||||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
||||
if answer != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
save_outfit_prompt(prompt)
|
||||
self._emit_config()
|
||||
|
||||
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 core.outfit_batch import OutfitBatchOptions
|
||||
options = OutfitBatchOptions(
|
||||
concurrency=self._concurrency.value(),
|
||||
request_interval=self._interval.value(),
|
||||
task_cooldown=self._cooldown.value(),
|
||||
retry_count=self._retry_count.value(),
|
||||
)
|
||||
|
||||
# reset run state
|
||||
self._table.setRowCount(0)
|
||||
self._results.clear()
|
||||
self._row_to_table = {}
|
||||
self._failures = []
|
||||
self._progress.setValue(0)
|
||||
self._log.clear()
|
||||
|
||||
self._worker = _OutfitWorker(
|
||||
excel, output_dir, model_config, prompt, options,
|
||||
self._resolution.currentText(), self._quality.currentText(),
|
||||
self._retry_failed_chk.isChecked(),
|
||||
)
|
||||
self._thread = QThread(self)
|
||||
self._worker.moveToThread(self._thread)
|
||||
self._thread.started.connect(self._worker.run)
|
||||
self._worker.tasks_loaded.connect(self._on_tasks_loaded)
|
||||
self._worker.log.connect(self._append_log)
|
||||
self._worker.progress.connect(self._on_progress)
|
||||
self._worker.finished.connect(self._on_finished)
|
||||
self._worker.failed.connect(self._on_failed)
|
||||
self._worker.finished.connect(self._thread.quit)
|
||||
self._worker.failed.connect(self._thread.quit)
|
||||
self._thread.finished.connect(self._cleanup_thread)
|
||||
self._thread.start()
|
||||
|
||||
self._set_running(True)
|
||||
|
||||
def _stop(self):
|
||||
if self._worker is not None:
|
||||
self._worker.stop()
|
||||
self._append_log("已请求停止:不再提交新任务,进行中的任务会收尾。")
|
||||
self._stop_btn.setEnabled(False)
|
||||
|
||||
def _selected_model_config(self):
|
||||
if not self._models:
|
||||
QMessageBox.warning(
|
||||
self, "未配置模型",
|
||||
"尚未配置 AI 模型。请在 ~/.cmbot/config/ai_models.json 添加后重试。")
|
||||
return None
|
||||
data = self._models[self._model_combo.currentIndex()]
|
||||
from services.ai_image_service import AiModelConfig, api_config_errors
|
||||
errors = api_config_errors(data)
|
||||
if errors:
|
||||
QMessageBox.warning(self, "模型配置有误", ";".join(errors))
|
||||
return None
|
||||
return AiModelConfig.from_dict(data)
|
||||
|
||||
def _set_running(self, running):
|
||||
self._start_btn.setEnabled(not running)
|
||||
self._stop_btn.setEnabled(running)
|
||||
self._excel_edit.setEnabled(not running)
|
||||
self._model_combo.setEnabled(not running and bool(self._models))
|
||||
|
||||
# -- worker callbacks (UI thread) -----------------------------------
|
||||
|
||||
def _on_tasks_loaded(self, tasks):
|
||||
self._table.setRowCount(len(tasks))
|
||||
for row, task in enumerate(tasks):
|
||||
self._row_to_table[task.row_index] = row
|
||||
self._set_cell(row, 0, str(task.row_index))
|
||||
self._set_cell(row, 1, task.title)
|
||||
self._set_cell(row, 2, task.product_id)
|
||||
self._set_cell(row, 3, _basename(task.garment_path))
|
||||
self._set_cell(row, 4, "待处理")
|
||||
self._set_cell(row, 5, "—")
|
||||
self._progress.setMaximum(max(1, len(tasks)))
|
||||
self._update_stats(0, 0, len(tasks))
|
||||
self._append_log("已加载 {} 行待处理任务".format(len(tasks)))
|
||||
|
||||
def _on_progress(self, completed, total, result):
|
||||
row = self._row_to_table.get(result.task.row_index)
|
||||
if row is not None:
|
||||
if result.success:
|
||||
self._set_cell(row, 4, "完成")
|
||||
self._set_cell(row, 5, result.output_path)
|
||||
else:
|
||||
self._set_cell(row, 4, "失败")
|
||||
self._set_cell(row, 5, result.error)
|
||||
self._progress.setValue(completed)
|
||||
|
||||
if result.success:
|
||||
self._add_result_thumb(result)
|
||||
else:
|
||||
self._failures.append(result)
|
||||
self._export_fail_btn.setEnabled(True)
|
||||
|
||||
failed = len(self._failures)
|
||||
self._update_stats(completed - failed, failed, total - completed)
|
||||
|
||||
def _on_finished(self, summary):
|
||||
self._set_running(False)
|
||||
msg = "完成 {},失败 {}{}".format(
|
||||
summary.success_count, summary.failure_count,
|
||||
"(已停止)" if getattr(summary, "stopped", False) else "")
|
||||
self._append_log("批量结束:" + msg)
|
||||
QMessageBox.information(self, "AI 穿搭", "本次生成结束。\n" + msg)
|
||||
|
||||
def _on_failed(self, message):
|
||||
self._set_running(False)
|
||||
self._append_log("无法开始:" + message)
|
||||
QMessageBox.warning(self, "无法开始", message)
|
||||
|
||||
def _cleanup_thread(self):
|
||||
self._thread = None
|
||||
self._worker = None
|
||||
|
||||
# -- helpers --------------------------------------------------------
|
||||
|
||||
def _add_result_thumb(self, result):
|
||||
pix = QPixmap(result.output_path)
|
||||
item = QListWidgetItem(result.task.product_id)
|
||||
if not pix.isNull():
|
||||
item.setIcon(QIcon(pix))
|
||||
item.setData(Qt.UserRole, result.output_path)
|
||||
self._results.insertItem(0, item)
|
||||
|
||||
def _open_result(self, item):
|
||||
path = item.data(Qt.UserRole)
|
||||
if path:
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
|
||||
|
||||
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())
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
|
||||
|
||||
def _export_failures(self):
|
||||
if not self._failures:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "导出失败清单", "失败清单.csv", "CSV 文件 (*.csv)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8-sig", newline="") as f:
|
||||
f.write("行,标题,货号,原因\n")
|
||||
for r in self._failures:
|
||||
f.write("{},{},{},{}\n".format(
|
||||
r.task.row_index,
|
||||
_csv(r.task.title), _csv(r.task.product_id), _csv(r.error)))
|
||||
self.statusBar_message("失败清单已导出")
|
||||
except OSError as exc:
|
||||
QMessageBox.warning(self, "导出失败", str(exc))
|
||||
|
||||
def _append_log(self, message):
|
||||
self._log.appendPlainText(message)
|
||||
|
||||
def _update_stats(self, done, failed, pending):
|
||||
self._stats.setText("完成 {} · 失败 {} · 待处理 {}".format(
|
||||
max(0, done), max(0, failed), max(0, pending)))
|
||||
|
||||
def _set_cell(self, row, col, text):
|
||||
self._table.setItem(row, col, QTableWidgetItem(str(text)))
|
||||
|
||||
def statusBar_message(self, text):
|
||||
win = self.window()
|
||||
if hasattr(win, "statusBar"):
|
||||
win.statusBar().showMessage(text, 4000)
|
||||
|
||||
|
||||
def _basename(path):
|
||||
import os
|
||||
return os.path.basename(str(path))
|
||||
|
||||
|
||||
def _csv(value):
|
||||
text = str(value).replace('"', '""')
|
||||
if any(c in text for c in (",", "\n", '"')):
|
||||
return '"' + text + '"'
|
||||
return text
|
||||
@@ -14,9 +14,29 @@ DEFAULT_CONFIG = {
|
||||
"update_source": "", # HTTP(S) manifest source (empty = no update check)
|
||||
"update_user": "", # HTTP Basic Auth user (empty = anonymous)
|
||||
"update_pass": "", # HTTP Basic Auth password (empty = anonymous)
|
||||
# AI 穿搭(docs/11 §11):上次路径与批量设置并入 app_config(密钥不在此,见 ai_models.json)
|
||||
"outfit_excel": "",
|
||||
"outfit_output_dir": "",
|
||||
"outfit_model": "", # last-selected model name
|
||||
"outfit_concurrency": 1,
|
||||
"outfit_request_interval": 2.0,
|
||||
"outfit_task_cooldown": 1.0,
|
||||
"outfit_retry_count": 2,
|
||||
"outfit_resolution": "1K",
|
||||
"outfit_quality": "均衡",
|
||||
"outfit_retry_failed": False,
|
||||
}
|
||||
|
||||
_CONFIG_FILENAME = "app_config.json"
|
||||
_AI_MODELS_FILENAME = "ai_models.json"
|
||||
_OUTFIT_PROMPT_FILENAME = "outfit_prompt.txt"
|
||||
|
||||
# Default outfit prompt (docs/11 §7). Persisted to outfit_prompt.txt on first save.
|
||||
DEFAULT_OUTFIT_PROMPT = (
|
||||
"为商品「{title}」(货号 {product_id})生成人物上身实穿效果图:真人模特正面"
|
||||
"穿着这件衣服,完整保留款式、版型、颜色与印花图案,自然光、纯色棚拍背景,"
|
||||
"电商主图风格,不加文字与促销标签。"
|
||||
)
|
||||
|
||||
|
||||
def load_config():
|
||||
@@ -65,3 +85,56 @@ def save_config(data):
|
||||
logger.info("Config saved to %s", config_file)
|
||||
except OSError as exc:
|
||||
logger.error("Failed to save config to %s: %s", config_file, exc)
|
||||
|
||||
|
||||
def load_ai_models():
|
||||
"""Load AI model configs from ai_models.json.
|
||||
|
||||
Returns a list of dicts (each with a ``name`` plus AiModelConfig fields).
|
||||
Missing/corrupt file → empty list (the UI then prompts the admin to add one).
|
||||
Keys live only in ~/.cmbot and are never committed (docs/11 §11).
|
||||
"""
|
||||
from services.file_service import get_config_path
|
||||
models_file = get_config_path(_AI_MODELS_FILENAME)
|
||||
if not models_file.exists():
|
||||
logger.info("AI models file not found: %s", models_file)
|
||||
return []
|
||||
try:
|
||||
with open(str(models_file), encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError, OSError) as exc:
|
||||
logger.warning("AI models file unreadable (%s): %s", exc, models_file)
|
||||
return []
|
||||
|
||||
models = data.get("models") if isinstance(data, dict) else data
|
||||
if not isinstance(models, list):
|
||||
logger.warning("AI models file has no model list: %s", models_file)
|
||||
return []
|
||||
return [m for m in models if isinstance(m, dict)]
|
||||
|
||||
|
||||
def load_outfit_prompt():
|
||||
"""Return the saved outfit prompt template, or the built-in default."""
|
||||
from services.file_service import get_config_path
|
||||
prompt_file = get_config_path(_OUTFIT_PROMPT_FILENAME)
|
||||
if not prompt_file.exists():
|
||||
return DEFAULT_OUTFIT_PROMPT
|
||||
try:
|
||||
with open(str(prompt_file), encoding="utf-8-sig") as f:
|
||||
return f.read()
|
||||
except OSError as exc:
|
||||
logger.warning("Outfit prompt unreadable (%s): %s", exc, prompt_file)
|
||||
return DEFAULT_OUTFIT_PROMPT
|
||||
|
||||
|
||||
def save_outfit_prompt(text):
|
||||
"""Persist the outfit prompt template (utf-8, no BOM). Does not raise."""
|
||||
from services.file_service import get_config_path
|
||||
prompt_file = get_config_path(_OUTFIT_PROMPT_FILENAME)
|
||||
try:
|
||||
prompt_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(str(prompt_file), "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
logger.info("Outfit prompt saved to %s", prompt_file)
|
||||
except OSError as exc:
|
||||
logger.error("Failed to save outfit prompt to %s: %s", prompt_file, exc)
|
||||
|
||||
@@ -1063,16 +1063,18 @@
|
||||
|
||||
前置阅读:`docs/11-ai-outfit.md`(§10)、`docs/07-ui-design.md`(§4.2)、`docs/ui-ai-outfit.png`
|
||||
|
||||
- [ ] 页签栏接 `QStackedWidget`(「1 添加印花」=现有工作区,「2 AI 穿搭」=新面板),启用当前禁用的页签
|
||||
- [ ] `app/widgets/ai_outfit_panel.py`:左设置(Excel/输出/模型/话术编辑+保存+插入占位符+预览弹窗/生成设置 3 列)、中(最近结果缩略图条 + 处理明细表)、右(进度环+统计+开始/停止+导出失败清单+实时日志)
|
||||
- [ ] 接线后台线程、日志、进度、结束摘要、失败清单导出
|
||||
- [x] 页签栏接 `QStackedWidget`(页 0=添加印花工作区+队列,页 1=AI 穿搭面板);启用页签 1(导出上架仍禁用)
|
||||
- [x] `app/widgets/ai_outfit_panel.py`:左设置(Excel/输出/模型/话术编辑+保存+插入占位符+预览弹窗/生成设置)、中(最近结果缩略图 + 处理明细表)、右(进度+统计+开始/停止+导出失败清单+打开输出目录+实时日志)
|
||||
- [x] 接线后台线程:`QThread` + `_OutfitWorker(QObject)` 包 `OutfitBatchRunner`,signal 回主线程刷日志/进度/明细/缩略图;每行写回 Excel;结束摘要弹窗;失败清单导出 CSV
|
||||
- [ ] GUI 实测(真机):选 Excel/模型跑通、停止生效、失败清单正确(离屏冒烟已过,待真机)
|
||||
|
||||
### 19.4 配置与提示词 — docs/11 §14 阶段 4
|
||||
|
||||
前置阅读:`docs/11-ai-outfit.md`(§7、§11)
|
||||
|
||||
- [ ] `~/.cmbot/config/ai_models.json`(密钥明文、**不入库**)、`outfit_prompt.txt`、并入 `app_config.json`(上次 Excel/输出路径、批量设置),统一经 `config_service`
|
||||
- [ ] `requirements.txt` 增 `requests>=2.31,<3`、`openpyxl>=3.1,<4`、`urllib3<2`;`docs/03` 增列依赖
|
||||
- [x] `config_service` 增 `load_ai_models()` / `load_outfit_prompt()` / `save_outfit_prompt()`;`ai_models.json`(密钥明文、**不入库**、BOM 容错)、`outfit_prompt.txt`(无 BOM)、`app_config.json` 并入 `outfit_*`(上次 Excel/输出/模型 + 批量设置),UI 经 `config_changed` 信号回主窗口集中存;`tests/test_config_service.py` 8 用例
|
||||
- [x] `requirements.txt` 已锁 `requests 2.31`/`openpyxl 3.1.3`/`urllib3 1.26`;`docs/03` 增列依赖
|
||||
- [ ] 可选:应用内 AI 模型编辑界面(当前由管理员预置 `ai_models.json`,暂不做)
|
||||
|
||||
### 19.5 真机联调 — docs/11 §14 阶段 5
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests for AI-outfit config helpers in config_service (no GUI)."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
import services.config_service as cs
|
||||
|
||||
|
||||
class TestOutfitConfigHelpers(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)
|
||||
self.config_dir = self.tmp / "config"
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
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)
|
||||
|
||||
# -- ai_models.json -------------------------------------------------
|
||||
|
||||
def test_load_ai_models_missing_returns_empty(self):
|
||||
self.assertEqual(cs.load_ai_models(), [])
|
||||
|
||||
def test_load_ai_models_object_with_models_list(self):
|
||||
(self.config_dir / "ai_models.json").write_text(
|
||||
json.dumps({"models": [{"name": "m1", "url": "https://x"}]}),
|
||||
encoding="utf-8")
|
||||
models = cs.load_ai_models()
|
||||
self.assertEqual(len(models), 1)
|
||||
self.assertEqual(models[0]["name"], "m1")
|
||||
|
||||
def test_load_ai_models_bare_list(self):
|
||||
(self.config_dir / "ai_models.json").write_text(
|
||||
json.dumps([{"name": "a"}, {"name": "b"}]), encoding="utf-8")
|
||||
self.assertEqual(len(cs.load_ai_models()), 2)
|
||||
|
||||
def test_load_ai_models_corrupt_returns_empty(self):
|
||||
(self.config_dir / "ai_models.json").write_text("{ not json", encoding="utf-8")
|
||||
self.assertEqual(cs.load_ai_models(), [])
|
||||
|
||||
def test_load_ai_models_tolerates_bom(self):
|
||||
(self.config_dir / "ai_models.json").write_bytes(
|
||||
"".encode("utf-8") + json.dumps([{"name": "z"}]).encode("utf-8"))
|
||||
self.assertEqual(cs.load_ai_models()[0]["name"], "z")
|
||||
|
||||
# -- outfit_prompt.txt ----------------------------------------------
|
||||
|
||||
def test_prompt_default_when_missing(self):
|
||||
self.assertEqual(cs.load_outfit_prompt(), cs.DEFAULT_OUTFIT_PROMPT)
|
||||
|
||||
def test_prompt_save_then_load_roundtrip(self):
|
||||
cs.save_outfit_prompt("hello {title} {product_id}")
|
||||
self.assertEqual(cs.load_outfit_prompt(), "hello {title} {product_id}")
|
||||
|
||||
def test_prompt_saved_without_bom(self):
|
||||
cs.save_outfit_prompt("abc")
|
||||
raw = (self.config_dir / "outfit_prompt.txt").read_bytes()
|
||||
self.assertFalse(raw.startswith(b"\xef\xbb\xbf"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user