feat: implement image list panel UI for garment and print assets

Implement ImageListPanel with two _AssetPanel sub-widgets (garment/print)
separated by a vertical QSplitter:

- _AssetItemWidget: checkbox (batch select) + 52px thumbnail + subfolder
  label + filename; WA_StyledBackground enables preview highlight (#cce4f7)
- _AssetPanel: open-folder button → scan_image_folder(); stats row with
  tristate 全选 checkbox + "含子文件夹·共N 已选M" label + sort selector
  (文件夹+名称 / 名称); QListWidget with NoSelection mode so preview
  highlight and batch checkbox are visually distinct
- ImageListPanel: exposes garment_preview_changed, print_preview_changed,
  garments_changed, prints_changed signals for downstream panels

File scanning delegates to services/file_service.scan_image_folder();
no scanning logic duplicated in UI layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 08:44:17 +08:00
co-authored by Claude Sonnet 4.6
parent 0887cb35aa
commit 94d30ab183
2 changed files with 448 additions and 15 deletions
+435 -2
View File
@@ -1,5 +1,438 @@
from PySide6.QtWidgets import QWidget
import logging
from pathlib import Path
from typing import List, Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QSizePolicy,
QSplitter,
QVBoxLayout,
QWidget,
)
from core.models import ImageAsset
from services.file_service import scan_image_folder
logger = logging.getLogger(__name__)
_THUMB_SIZE = 52 # thumbnail size in px
# CheckState int values (avoids enum import issues across PySide6 versions)
_UNCHECKED = 0
_PARTIAL = 1
_CHECKED = 2
class _AssetItemWidget(QWidget):
"""Single row: checkbox + thumbnail + subfolder label + filename."""
check_changed = Signal()
def __init__(
self,
asset: ImageAsset,
root_folder: Optional[Path] = None,
parent=None,
):
super().__init__(parent)
self._asset = asset
self._root_folder = root_folder
# Required so setStyleSheet() paints the widget background
self.setAttribute(Qt.WA_StyledBackground, True)
self._setup_ui()
def _setup_ui(self):
row = QHBoxLayout(self)
row.setContentsMargins(4, 3, 4, 3)
row.setSpacing(6)
# Batch-selection checkbox
self._check = QCheckBox()
self._check.setChecked(self._asset.selected)
self._check.stateChanged.connect(self._on_check_changed)
# Thumbnail
self._thumb = QLabel()
self._thumb.setFixedSize(_THUMB_SIZE, _THUMB_SIZE)
self._thumb.setAlignment(Qt.AlignCenter)
self._thumb.setObjectName("thumbLabel")
self._load_thumbnail()
# Info column: subfolder + filename
info_col = QWidget()
info_layout = QVBoxLayout(info_col)
info_layout.setContentsMargins(0, 0, 0, 0)
info_layout.setSpacing(2)
subfolder = self._compute_subfolder()
self._folder_label = QLabel(subfolder)
self._folder_label.setObjectName("subfolderLabel")
self._folder_label.setVisible(bool(subfolder))
self._name_label = QLabel(Path(self._asset.path).name)
self._name_label.setObjectName("fileNameLabel")
self._name_label.setWordWrap(False)
info_layout.addWidget(self._folder_label)
info_layout.addWidget(self._name_label)
info_layout.addStretch()
row.addWidget(self._check, 0, Qt.AlignVCenter)
row.addWidget(self._thumb)
row.addWidget(info_col, 1)
self.setFixedHeight(64)
def _compute_subfolder(self) -> str:
asset_path = Path(self._asset.path)
if self._root_folder:
try:
rel = asset_path.parent.relative_to(self._root_folder)
s = str(rel)
return "" if s == "." else s
except ValueError:
pass
name = asset_path.parent.name
return name if name else ""
def _load_thumbnail(self):
try:
pix = QPixmap(str(self._asset.path))
if not pix.isNull():
pix = pix.scaled(
_THUMB_SIZE, _THUMB_SIZE,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self._thumb.setPixmap(pix)
return
except Exception:
pass
self._thumb.setText("?")
def _on_check_changed(self):
self._asset.selected = self._check.isChecked()
self.check_changed.emit()
@property
def asset(self) -> ImageAsset:
return self._asset
def set_checked(self, checked: bool):
self._check.blockSignals(True)
self._check.setChecked(checked)
self._check.blockSignals(False)
self._asset.selected = checked
def set_preview_selected(self, active: bool):
"""Highlight or clear the current-preview indicator."""
if active:
self.setStyleSheet("background-color: #cce4f7;")
else:
self.setStyleSheet("")
class _AssetPanel(QWidget):
"""Panel for one asset type (garment or print)."""
preview_changed = Signal(object) # ImageAsset | None
assets_changed = Signal(list) # List[ImageAsset]
def __init__(self, title: str, open_text: str, parent=None):
super().__init__(parent)
self._assets: List[ImageAsset] = []
self._root_folder: Optional[Path] = None
self._current_preview_row: int = -1
self._setup_ui(title, open_text)
# ── UI construction ────────────────────────────────────────────────
def _setup_ui(self, title: str, open_text: str):
col = QVBoxLayout(self)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(0)
col.addWidget(self._make_header(title, open_text))
col.addWidget(self._make_ctrl_row())
self._list = QListWidget()
self._list.setObjectName("assetList")
self._list.setVerticalScrollMode(QListWidget.ScrollPerPixel)
self._list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
# Disable QListWidget's own selection highlight — preview uses custom style
self._list.setSelectionMode(QListWidget.NoSelection)
self._list.setSpacing(1)
self._list.itemClicked.connect(self._on_item_clicked)
col.addWidget(self._list, 1)
def _make_header(self, title: str, open_text: str) -> QWidget:
header = QWidget()
header.setObjectName("assetPanelHeader")
row = QHBoxLayout(header)
row.setContentsMargins(8, 5, 8, 5)
title_lbl = QLabel(title)
title_lbl.setObjectName("assetPanelTitle")
self._open_btn = QPushButton(open_text)
self._open_btn.setObjectName("openFolderBtn")
self._open_btn.clicked.connect(self._open_folder)
row.addWidget(title_lbl)
row.addStretch()
row.addWidget(self._open_btn)
return header
def _make_ctrl_row(self) -> QWidget:
ctrl = QWidget()
ctrl.setObjectName("ctrlRow")
row = QHBoxLayout(ctrl)
row.setContentsMargins(8, 3, 8, 3)
row.setSpacing(4)
self._select_all_cb = QCheckBox("全选")
self._select_all_cb.setTristate(True)
self._select_all_cb.stateChanged.connect(self._on_select_all_changed)
self._stats_lbl = QLabel("共 0")
self._stats_lbl.setObjectName("statsLabel")
self._stats_lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
self._sort_combo = QComboBox()
self._sort_combo.setObjectName("sortCombo")
self._sort_combo.addItem("文件夹+名称")
self._sort_combo.addItem("名称")
self._sort_combo.currentIndexChanged.connect(self._apply_sort)
row.addWidget(self._select_all_cb)
row.addWidget(self._stats_lbl, 1)
row.addWidget(self._sort_combo)
return ctrl
# ── folder scanning ────────────────────────────────────────────────
def _open_folder(self):
folder = QFileDialog.getExistingDirectory(self, "选择文件夹")
if not folder:
return
self._root_folder = Path(folder)
try:
self._assets = scan_image_folder(self._root_folder)
except OSError as exc:
logger.error("Failed to scan folder %s: %s", folder, exc)
return
logger.info("Loaded %d asset(s) from %s", len(self._assets), folder)
self._apply_sort()
self.assets_changed.emit(list(self._assets))
# ── sorting & list rebuild ─────────────────────────────────────────
def _apply_sort(self):
if self._sort_combo.currentIndex() == 0: # 文件夹+名称
self._assets.sort(
key=lambda a: (
str(Path(a.path).parent),
str(Path(a.path).name).lower(),
)
)
else: # 名称
self._assets.sort(key=lambda a: str(Path(a.path).name).lower())
self._rebuild_list()
def _rebuild_list(self):
self._list.clear()
self._current_preview_row = -1
for asset in self._assets:
item = QListWidgetItem()
widget = _AssetItemWidget(asset, self._root_folder)
widget.check_changed.connect(self._on_check_changed)
item.setSizeHint(widget.sizeHint())
self._list.addItem(item)
self._list.setItemWidget(item, widget)
self._refresh_stats()
self._refresh_select_all()
# ── interaction ────────────────────────────────────────────────────
def _on_item_clicked(self, item: QListWidgetItem):
row = self._list.row(item)
# Clear previous preview highlight
if 0 <= self._current_preview_row < self._list.count():
prev = self._list.item(self._current_preview_row)
if prev:
w = self._list.itemWidget(prev)
if w:
w.set_preview_selected(False)
self._current_preview_row = row
widget = self._list.itemWidget(item)
if widget:
widget.set_preview_selected(True)
self.preview_changed.emit(widget.asset)
def _on_check_changed(self):
self._refresh_stats()
self._refresh_select_all()
self.assets_changed.emit(list(self._assets))
def _on_select_all_changed(self, state: int):
if state == _PARTIAL: # programmatic tristate — ignore
return
checked = (state == _CHECKED)
for i in range(self._list.count()):
w = self._list.itemWidget(self._list.item(i))
if w:
w.set_checked(checked)
self._refresh_stats()
self.assets_changed.emit(list(self._assets))
# ── stat helpers ───────────────────────────────────────────────────
def _has_subfolders(self) -> bool:
if not self._root_folder:
return False
return any(
Path(a.path).parent != self._root_folder for a in self._assets
)
def _refresh_stats(self):
n = len(self._assets)
sel = sum(1 for a in self._assets if a.selected)
prefix = "含子文件夹 · " if self._has_subfolders() else ""
self._stats_lbl.setText("{}共 {} 已选 {}".format(prefix, n, sel))
def _refresh_select_all(self):
n = len(self._assets)
self._select_all_cb.blockSignals(True)
if n == 0:
self._select_all_cb.setCheckState(Qt.Unchecked)
else:
sel = sum(1 for a in self._assets if a.selected)
if sel == 0:
self._select_all_cb.setCheckState(Qt.Unchecked)
elif sel == n:
self._select_all_cb.setCheckState(Qt.Checked)
else:
self._select_all_cb.setCheckState(Qt.PartiallyChecked)
self._select_all_cb.blockSignals(False)
def get_assets(self) -> List[ImageAsset]:
return list(self._assets)
class ImageListPanel(QWidget):
pass
"""Left-panel container: garment list (top) + print list (bottom)."""
garment_preview_changed = Signal(object) # ImageAsset | None
print_preview_changed = Signal(object) # ImageAsset | None
garments_changed = Signal(list) # List[ImageAsset]
prints_changed = Signal(list) # List[ImageAsset]
def __init__(self, parent=None):
super().__init__(parent)
self._setup_ui()
def _setup_ui(self):
col = QVBoxLayout(self)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(0)
splitter = QSplitter(Qt.Vertical)
splitter.setHandleWidth(3)
splitter.setObjectName("assetSplitter")
self._garment_panel = _AssetPanel("衣服图片", "打开衣服文件夹")
self._print_panel = _AssetPanel("印花图片", "打开印花文件夹")
self._garment_panel.preview_changed.connect(self.garment_preview_changed)
self._print_panel.preview_changed.connect(self.print_preview_changed)
self._garment_panel.assets_changed.connect(self.garments_changed)
self._print_panel.assets_changed.connect(self.prints_changed)
splitter.addWidget(self._garment_panel)
splitter.addWidget(self._print_panel)
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 1)
col.addWidget(splitter)
self._apply_stylesheet()
def _apply_stylesheet(self):
self.setStyleSheet("""
#assetPanelHeader {
background-color: #f7f7f7;
border-bottom: 1px solid #e0e0e0;
}
#assetPanelTitle {
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 13px;
font-weight: bold;
color: #1a1a1a;
}
#openFolderBtn {
font-size: 12px;
padding: 2px 8px;
border: 1px solid #d6d6d6;
border-radius: 3px;
background: #ffffff;
}
#openFolderBtn:hover { background: #e8e8e8; }
#ctrlRow {
background-color: #fafafa;
border-bottom: 1px solid #eeeeee;
}
#statsLabel {
font-size: 11px;
color: #666666;
}
#sortCombo {
font-size: 11px;
max-width: 96px;
}
#assetList {
background-color: #ffffff;
border: none;
}
#assetList::item {
background: transparent;
border: none;
}
#subfolderLabel {
font-size: 10px;
color: #888888;
}
#fileNameLabel {
font-size: 12px;
color: #1a1a1a;
}
#thumbLabel {
background-color: #f0f0f0;
border: 1px solid #e0e0e0;
border-radius: 2px;
color: #aaaaaa;
font-size: 16px;
}
QSplitter#assetSplitter::handle {
background-color: #d6d6d6;
height: 3px;
}
""")
def get_garments(self) -> List[ImageAsset]:
return self._garment_panel.get_assets()
def get_prints(self) -> List[ImageAsset]:
return self._print_panel.get_assets()