feat: implement queue panel with table, batch modes, stats, and export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 09:25:45 +08:00
co-authored by Claude Sonnet 4.6
parent 1054ab6236
commit aeb6b59ca1
3 changed files with 491 additions and 20 deletions
+450
View File
@@ -0,0 +1,450 @@
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
# ── 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;
}
""")