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:
@@ -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;
|
||||
}
|
||||
""")
|
||||
Reference in New Issue
Block a user