/** * 素材选择器 — V21 设计系统 * 支持批量选择、拖拽排序、预览缩略图、质量分筛选、视图切换 * * 功能: * - 批量选择:checkbox 多选 + 全选/反选 + Shift 连选 * - 拖拽排序:HTML5 DnD,已选素材可拖拽调整顺序 * - 预览缩略图:悬浮放大预览(视频显示时长角标) * - 筛选增强:类型筛选 + 质量分筛选 * - 视图切换:网格视图 / 列表视图 */ import React, { useState, useMemo, useCallback, useRef, useEffect } from "react" import "./AssetSelector.css" import { Input, Select, Button } from "@/components/ui" import type { MediaAsset } from "@/api/template-editor" import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor" /* ──────────── 类型 ──────────── */ export interface AssetSelectorProps { assets: MediaAsset[] selectedIds?: string[] onSelectionChange?: (ids: string[]) => void onAssetDragStart?: (asset: MediaAsset) => void onReorder?: (fromIdx: number, toIdx: number) => void showQualityFilter?: boolean showBatchSelect?: boolean compact?: boolean } type ViewMode = "grid" | "list" /* ──────────── 工具函数 ──────────── */ /** 格式化文件大小 */ const formatSize = (bytes?: number): string => { if (!bytes) return "" if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` return `${(bytes / (1024 * 1024)).toFixed(1)}MB` } /** 格式化时长 */ const formatDuration = (seconds?: number): string => { if (!seconds) return "" const m = Math.floor(seconds / 60) const s = Math.floor(seconds % 60) return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s` } /** 获取质量分等级 */ const getQualityLevel = (score?: number): string => { if (score == null) return "none" if (score >= 90) return "excellent" if (score >= 70) return "good" if (score >= 50) return "fair" return "poor" } /** 质量分颜色 */ const getQualityColor = (score?: number): string => { if (score == null) return "var(--text-secondary)" if (score >= 90) return "var(--success-color, #10b981)" if (score >= 70) return "var(--primary-color, #6366f1)" if (score >= 50) return "var(--warning-color, #f59e0b)" return "var(--error-color, #ef4444)" } /* ──────────── 类型筛选选项 ──────────── */ const TYPE_OPTIONS = [ { value: "", label: "全部类型" }, { value: "video", label: "🎬 视频" }, { value: "image", label: "🖼️ 图片" }, { value: "audio", label: "🎵 音频" }, ] /* ──────────── 组件 ──────────── */ const AssetSelector: React.FC = ({ assets, selectedIds = [], onSelectionChange, onAssetDragStart, onReorder, showQualityFilter = true, showBatchSelect = true, compact = false, }) => { /* ── 搜索 & 筛选 ── */ const [searchText, setSearchText] = useState("") const [filterType, setFilterType] = useState("") const [filterQuality, setFilterQuality] = useState("") const [viewMode, setViewMode] = useState("grid") /* ── 拖拽状态 ── */ const [dragIdx, setDragIdx] = useState(null) const [dragOverIdx, setDragOverIdx] = useState(null) /* ── 悬浮预览 ── */ const [previewAsset, setPreviewAsset] = useState(null) const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 }) const previewTimer = useRef | null>(null) /* ── Shift 连选 ── */ const lastClickedIdx = useRef(null) /* ── 过滤后的素材列表 ── */ const filteredAssets = useMemo(() => { let list = assets if (searchText) { const q = searchText.toLowerCase() list = list.filter( (a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)), ) } if (filterType) { list = list.filter((a) => a.type === filterType) } if (filterQuality) { const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality) if (opt?.min != null && opt?.max != null) { list = list.filter( (a) => a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!, ) } } return list }, [assets, searchText, filterType, filterQuality]) /* ── 选中状态 ── */ const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) /* ── 选择操作 ── */ const toggleSelect = useCallback( (asset: MediaAsset, idx: number, shiftKey: boolean) => { if (!onSelectionChange) return if (shiftKey && lastClickedIdx.current !== null) { // Shift 连选 const start = Math.min(lastClickedIdx.current, idx) const end = Math.max(lastClickedIdx.current, idx) const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id) const newSet = new Set(selectedIds) rangeIds.forEach((id) => newSet.add(id)) onSelectionChange(Array.from(newSet)) } else { const newSet = new Set(selectedIds) if (newSet.has(asset.id)) { newSet.delete(asset.id) } else { newSet.add(asset.id) } onSelectionChange(Array.from(newSet)) } lastClickedIdx.current = idx }, [onSelectionChange, selectedIds, filteredAssets], ) const clearSelection = useCallback(() => { onSelectionChange?.([]) }, [onSelectionChange]) /* ── 拖拽排序 ── */ const handleDragStart = useCallback( (e: React.DragEvent, idx: number) => { setDragIdx(idx) e.dataTransfer.effectAllowed = "move" e.dataTransfer.setData("text/plain", String(idx)) // 设置素材数据,供 TimelinePanel 接收(P1-2 修复) e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx])) // 批量拖拽:如果有多个选中素材,一起携带 if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) { const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id)) e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets)) } // 通知父组件素材拖拽开始 if (onAssetDragStart) { onAssetDragStart(filteredAssets[idx]) } }, [filteredAssets, selectedIds, onAssetDragStart], ) const handleDragOver = useCallback( (e: React.DragEvent, idx: number) => { e.preventDefault() e.dataTransfer.dropEffect = "move" if (dragIdx === null || dragIdx === idx) return setDragOverIdx(idx) }, [dragIdx], ) const handleDrop = useCallback( (e: React.DragEvent, toIdx: number) => { e.preventDefault() if (dragIdx !== null && dragIdx !== toIdx && onReorder) { onReorder(dragIdx, toIdx) } setDragIdx(null) setDragOverIdx(null) }, [dragIdx, onReorder], ) const handleDragEnd = useCallback(() => { setDragIdx(null) setDragOverIdx(null) }, []) /* ── 悬浮预览 ── */ const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => { if (previewTimer.current) clearTimeout(previewTimer.current) previewTimer.current = setTimeout(() => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() setPreviewAsset(asset) setPreviewPos({ x: rect.right + 12, y: Math.max(8, rect.top - 20), }) }, 400) }, []) const handleMouseLeave = useCallback(() => { if (previewTimer.current) { clearTimeout(previewTimer.current) previewTimer.current = null } setPreviewAsset(null) }, []) // 清理定时器 useEffect(() => { return () => { if (previewTimer.current) clearTimeout(previewTimer.current) } }, []) /* ── 点击卡片 ── */ const handleCardClick = useCallback( (asset: MediaAsset, idx: number, e: React.MouseEvent) => { // 如果点击的是 checkbox 区域,不触发卡片点击 const target = e.target as HTMLElement if (target.closest("[data-checkbox]")) return toggleSelect(asset, idx, e.shiftKey) }, [toggleSelect], ) /* ──────────── 渲染 ──────────── */ const hasSelection = selectedIds.length > 0 return (
{/* ═══ 工具栏 ═══ */}
setSearchText(e.target.value)} prefix="🔍" />
setFilterQuality(v)} options={QUALITY_OPTIONS} /> )}
{/* ═══ 批量操作栏 ═══ */} {showBatchSelect && hasSelection && (
已选 {selectedIds.length} 项
)} {/* ═══ 素材列表 ═══ */}
{filteredAssets.length === 0 ? (
📂

暂无素材

) : viewMode === "grid" ? ( /* ── 网格视图 ── */
{filteredAssets.map((asset, idx) => { const isSelected = selectedSet.has(asset.id) const isDragging = dragIdx === idx const isDragOver = dragOverIdx === idx const qualityLevel = getQualityLevel(asset.quality_score) return (
handleDragStart(e, idx)} onDragOver={(e) => handleDragOver(e, idx)} onDrop={(e) => handleDrop(e, idx)} onDragEnd={handleDragEnd} onClick={(e) => handleCardClick(asset, idx, e)} onMouseEnter={(e) => handleMouseEnter(asset, e)} onMouseLeave={handleMouseLeave} > {/* 缩略图 */}
{asset.thumbnail_url ? ( {asset.name} ) : ( {MATERIAL_TYPE_ICONS[asset.type]} )} {/* Checkbox */} {showBatchSelect && ( { e.stopPropagation() toggleSelect(asset, idx, e.shiftKey) }} /> )} {/* 类型角标 */} {MATERIAL_TYPE_LABELS[asset.type]} {/* 时长角标 */} {asset.duration != null && ( {formatDuration(asset.duration)} )} {/* 质量分角标 */} {asset.quality_score != null && ( {asset.quality_score} )}
{/* 信息 */}

{asset.name}

{formatSize(asset.size)}
) })}
) : ( /* ── 列表视图 ── */
{filteredAssets.map((asset, idx) => { const isSelected = selectedSet.has(asset.id) const isDragging = dragIdx === idx const isDragOver = dragOverIdx === idx return (
handleDragStart(e, idx)} onDragOver={(e) => handleDragOver(e, idx)} onDrop={(e) => handleDrop(e, idx)} onDragEnd={handleDragEnd} onClick={(e) => handleCardClick(asset, idx, e)} onMouseEnter={(e) => handleMouseEnter(asset, e)} onMouseLeave={handleMouseLeave} > {/* 拖拽手柄 */} {/* Checkbox */} {showBatchSelect && ( { e.stopPropagation() toggleSelect(asset, idx, e.shiftKey) }} /> )} {/* 图标 */} {MATERIAL_TYPE_ICONS[asset.type]} {/* 信息 */}
{asset.name}
{MATERIAL_TYPE_LABELS[asset.type]} {asset.duration != null && ` · ${formatDuration(asset.duration)}`} {asset.size != null && ` · ${formatSize(asset.size)}`}
{/* 质量分 */} {asset.quality_score != null && ( {asset.quality_score}分 )}
) })}
)}
{/* ═══ 悬浮预览 ═══ */} {previewAsset && (
{previewAsset.thumbnail_url ? ( {previewAsset.name} ) : ( {MATERIAL_TYPE_ICONS[previewAsset.type]} )}

{previewAsset.name}

类型: {MATERIAL_TYPE_LABELS[previewAsset.type]} {previewAsset.duration != null && ( 时长: {formatDuration(previewAsset.duration)} )} {previewAsset.size != null && 大小: {formatSize(previewAsset.size)}} {previewAsset.quality_score != null && ( 质量分: {previewAsset.quality_score} )} {previewAsset.tags.length > 0 && 标签: {previewAsset.tags.join(", ")}}
)}
) } export default AssetSelector