import logging from dataclasses import dataclass from pathlib import Path from typing import List, Optional, Tuple from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QApplication, QComboBox, QHeaderView, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QStyle, QStyledItemDelegate, QStyleOptionProgressBar, QTableView, QVBoxLayout, QWidget, ) from core.composer import compose from core.models import BatchMode, ExportOptions, ImageAsset, TransformState from services.file_service import get_output_dir, make_safe_output_path logger = logging.getLogger(__name__) # ── column layout ───────────────────────────────────────────────────────────── _COL_NUM = 0 _COL_GARMENT = 1 _COL_PRINT = 2 _COL_FOLDER = 3 _COL_PARAM = 4 _COL_STATUS = 5 _COL_OUTPUT = 6 _HEADERS = ["#", "衣服", "印花", "来源文件夹", "参数", "状态", "进度/输出文件"] _STATUS_COLOR = { "已完成": QColor("#107c10"), "导出中": QColor("#0078d4"), "失败": QColor("#c42b1c"), "待导出": QColor("#555555"), } # ── data model ──────────────────────────────────────────────────────────────── @dataclass class QueueItem: """One entry in the composition queue.""" garment: ImageAsset print_asset: ImageAsset transform: Optional[TransformState] = None # None → use template transform param_source: str = "模板" # "模板" | "已微调" status: str = "待导出" # "待导出"|"导出中"|"已完成"|"失败" progress: float = 0.0 output_path: Optional[str] = None error: Optional[str] = None def _generate_pairs( garments: List[ImageAsset], prints: List[ImageAsset], mode: BatchMode, ) -> List[Tuple[ImageAsset, ImageAsset]]: if not garments or not prints: return [] if mode == BatchMode.ONE_TO_ONE: return list(zip(garments, prints)) if mode == BatchMode.MANY_GARMENTS: p = prints[0] return [(g, p) for g in garments] if mode == BatchMode.MANY_PRINTS: g = garments[0] return [(g, p) for p in prints] # FULL_COMBO return [(g, p) for g in garments for p in prints] class _QueueModel(QAbstractTableModel): def __init__(self, parent=None): super().__init__(parent) self._items: List[QueueItem] = [] def set_items(self, items: List[QueueItem]): self.beginResetModel() self._items = list(items) self.endResetModel() def rowCount(self, parent=QModelIndex()): return len(self._items) def columnCount(self, parent=QModelIndex()): return len(_HEADERS) def headerData(self, section, orientation, role=Qt.DisplayRole): if role == Qt.DisplayRole and orientation == Qt.Horizontal: return _HEADERS[section] return None def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return None item = self._items[index.row()] col = index.column() if role == Qt.DisplayRole: if col == _COL_NUM: return str(index.row() + 1) if col == _COL_GARMENT: return Path(item.garment.path).name if col == _COL_PRINT: return Path(item.print_asset.path).name if col == _COL_FOLDER: return Path(item.garment.path).parent.name if col == _COL_PARAM: return item.param_source if col == _COL_STATUS: return item.status if col == _COL_OUTPUT: if item.status == "已完成" and item.output_path: return Path(item.output_path).name if item.status == "失败": return item.error or "未知错误" return "" if role == Qt.UserRole and col == _COL_OUTPUT: return item.progress if item.status == "导出中" else None if role == Qt.ForegroundRole and col == _COL_STATUS: return _STATUS_COLOR.get(item.status) if role == Qt.TextAlignmentRole and col == _COL_NUM: return int(Qt.AlignCenter) return None def get_item(self, row: int) -> Optional[QueueItem]: return self._items[row] if 0 <= row < len(self._items) else None def update_row(self, row: int): if 0 <= row < len(self._items): self.dataChanged.emit( self.index(row, 0), self.index(row, len(_HEADERS) - 1), ) class _ProgressDelegate(QStyledItemDelegate): """Paints a progress bar in the output column when status is '导出中'.""" def paint(self, painter, option, index): progress = index.data(Qt.UserRole) if progress is not None: opt = QStyleOptionProgressBar() opt.rect = option.rect opt.minimum = 0 opt.maximum = 100 opt.progress = max(0, min(100, int(progress * 100))) opt.text = "{}%".format(opt.progress) opt.textVisible = True QApplication.style().drawControl(QStyle.CE_ProgressBar, opt, painter) else: super().paint(painter, option, index) # ── main widget ─────────────────────────────────────────────────────────────── class QueuePanel(QWidget): """Bottom queue: batch mode selector, stats, action buttons, item table.""" item_activated = Signal(object) # QueueItem when a row is selected def __init__(self, parent=None): super().__init__(parent) self._garments: List[ImageAsset] = [] self._prints: List[ImageAsset] = [] self._template_transform: Optional[TransformState] = None self._export_options: ExportOptions = ExportOptions() self._running = False self._setup_ui() self._apply_stylesheet() # ── UI construction ────────────────────────────────────────────────────── def _setup_ui(self): col = QVBoxLayout(self) col.setContentsMargins(0, 0, 0, 0) col.setSpacing(0) col.addWidget(self._make_ctrl_bar()) col.addWidget(self._make_table(), 1) def _make_ctrl_bar(self) -> QWidget: bar = QWidget() bar.setObjectName("queueCtrlBar") row = QHBoxLayout(bar) row.setContentsMargins(8, 4, 8, 4) row.setSpacing(8) mode_lbl = QLabel("批量模式:") mode_lbl.setObjectName("queueCtrlLabel") self._mode_combo = QComboBox() self._mode_combo.setObjectName("queueModeCombo") self._mode_combo.addItem("全组合(矩阵)", BatchMode.FULL_COMBO) self._mode_combo.addItem("多衣服 × 单印花", BatchMode.MANY_GARMENTS) self._mode_combo.addItem("单衣服 × 多印花", BatchMode.MANY_PRINTS) self._mode_combo.addItem("一一匹配", BatchMode.ONE_TO_ONE) self._mode_combo.currentIndexChanged.connect(self._rebuild_queue) self._stats_lbl = QLabel("共 0 项") self._stats_lbl.setObjectName("queueStatsLabel") self._stats_lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) self._reset_btn = QPushButton("重置全部") self._reset_btn.setObjectName("queueActionBtn") self._reset_btn.clicked.connect(self._reset_all) self._export_sel_btn = QPushButton("导出选中") self._export_sel_btn.setObjectName("queueActionBtn") self._export_sel_btn.clicked.connect(self._export_selected) self._batch_btn = QPushButton("开始批量导出") self._batch_btn.setObjectName("queueBatchBtn") self._batch_btn.clicked.connect(self._toggle_batch) row.addWidget(mode_lbl) row.addWidget(self._mode_combo) row.addWidget(self._stats_lbl, 1) row.addWidget(self._reset_btn) row.addWidget(self._export_sel_btn) row.addWidget(self._batch_btn) return bar def _make_table(self) -> QTableView: self._model = _QueueModel() self._table = QTableView() self._table.setObjectName("queueTable") self._table.setModel(self._model) self._table.setItemDelegateForColumn(_COL_OUTPUT, _ProgressDelegate()) self._table.setSelectionBehavior(QTableView.SelectRows) self._table.setSelectionMode(QTableView.SingleSelection) self._table.setAlternatingRowColors(True) self._table.setEditTriggers(QTableView.NoEditTriggers) self._table.verticalHeader().setVisible(False) self._table.verticalHeader().setDefaultSectionSize(24) hdr = self._table.horizontalHeader() hdr.setSectionResizeMode(QHeaderView.Interactive) hdr.setStretchLastSection(True) self._table.setColumnWidth(_COL_NUM, 36) self._table.setColumnWidth(_COL_GARMENT, 130) self._table.setColumnWidth(_COL_PRINT, 130) self._table.setColumnWidth(_COL_FOLDER, 100) self._table.setColumnWidth(_COL_PARAM, 56) self._table.setColumnWidth(_COL_STATUS, 66) self._table.selectionModel().currentRowChanged.connect(self._on_row_changed) return self._table # ── public API ─────────────────────────────────────────────────────────── def set_garments(self, garments: List[ImageAsset]): self._garments = garments self._rebuild_queue() def set_prints(self, prints: List[ImageAsset]): self._prints = prints self._rebuild_queue() def set_transform(self, state: TransformState): """Store the template transform applied to items that have not been fine-tuned.""" self._template_transform = state def set_export_options(self, options: ExportOptions): self._export_options = options def mark_item_fine_tuned(self, item: "QueueItem", state: TransformState): """Save a per-item transform and mark it as fine-tuned in the table.""" try: row = self._model._items.index(item) except ValueError: return item.transform = state item.param_source = "已微调" self._model.update_row(row) # ── internal slots ─────────────────────────────────────────────────────── def _rebuild_queue(self): mode = self._mode_combo.currentData() sel_g = [g for g in self._garments if g.selected] sel_p = [p for p in self._prints if p.selected] pairs = _generate_pairs(sel_g, sel_p, mode) self._model.set_items([QueueItem(garment=g, print_asset=p) for g, p in pairs]) self._update_stats() def _update_stats(self): items = self._model._items n = len(items) if n == 0: self._stats_lbl.setText("共 0 项") return done = sum(1 for i in items if i.status == "已完成") running = sum(1 for i in items if i.status == "导出中") failed = sum(1 for i in items if i.status == "失败") pending = sum(1 for i in items if i.status == "待导出") self._stats_lbl.setText( "共 {} 项 · 完成 {} · 进行 {} · 失败 {} · 待导出 {}".format( n, done, running, failed, pending ) ) def _on_row_changed(self, current, _previous): item = self._model.get_item(current.row()) if item: self.item_activated.emit(item) def _reset_all(self): for item in self._model._items: item.status = "待导出" item.progress = 0.0 item.output_path = None item.error = None self._model.set_items(list(self._model._items)) self._update_stats() def _export_selected(self): rows = self._table.selectionModel().selectedRows() if not rows: return row = rows[0].row() item = self._model.get_item(row) if item and item.status == "待导出": self._export_item(row, item) self._update_stats() def _toggle_batch(self): if self._running: self._running = False else: self._start_batch() def _start_batch(self): self._running = True self._batch_btn.setText("停止") items = self._model._items for i, item in enumerate(items): if not self._running: break if item.status != "待导出": continue self._export_item(i, item) self._update_stats() QApplication.processEvents() self._running = False self._batch_btn.setText("开始批量导出") def _export_item(self, row: int, item: QueueItem): transform = item.transform or self._template_transform if not transform: item.status = "失败" item.error = "无印花参数(请先在画布中调整印花位置)" self._model.update_row(row) return out_dir = ( self._export_options.output_dir if self._export_options.output_dir else str(get_output_dir()) ) fmt = self._export_options.output_format or "PNG" output_path = make_safe_output_path( out_dir, item.garment.path, item.print_asset.path, fmt ) item.status = "导出中" item.progress = 0.0 self._model.update_row(row) QApplication.processEvents() result = compose( item.garment.path, item.print_asset.path, transform, self._export_options, output_path, ) if result.success: item.status = "已完成" item.progress = 1.0 item.output_path = str(result.output_path) logger.info("Queue export OK: %s", result.output_path) else: item.status = "失败" item.error = result.error logger.error("Queue export failed: %s", result.error) self._model.update_row(row) # ── stylesheet ─────────────────────────────────────────────────────────── def _apply_stylesheet(self): self.setStyleSheet(""" #queueCtrlBar { background-color: #f7f7f7; border-bottom: 1px solid #e0e0e0; } #queueCtrlLabel { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; color: #444444; } #queueModeCombo { font-size: 12px; border: 1px solid #d6d6d6; border-radius: 3px; padding: 2px 4px; } #queueStatsLabel { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 11px; color: #666666; } #queueActionBtn { font-size: 12px; padding: 3px 8px; border: 1px solid #d6d6d6; border-radius: 3px; background: #f0f0f0; } #queueActionBtn:hover { background: #e0e0e0; } #queueBatchBtn { font-size: 12px; font-weight: bold; padding: 3px 12px; border: none; border-radius: 3px; color: #ffffff; background-color: #0078d4; } #queueBatchBtn:hover { background-color: #106ebe; } #queueBatchBtn:pressed { background-color: #005a9e; } #queueTable { border: none; background-color: #ffffff; alternate-background-color: #f9f9f9; font-size: 12px; gridline-color: #eeeeee; } #queueTable::item { padding: 2px 4px; } #queueTable::item:selected { background-color: #cce4f7; color: #000000; } """)