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:
@@ -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):
|
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()
|
||||||
|
|||||||
@@ -290,22 +290,22 @@
|
|||||||
|
|
||||||
任务:
|
任务:
|
||||||
|
|
||||||
- [ ] 先读取 `src/app/widgets/image_list_panel.py` 现有内容
|
- [x] 先读取 `src/app/widgets/image_list_panel.py` 现有内容
|
||||||
- [ ] 实现衣服图片列表
|
- [x] 实现衣服图片列表
|
||||||
- [ ] 实现印花图片列表
|
- [x] 实现印花图片列表
|
||||||
- [ ] 实现打开文件夹按钮
|
- [x] 实现打开文件夹按钮
|
||||||
- [ ] 实现全选和取消全选
|
- [x] 实现全选和取消全选
|
||||||
- [ ] 实现单张勾选和取消勾选
|
- [x] 实现单张勾选和取消勾选
|
||||||
- [ ] 显示已选数量和总数量(例如:`含子文件夹 · 共 12`)
|
- [x] 显示已选数量和总数量(例如:`含子文件夹 · 共 12`)
|
||||||
- [ ] 显示来源子文件夹标签和文件名
|
- [x] 显示来源子文件夹标签和文件名
|
||||||
- [ ] 实现排序选择器(默认按文件夹+名称排序)
|
- [x] 实现排序选择器(默认按文件夹+名称排序)
|
||||||
- [ ] 点击素材后更新当前预览选择
|
- [x] 点击素材后更新当前预览选择
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- [ ] 支持递归导入素材
|
- [x] 支持递归导入素材
|
||||||
- [ ] 当前预览选中态和批量勾选态可区分
|
- [x] 当前预览选中态和批量勾选态可区分
|
||||||
- [ ] 不在 UI 层重复实现文件扫描规则
|
- [x] 不在 UI 层重复实现文件扫描规则
|
||||||
|
|
||||||
## 9. 图像预览编辑器
|
## 9. 图像预览编辑器
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user