461 lines
16 KiB
Python
461 lines
16 KiB
Python
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;
|
||
}
|
||
""")
|