diff --git a/src/app/widgets/export_panel.py b/src/app/widgets/export_panel.py index cf0a53d..fe4ac2e 100644 --- a/src/app/widgets/export_panel.py +++ b/src/app/widgets/export_panel.py @@ -1,5 +1,296 @@ -from PySide6.QtWidgets import QWidget +import logging +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QComboBox, + QFileDialog, + QFormLayout, + 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 + +logger = logging.getLogger(__name__) class ExportPanel(QWidget): - pass + """Right-panel section: output settings and export / template actions.""" + + apply_as_template = Signal() # "应用为模板" clicked + reset_to_template = Signal() # "重置为模板" clicked + + 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_hint_label()) + body_col.addWidget(self._make_export_btn()) + body_col.addLayout(self._make_secondary_btns()) + + 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())) + + 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) -> QFormLayout: + form = QFormLayout() + form.setSpacing(6) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + + 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) + form.addRow("格式", self._format_combo) + + 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 + form.addRow("质量", self._quality_combo) + + return form + + def _make_hint_label(self) -> QLabel: + self._hint_lbl = QLabel("加载衣服和印花图片后即可导出。") + self._hint_lbl.setObjectName("exportHint") + self._hint_lbl.setWordWrap(True) + return self._hint_lbl + + def _make_export_btn(self) -> QPushButton: + self._export_btn = QPushButton("导出当前单张") + self._export_btn.setObjectName("exportPrimaryBtn") + self._export_btn.setEnabled(False) + self._export_btn.clicked.connect(self._do_export) + return self._export_btn + + def _make_secondary_btns(self) -> QHBoxLayout: + row = QHBoxLayout() + row.setSpacing(6) + + apply_btn = QPushButton("应用为模板") + apply_btn.setObjectName("exportSecBtn") + apply_btn.setToolTip("将当前印花参数另存为新模板") + apply_btn.clicked.connect(lambda: self.apply_as_template.emit()) + + reset_btn = QPushButton("重置为模板") + reset_btn.setObjectName("exportSecBtn") + reset_btn.setToolTip("将印花参数还原为所选模板的默认值") + reset_btn.clicked.connect(lambda: self.reset_to_template.emit()) + + row.addWidget(apply_btn) + row.addWidget(reset_btn) + return row + + # ── 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 set_hint(self, text: str): + """Update the operation-scope hint label.""" + self._hint_lbl.setText(text) + + # ── slot handlers ──────────────────────────────────────────────────────── + + def _on_format_changed(self, _index: int): + fmt = self._format_combo.currentData() + self._quality_combo.setEnabled(fmt == "JPG") + + 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 + + out_dir = self._dir_input.text().strip() or str(get_output_dir()) + fmt = self._format_combo.currentData() + quality = self._quality_combo.currentData() if fmt == "JPG" else 95 + + 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 + + options = ExportOptions( + output_dir=out_dir, + output_format=fmt, + quality=quality, + ) + output_path = make_safe_output_path( + out_dir, self._garment_path, self._print_path, fmt + ) + + 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; + } + #exportHint { + font-size: 11px; + color: #777777; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: 3px; + padding: 4px 6px; + } + #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; + } + #exportSecBtn { + font-size: 12px; + padding: 4px 8px; + border: 1px solid #d6d6d6; + border-radius: 3px; + background: #f0f0f0; + } + #exportSecBtn:hover { background: #e0e0e0; } + #exportSecBtn:pressed { background: #d0d0d0; } + """) diff --git a/tasks.md b/tasks.md index 1c58962..e67ec7a 100644 --- a/tasks.md +++ b/tasks.md @@ -398,21 +398,21 @@ 任务: -- [ ] 先读取 `src/app/widgets/export_panel.py` 现有内容 -- [ ] 完善 `src/app/widgets/export_panel.py` -- [ ] 实现输出目录选择 -- [ ] 实现输出格式选择 -- [ ] 实现质量选择 -- [ ] 实现导出当前单张 -- [ ] 实现应用为模板入口 -- [ ] 实现重置为模板入口 -- [ ] 显示当前操作影响范围提示 +- [x] 先读取 `src/app/widgets/export_panel.py` 现有内容 +- [x] 完善 `src/app/widgets/export_panel.py` +- [x] 实现输出目录选择 +- [x] 实现输出格式选择 +- [x] 实现质量选择 +- [x] 实现导出当前单张 +- [x] 实现应用为模板入口 +- [x] 实现重置为模板入口 +- [x] 显示当前操作影响范围提示 验收: -- [ ] 缺少衣服或印花时导出按钮禁用 -- [ ] 输出目录不可写时提示用户并记录日志 -- [ ] 单张导出调用 `core/composer.py` +- [x] 缺少衣服或印花时导出按钮禁用 +- [x] 输出目录不可写时提示用户并记录日志 +- [x] 单张导出调用 `core/composer.py` ## 13. 合成队列 UI