3862996045
CI/CD Pipeline / Check if frontend-only change (push) Waiting to run
CI/CD Pipeline / Validate - Code Quality (push) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (push) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (push) Waiting to run
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Waiting to run
CI/CD Pipeline / PR Build Web Image (push) Waiting to run
CI/CD Pipeline / PR Build Worker Image (push) Waiting to run
CI/CD Pipeline / Build Staging API Image (push) Waiting to run
CI/CD Pipeline / Build Staging Web Image (push) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Waiting to run
CI/CD Pipeline / Build Production Web Image (push) Waiting to run
CI/CD Pipeline / Build Production Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
/**
|
|
* 素材选择器 — 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<AssetSelectorProps> = ({
|
|
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<ViewMode>("grid")
|
|
|
|
/* ── 拖拽状态 ── */
|
|
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
|
|
|
/* ── 悬浮预览 ── */
|
|
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
|
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
|
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
/* ── Shift 连选 ── */
|
|
const lastClickedIdx = useRef<number | null>(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 (
|
|
<div className="as-container">
|
|
{/* ═══ 工具栏 ═══ */}
|
|
<div className="as-toolbar">
|
|
<div className="as-toolbar-left">
|
|
<div className="as-search">
|
|
<Input
|
|
placeholder="搜索素材..."
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
prefix="🔍"
|
|
/>
|
|
</div>
|
|
<Select
|
|
value={filterType}
|
|
onChange={(v: string) => setFilterType(v)}
|
|
options={TYPE_OPTIONS}
|
|
/>
|
|
{showQualityFilter && (
|
|
<Select
|
|
value={filterQuality}
|
|
onChange={(v: string) => setFilterQuality(v)}
|
|
options={QUALITY_OPTIONS}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="as-toolbar-right">
|
|
<button
|
|
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
|
onClick={() => setViewMode("grid")}
|
|
title="网格视图"
|
|
>
|
|
⊞
|
|
</button>
|
|
<button
|
|
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
|
onClick={() => setViewMode("list")}
|
|
title="列表视图"
|
|
>
|
|
☰
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ═══ 批量操作栏 ═══ */}
|
|
{showBatchSelect && hasSelection && (
|
|
<div className="as-batch-bar">
|
|
<span className="as-batch-bar-count">已选 {selectedIds.length} 项</span>
|
|
<div className="as-batch-bar-actions">
|
|
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
|
取消选择
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ═══ 素材列表 ═══ */}
|
|
<div className="as-body">
|
|
{filteredAssets.length === 0 ? (
|
|
<div className="as-empty">
|
|
<div className="as-empty-icon">📂</div>
|
|
<p>暂无素材</p>
|
|
</div>
|
|
) : viewMode === "grid" ? (
|
|
/* ── 网格视图 ── */
|
|
<div className={`as-grid${compact ? " compact" : ""}`}>
|
|
{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 (
|
|
<div
|
|
key={asset.id}
|
|
className={[
|
|
"as-card",
|
|
isSelected ? "selected" : "",
|
|
isDragging ? "dragging" : "",
|
|
isDragOver ? "drag-over" : "",
|
|
showBatchSelect ? "has-checkbox" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
draggable
|
|
onDragStart={(e) => 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}
|
|
>
|
|
{/* 缩略图 */}
|
|
<div className="as-card-thumb">
|
|
{asset.thumbnail_url ? (
|
|
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
|
) : (
|
|
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
|
)}
|
|
|
|
{/* Checkbox */}
|
|
{showBatchSelect && (
|
|
<span
|
|
data-checkbox
|
|
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleSelect(asset, idx, e.shiftKey)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 类型角标 */}
|
|
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
|
|
|
{/* 时长角标 */}
|
|
{asset.duration != null && (
|
|
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
|
)}
|
|
|
|
{/* 质量分角标 */}
|
|
{asset.quality_score != null && (
|
|
<span
|
|
className={`as-card-quality ${qualityLevel}`}
|
|
title={`质量分: ${asset.quality_score}`}
|
|
>
|
|
{asset.quality_score}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 信息 */}
|
|
<div className="as-card-info">
|
|
<p className="as-card-name" title={asset.name}>
|
|
{asset.name}
|
|
</p>
|
|
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
/* ── 列表视图 ── */
|
|
<div className="as-list">
|
|
{filteredAssets.map((asset, idx) => {
|
|
const isSelected = selectedSet.has(asset.id)
|
|
const isDragging = dragIdx === idx
|
|
const isDragOver = dragOverIdx === idx
|
|
|
|
return (
|
|
<div
|
|
key={asset.id}
|
|
className={[
|
|
"as-list-item",
|
|
isSelected ? "selected" : "",
|
|
isDragging ? "dragging" : "",
|
|
isDragOver ? "drag-over" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
draggable
|
|
onDragStart={(e) => 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}
|
|
>
|
|
{/* 拖拽手柄 */}
|
|
<span className="as-list-item-drag" title="拖拽排序">
|
|
⠿
|
|
</span>
|
|
|
|
{/* Checkbox */}
|
|
{showBatchSelect && (
|
|
<span
|
|
data-checkbox
|
|
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleSelect(asset, idx, e.shiftKey)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 图标 */}
|
|
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
|
|
|
{/* 信息 */}
|
|
<div className="as-list-item-info">
|
|
<div className="as-list-item-name">{asset.name}</div>
|
|
<div className="as-list-item-meta">
|
|
{MATERIAL_TYPE_LABELS[asset.type]}
|
|
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
|
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 质量分 */}
|
|
{asset.quality_score != null && (
|
|
<span
|
|
className="as-list-item-quality"
|
|
style={{ color: getQualityColor(asset.quality_score) }}
|
|
>
|
|
{asset.quality_score}分
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ═══ 悬浮预览 ═══ */}
|
|
{previewAsset && (
|
|
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
|
|
<div className="as-preview-overlay-thumb">
|
|
{previewAsset.thumbnail_url ? (
|
|
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
|
|
) : (
|
|
<span className="as-preview-overlay-thumb-icon">
|
|
{MATERIAL_TYPE_ICONS[previewAsset.type]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="as-preview-overlay-name">{previewAsset.name}</p>
|
|
<div className="as-preview-overlay-meta">
|
|
<span>类型: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
|
|
{previewAsset.duration != null && (
|
|
<span>时长: {formatDuration(previewAsset.duration)}</span>
|
|
)}
|
|
{previewAsset.size != null && <span>大小: {formatSize(previewAsset.size)}</span>}
|
|
{previewAsset.quality_score != null && (
|
|
<span>质量分: {previewAsset.quality_score}</span>
|
|
)}
|
|
{previewAsset.tags.length > 0 && <span>标签: {previewAsset.tags.join(", ")}</span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AssetSelector
|