Files
cmbot/src/app/widgets/queue_panel.py
T

461 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
""")