import logging from pathlib import Path from typing import Optional from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QVBoxLayout, QWidget, ) from core.composer import compose from core.models import ExportOptions, TransformState from services.file_service import ( get_output_dir, make_safe_output_path, timestamped_run_dir, ) logger = logging.getLogger(__name__) class ExportPanel(QWidget): """Right-panel section: output settings and single-image export.""" export_options_changed = Signal(object) # ExportOptions when dir/format/quality changes def __init__(self, parent=None): super().__init__(parent) self._garment_path: Optional[str] = None self._print_path: Optional[str] = None self._transform: Optional[TransformState] = None self._setup_ui() self._apply_stylesheet() # ── UI construction ────────────────────────────────────────────────────── def _setup_ui(self): col = QVBoxLayout(self) col.setContentsMargins(0, 0, 0, 0) col.setSpacing(0) header = QLabel("输出设置") header.setObjectName("exportSectionHeader") col.addWidget(header) body = QWidget() body.setObjectName("exportBody") body_col = QVBoxLayout(body) body_col.setContentsMargins(10, 8, 10, 10) body_col.setSpacing(8) body_col.addLayout(self._make_dir_row()) body_col.addLayout(self._make_format_form()) body_col.addWidget(self._make_export_btn()) col.addWidget(body) def _make_dir_row(self) -> QHBoxLayout: row = QHBoxLayout() row.setSpacing(4) self._dir_input = QLineEdit() self._dir_input.setObjectName("exportDirInput") self._dir_input.setReadOnly(True) self._dir_input.setPlaceholderText("输出目录") self._dir_input.setText(str(get_output_dir())) self._dir_input.textChanged.connect(self._emit_options) browse_btn = QPushButton("浏览…") browse_btn.setObjectName("exportBrowseBtn") browse_btn.setFixedWidth(52) browse_btn.clicked.connect(self._browse_output_dir) row.addWidget(self._dir_input, 1) row.addWidget(browse_btn) return row def _make_format_form(self) -> QHBoxLayout: row = QHBoxLayout() row.setSpacing(6) self._format_combo = QComboBox() self._format_combo.setObjectName("exportCombo") self._format_combo.addItem("PNG", "PNG") self._format_combo.addItem("JPG", "JPG") self._format_combo.currentIndexChanged.connect(self._on_format_changed) self._quality_combo = QComboBox() self._quality_combo.setObjectName("exportCombo") for label, val in [("最高 (95)", 95), ("高 (90)", 90), ("中 (80)", 80), ("低 (60)", 60)]: self._quality_combo.addItem(label, val) self._quality_combo.setCurrentIndex(1) # default: 高 (90) self._quality_combo.setEnabled(False) # enabled only for JPG self._quality_combo.currentIndexChanged.connect(self._emit_options) # 格式 and 质量 share one row (quality lights up only for JPG) row.addWidget(QLabel("格式")) row.addWidget(self._format_combo, 1) row.addWidget(QLabel("质量")) row.addWidget(self._quality_combo, 1) return row def _make_export_btn(self) -> QPushButton: self._export_btn = QPushButton("导出当前单张") self._export_btn.setObjectName("exportPrimaryBtn") self._export_btn.setEnabled(False) self._export_btn.setVisible(False) # hidden: export via the queue self._export_btn.clicked.connect(self._do_export) return self._export_btn # ── public API ─────────────────────────────────────────────────────────── def set_garment(self, path: Optional[str]): """Update current garment path; enables export button when both paths set.""" self._garment_path = path self._update_export_btn() def set_print(self, path: Optional[str]): """Update current print path; enables export button when both paths set.""" self._print_path = path self._update_export_btn() def set_transform(self, state: TransformState): """Store current print transform (used on export).""" self._transform = state def current_options(self) -> ExportOptions: """Build ExportOptions from the current output settings.""" fmt = self._format_combo.currentData() return ExportOptions( output_dir=self._dir_input.text().strip(), output_format=fmt, quality=self._quality_combo.currentData() if fmt == "JPG" else 95, ) # ── slot handlers ──────────────────────────────────────────────────────── def _emit_options(self, *_): self.export_options_changed.emit(self.current_options()) def _on_format_changed(self, _index: int): fmt = self._format_combo.currentData() self._quality_combo.setEnabled(fmt == "JPG") self._emit_options() def _browse_output_dir(self): folder = QFileDialog.getExistingDirectory( self, "选择输出目录", self._dir_input.text() ) if folder: self._dir_input.setText(folder) def _update_export_btn(self): self._export_btn.setEnabled( bool(self._garment_path and self._print_path) ) def _do_export(self): if not self._garment_path or not self._print_path: return if not self._transform: QMessageBox.information(self, "提示", "当前没有可导出的印花参数。") return options = self.current_options() out_dir = options.output_dir or str(get_output_dir()) out_dir_path = Path(out_dir) try: out_dir_path.mkdir(parents=True, exist_ok=True) except OSError as exc: logger.error("Output dir not writable: %s", exc) QMessageBox.warning( self, "目录错误", "输出目录无法创建:\n{}".format(exc), ) return # Group this export under a timestamped run folder (compose creates it) run_dir = timestamped_run_dir(out_dir) output_path = make_safe_output_path( run_dir, self._garment_path, self._print_path, options.output_format ) result = compose( self._garment_path, self._print_path, self._transform, options, output_path, ) if result.success: logger.info("Exported: %s", result.output_path) QMessageBox.information( self, "导出成功", "已导出:\n{}".format(result.output_path), ) else: logger.error("Export failed: %s", result.error) QMessageBox.warning( self, "导出失败", "导出失败:\n{}".format(result.error), ) # ── stylesheet ─────────────────────────────────────────────────────────── def _apply_stylesheet(self): self.setStyleSheet(""" #exportSectionHeader { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; font-weight: bold; color: #555555; background-color: #f7f7f7; border-bottom: 1px solid #eeeeee; padding: 4px 10px; } #exportBody { background-color: #ffffff; border-bottom: 1px solid #e0e0e0; } #exportDirInput { font-size: 11px; border: 1px solid #d6d6d6; border-radius: 3px; padding: 2px 4px; background: #fafafa; color: #444444; } #exportBrowseBtn { font-size: 12px; padding: 3px 6px; border: 1px solid #d6d6d6; border-radius: 3px; background: #f0f0f0; } #exportBrowseBtn:hover { background: #e0e0e0; } #exportCombo { font-size: 12px; border: 1px solid #d6d6d6; border-radius: 3px; padding: 2px 4px; } #exportPrimaryBtn { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 13px; font-weight: bold; color: #ffffff; background-color: #0078d4; border: none; border-radius: 4px; padding: 7px 0; } #exportPrimaryBtn:hover { background-color: #106ebe; } #exportPrimaryBtn:pressed { background-color: #005a9e; } #exportPrimaryBtn:disabled { background-color: #c8c8c8; color: #888888; } """)