Merge pull request 'feat(task-2.15): 素材选择器优化 — 批量选择+拖拽排序+预览缩略图+质量分筛选' (#154) from feature/task-2.15-asset-selector into develop
Deploy / Deploy Staging (push) Failing after 185h34m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 185h34m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 185h35m4s
Deploy / Production Browser E2E (push) Failing after 1844h10m11s
Deploy / Build Production Runtime Images (push) Failing after 1844h10m15s
Deploy / Deploy Production (push) Failing after 1844h41m35s

This commit was merged in pull request #154.
This commit is contained in:
2026-07-01 19:19:30 +08:00
8 changed files with 1345 additions and 163 deletions
@@ -0,0 +1,527 @@
/**
* 素材选择器 - V21 设计系统样式
* 支持批量选择、拖拽排序、预览缩略图、质量分筛选
* 前缀: as- (AssetSelector)
*/
@import "../../styles/global.css";
/* ============================================================
容器
============================================================ */
.as-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ── 工具栏 ─────────────────────────────────────────────────── */
.as-toolbar {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
flex-wrap: wrap;
}
.as-toolbar-left {
display: flex;
align-items: center;
gap: var(--space-sm);
flex: 1;
min-width: 0;
}
.as-toolbar-right {
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* 搜索框 */
.as-search {
flex: 1;
min-width: 120px;
max-width: 200px;
}
/* 视图切换按钮 */
.as-view-btn {
width: 28px;
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition-all);
font-size: 14px;
}
.as-view-btn:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
.as-view-btn.active {
background: var(--primary-soft);
color: var(--primary-color);
border-color: var(--primary-color);
}
/* ── 批量操作栏 ─────────────────────────────────────────────── */
.as-batch-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px var(--space-md);
background: var(--primary-soft);
border-bottom: 1px solid var(--color-primary-200);
font-size: var(--font-size-sm);
color: var(--primary-color);
flex-shrink: 0;
}
.as-batch-bar-count {
font-weight: 600;
}
.as-batch-bar-actions {
display: flex;
gap: var(--space-xs);
margin-left: auto;
}
/* ── 素材列表区域 ───────────────────────────────────────────── */
.as-body {
flex: 1;
overflow-y: auto;
padding: var(--space-sm);
}
.as-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl) var(--space-md);
text-align: center;
color: var(--text-secondary);
}
.as-empty-icon {
font-size: 36px;
margin-bottom: var(--space-sm);
opacity: 0.4;
}
.as-empty p {
margin: 0;
font-size: var(--font-size-sm);
}
/* ── 网格视图 ───────────────────────────────────────────────── */
.as-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: var(--space-sm);
}
.as-grid.compact {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 6px;
}
/* ── 列表视图 ───────────────────────────────────────────────── */
.as-list {
display: flex;
flex-direction: column;
gap: 4px;
}
/* ============================================================
素材卡片 — 网格模式
============================================================ */
.as-card {
position: relative;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: var(--transition-all);
background: var(--bg-primary);
user-select: none;
}
.as-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
.as-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-soft);
}
.as-card.dragging {
opacity: 0.4;
}
.as-card.drag-over {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color);
}
/* 缩略图区域 */
.as-card-thumb {
position: relative;
aspect-ratio: 9 / 16;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.as-card-thumb-icon {
font-size: 28px;
opacity: 0.4;
}
.as-card-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 类型角标 */
.as-card-type-badge {
position: absolute;
top: 4px;
left: 4px;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 10px;
line-height: 1.4;
backdrop-filter: blur(4px);
}
/* 时长角标 */
.as-card-duration {
position: absolute;
bottom: 4px;
right: 4px;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
}
/* 质量分角标 */
.as-card-quality {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
color: #fff;
}
.as-card-quality.excellent {
background: var(--success-color, #10b981);
}
.as-card-quality.good {
background: var(--primary-color, #6366f1);
}
.as-card-quality.fair {
background: var(--warning-color, #f59e0b);
}
.as-card-quality.poor {
background: var(--error-color, #ef4444);
}
/* 选择 checkbox */
.as-card-checkbox {
position: absolute;
top: 4px;
left: 4px;
z-index: 2;
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
border: 2px solid rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-all);
backdrop-filter: blur(4px);
}
.as-card-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.as-card-checkbox.checked::after {
content: "✓";
color: #fff;
font-size: 11px;
font-weight: 700;
}
/* 有 checkbox 时,类型角标右移 */
.as-card.has-checkbox .as-card-type-badge {
left: 26px;
}
/* 卡片信息 */
.as-card-info {
padding: 6px 8px;
}
.as-card-name {
font-size: 11px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
line-height: 1.3;
}
.as-card-meta {
font-size: 10px;
color: var(--text-secondary);
margin-top: 2px;
}
/* ============================================================
素材卡片 — 列表模式
============================================================ */
.as-list-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 8px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition-all);
user-select: none;
}
.as-list-item:hover {
background: var(--bg-secondary);
border-color: var(--border-color);
}
.as-list-item.selected {
background: var(--primary-soft);
border-color: var(--primary-color);
}
.as-list-item.dragging {
opacity: 0.4;
}
.as-list-item.drag-over {
border-top: 2px solid var(--primary-color);
}
.as-list-item-checkbox {
width: 16px;
height: 16px;
border-radius: 3px;
border: 2px solid var(--border-color);
flex-shrink: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-all);
}
.as-list-item-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.as-list-item-checkbox.checked::after {
content: "✓";
color: #fff;
font-size: 10px;
font-weight: 700;
}
.as-list-item-drag {
cursor: grab;
color: var(--text-secondary);
font-size: 14px;
opacity: 0.4;
flex-shrink: 0;
}
.as-list-item-drag:hover {
opacity: 1;
}
.as-list-item-drag:active {
cursor: grabbing;
}
.as-list-item-icon {
font-size: 20px;
flex-shrink: 0;
width: 28px;
text-align: center;
}
.as-list-item-info {
flex: 1;
min-width: 0;
}
.as-list-item-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.as-list-item-meta {
font-size: 11px;
color: var(--text-secondary);
}
.as-list-item-quality {
width: 32px;
text-align: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
/* ============================================================
悬浮预览
============================================================ */
.as-preview-overlay {
position: fixed;
z-index: 1000;
pointer-events: none;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 8px;
min-width: 180px;
max-width: 260px;
animation: as-preview-in 0.15s ease;
}
.as-preview-overlay-thumb {
width: 100%;
aspect-ratio: 9 / 16;
max-height: 200px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin-bottom: 8px;
}
.as-preview-overlay-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.as-preview-overlay-thumb-icon {
font-size: 48px;
opacity: 0.3;
}
.as-preview-overlay-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
word-break: break-all;
}
.as-preview-overlay-meta {
font-size: 11px;
color: var(--text-secondary);
display: flex;
flex-direction: column;
gap: 2px;
}
@keyframes as-preview-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 768px) {
.as-grid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.as-toolbar {
padding: var(--space-xs) var(--space-sm);
}
.as-search {
max-width: 140px;
}
}
@media (max-width: 480px) {
.as-grid {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 4px;
}
.as-card-info {
padding: 4px 6px;
}
.as-card-name {
font-size: 10px;
}
}
@@ -0,0 +1,568 @@
/**
* 素材选择器 — 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/editPlans";
import {
MATERIAL_TYPE_LABELS,
MATERIAL_TYPE_ICONS,
QUALITY_OPTIONS,
} from "@/api/editPlans";
/* ──────────── 类型 ──────────── */
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 allFilteredSelected =
filteredAssets.length > 0 &&
filteredAssets.every((a) => selectedSet.has(a.id));
/* ── 选择操作 ── */
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 toggleSelectAll = useCallback(() => {
if (!onSelectionChange) return;
if (allFilteredSelected) {
// 反选:取消当前过滤结果中的所有选中
const filteredIds = new Set(filteredAssets.map((a) => a.id));
onSelectionChange(selectedIds.filter((id) => !filteredIds.has(id)));
} else {
// 全选:合并当前过滤结果到选中列表
const newSet = new Set(selectedIds);
filteredAssets.forEach((a) => newSet.add(a.id));
onSelectionChange(Array.from(newSet));
}
}, [onSelectionChange, selectedIds, filteredAssets, allFilteredSelected]);
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={(v: string) => setSearchText(v)}
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="xs" 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;
@@ -0,0 +1,2 @@
export { default as AssetSelector } from "./AssetSelector";
export type { AssetSelectorProps } from "./AssetSelector";