Files
cmbot/src/app/widgets/export_panel.py
T
adminandClaude Opus 4.8 7a1b9d8330 feat: print name in output filename; export panel trim; queue template fix
- Output filename now includes the print: <garment>_<print>.<ext>, e.g.
  1_TY030.png (folder grouping unchanged) (§17.12).
- Hide the single-export button (export via the queue) and remove the
  orphaned static export hint label (§17.13).
- Fix: selecting a non-fine-tuned queue item now lays the print out per the
  selected template (apply_current) instead of load_print's centred default;
  still guarded by _loading_queue_item so it isn't marked 已微调 (§17.14).

Docs: PRD 6.6/6.8/9, UI design 7.6/7.7/10/12.4, tasks.md 17.12–17.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:51:52 +08:00

266 lines
9.4 KiB
Python

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;
}
""")