diff --git a/src/app/main_window.py b/src/app/main_window.py index b330ee5..57c3e84 100644 --- a/src/app/main_window.py +++ b/src/app/main_window.py @@ -17,6 +17,7 @@ from version import APP_NAME, APP_VERSION from app.widgets.export_panel import ExportPanel from app.widgets.image_canvas import ImageCanvas from app.widgets.image_list_panel import ImageListPanel +from app.widgets.queue_panel import QueuePanel from app.widgets.template_panel import TemplatePanel from app.widgets.transform_panel import TransformPanel @@ -59,6 +60,7 @@ class MainWindow(QMainWindow): self.setCentralWidget(root) self._apply_stylesheet() + self._connect_signals() def _create_header(self): """Top header: app name, version label, settings button.""" @@ -210,12 +212,11 @@ class MainWindow(QMainWindow): self._queue_body.setObjectName("queueBody") body_layout = QVBoxLayout(self._queue_body) - body_layout.setContentsMargins(12, 8, 12, 8) + body_layout.setContentsMargins(0, 0, 0, 0) + body_layout.setSpacing(0) - placeholder = QLabel("合成队列(实现中)") - placeholder.setAlignment(Qt.AlignCenter) - placeholder.setObjectName("queuePlaceholder") - body_layout.addWidget(placeholder) + self.queue_panel = QueuePanel() + body_layout.addWidget(self.queue_panel) self._queue_expanded = True return self._queue_body @@ -240,6 +241,26 @@ class MainWindow(QMainWindow): self._queue_container.setFixedHeight(36) self._collapse_btn.setText("▼") + # ------------------------------------------------------------------ + # Signal wiring + # ------------------------------------------------------------------ + + def _connect_signals(self): + """Connect inter-panel signals needed for the queue workflow.""" + # Asset lists → queue rebuilds automatically + self.image_list_panel.garments_changed.connect(self.queue_panel.set_garments) + self.image_list_panel.prints_changed.connect(self.queue_panel.set_prints) + # Queue row selection → preview canvas + self.queue_panel.item_activated.connect(self._on_queue_item_activated) + + def _on_queue_item_activated(self, item): + """Load garment and print from a selected queue item into the canvas.""" + self.image_canvas.load_garment(str(item.garment.path)) + self.image_canvas.load_print(str(item.print_asset.path)) + if item.transform: + self.image_canvas.set_transform(item.transform) + self.transform_panel.set_transform(item.transform) + # ------------------------------------------------------------------ # Style (ref: docs/07-ui-design.md §10) # ------------------------------------------------------------------ diff --git a/src/app/widgets/queue_panel.py b/src/app/widgets/queue_panel.py new file mode 100644 index 0000000..2fa1e25 --- /dev/null +++ b/src/app/widgets/queue_panel.py @@ -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; + } + """) diff --git a/tasks.md b/tasks.md index e67ec7a..f4c5380 100644 --- a/tasks.md +++ b/tasks.md @@ -424,24 +424,24 @@ 任务: -- [ ] 实现底部合成队列表格(建议使用 `QTableView` + 模型视图结构) -- [ ] 实现队列头部批量模式切换:多衣服×单印花 / 单衣服×多印花 / 一一匹配 / 全组合(矩阵) -- [ ] 实现队列统计显示(共 N 项 · 完成 · 进行 · 失败 · 待导出) -- [ ] 实现「重置全部」按钮 -- [ ] 实现「导出选中」按钮 -- [ ] 实现「开始批量导出」按钮(导出中切换为停止/暂停) -- [ ] 实现队列折叠/展开 -- [ ] 显示任务状态(已完成 / 待导出 / 导出中 / 失败) -- [ ] 导出中任务显示进度条 -- [ ] 显示失败原因 -- [ ] 支持选中队列项并更新预览区和参数面板 +- [x] 实现底部合成队列表格(建议使用 `QTableView` + 模型视图结构) +- [x] 实现队列头部批量模式切换:多衣服×单印花 / 单衣服×多印花 / 一一匹配 / 全组合(矩阵) +- [x] 实现队列统计显示(共 N 项 · 完成 · 进行 · 失败 · 待导出) +- [x] 实现「重置全部」按钮 +- [x] 实现「导出选中」按钮 +- [x] 实现「开始批量导出」按钮(导出中切换为停止/暂停) +- [x] 实现队列折叠/展开 +- [x] 显示任务状态(已完成 / 待导出 / 导出中 / 失败) +- [x] 导出中任务显示进度条 +- [x] 显示失败原因 +- [x] 支持选中队列项并更新预览区和参数面板 验收: -- [ ] 队列统计显示总数、完成、进行、失败、待导出 -- [ ] 导出中任务显示进度条 -- [ ] 失败行显示可理解原因 -- [ ] 选中队列项可以加载对应状态 +- [x] 队列统计显示总数、完成、进行、失败、待导出 +- [x] 导出中任务显示进度条 +- [x] 失败行显示可理解原因 +- [x] 选中队列项可以加载对应状态 ## 14. 微调状态