From 7e954405f3a44408636dd3df572ace1343e47d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Wed, 1 Jul 2026 16:37:27 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(task-2.15):=20=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E4=BC=98=E5=8C=96=20=E2=80=94=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E9=80=89=E6=8B=A9=20+=20=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=20+=20=E9=A2=84=E8=A7=88=E7=BC=A9=E7=95=A5?= =?UTF-8?q?=E5=9B=BE=20+=20=E8=B4=A8=E9=87=8F=E5=88=86=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AssetSelector 可复用组件(批量选择/拖拽排序/悬浮预览/质量分筛选/视图切换) - MediaPanel 集成 AssetSelector,素材 Tab 使用 useQuery 内部获取数据 - TimelinePanel 支持批量素材拖入(application/x-media-assets) - EditingPlanner 新增 handleBatchAddAssets 批量添加逻辑 - editPlans.ts 扩展 MediaAsset 类型(quality_score/classification_status) - V21 设计系统响应式样式(1200/900/768/480 断点) --- apps/web/src/api/editPlans.ts | 34 ++ .../AssetSelector/AssetSelector.css | 527 +++++++++++++++++ .../AssetSelector/AssetSelector.tsx | 548 ++++++++++++++++++ .../web/src/components/AssetSelector/index.ts | 2 + .../pages/editing-planner/EditingPlanner.css | 13 + .../pages/editing-planner/EditingPlanner.tsx | 36 +- .../editing-planner/components/MediaPanel.tsx | 280 +++++---- .../components/TimelinePanel.tsx | 46 +- 8 files changed, 1323 insertions(+), 163 deletions(-) create mode 100644 apps/web/src/components/AssetSelector/AssetSelector.css create mode 100644 apps/web/src/components/AssetSelector/AssetSelector.tsx create mode 100644 apps/web/src/components/AssetSelector/index.ts diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index c2af2f3a4..b75760d04 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -77,6 +77,10 @@ export interface MediaAsset { /** 标签 */ tags: string[]; created_at: string; + /** 质量分 0-100 */ + quality_score?: number; + /** 分类状态 */ + classification_status?: "pending" | "processing" | "completed" | "failed"; } /* ============================================================ @@ -88,19 +92,25 @@ const MOCK_ASSETS: MediaAsset[] = [ id: "asset-001", name: "产品展示-正面.mp4", type: "video", + thumbnail_url: "https://picsum.photos/seed/asset001/320/180", duration: 15, size: 12_500_000, tags: ["产品", "展示"], created_at: "2026-06-20T10:00:00Z", + quality_score: 92, + classification_status: "completed", }, { id: "asset-002", name: "使用教程-片段A.mp4", type: "video", + thumbnail_url: "https://picsum.photos/seed/asset002/320/180", duration: 20, size: 18_000_000, tags: ["教程", "使用"], created_at: "2026-06-21T14:00:00Z", + quality_score: 85, + classification_status: "completed", }, { id: "asset-003", @@ -110,32 +120,43 @@ const MOCK_ASSETS: MediaAsset[] = [ size: 3_200_000, tags: ["BGM", "轻快"], created_at: "2026-06-18T09:00:00Z", + quality_score: 78, + classification_status: "completed", }, { id: "asset-004", name: "封面图-主图.jpg", type: "image", + thumbnail_url: "https://picsum.photos/seed/asset004/320/180", size: 850_000, tags: ["封面", "主图"], created_at: "2026-06-22T11:00:00Z", + quality_score: 88, + classification_status: "completed", }, { id: "asset-005", name: "细节特写-侧面.mp4", type: "video", + thumbnail_url: "https://picsum.photos/seed/asset005/320/180", duration: 10, size: 8_500_000, tags: ["产品", "细节"], created_at: "2026-06-23T16:00:00Z", + quality_score: 72, + classification_status: "completed", }, { id: "asset-006", name: "开箱视频-片段.mp4", type: "video", + thumbnail_url: "https://picsum.photos/seed/asset006/320/180", duration: 25, size: 22_000_000, tags: ["开箱", "展示"], created_at: "2026-06-19T08:00:00Z", + quality_score: 65, + classification_status: "processing", }, { id: "asset-007", @@ -145,14 +166,19 @@ const MOCK_ASSETS: MediaAsset[] = [ size: 5_600_000, tags: ["配音", "产品"], created_at: "2026-06-24T10:00:00Z", + quality_score: 95, + classification_status: "completed", }, { id: "asset-008", name: "场景图-生活场景.jpg", type: "image", + thumbnail_url: "https://picsum.photos/seed/asset008/320/180", size: 1_200_000, tags: ["场景", "生活"], created_at: "2026-06-25T13:00:00Z", + quality_score: 58, + classification_status: "pending", }, ]; @@ -334,3 +360,11 @@ export const PLAN_STATUS_LABELS: Record = { completed: "已完成", failed: "失败", }; + +/** 质量分筛选选项 */ +export const QUALITY_OPTIONS: { value: string; label: string; min?: number; max?: number }[] = [ + { value: "all", label: "全部质量" }, + { value: "high", label: "高质量 (80-100)", min: 80, max: 100 }, + { value: "medium", label: "中质量 (50-79)", min: 50, max: 79 }, + { value: "low", label: "低质量 (0-49)", min: 0, max: 49 }, +]; diff --git a/apps/web/src/components/AssetSelector/AssetSelector.css b/apps/web/src/components/AssetSelector/AssetSelector.css new file mode 100644 index 000000000..3e19910da --- /dev/null +++ b/apps/web/src/components/AssetSelector/AssetSelector.css @@ -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; + } +} diff --git a/apps/web/src/components/AssetSelector/AssetSelector.tsx b/apps/web/src/components/AssetSelector/AssetSelector.tsx new file mode 100644 index 000000000..685db5296 --- /dev/null +++ b/apps/web/src/components/AssetSelector/AssetSelector.tsx @@ -0,0 +1,548 @@ +/** + * 素材选择器 — 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/editingPlanner"; +import { + MATERIAL_TYPE_LABELS, + MATERIAL_TYPE_ICONS, + QUALITY_OPTIONS, +} from "@/api/editingPlanner"; + +/* ──────────── 类型 ──────────── */ + +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 minScore = parseInt(filterQuality, 10); + list = list.filter( + (a) => a.quality_score == null || a.quality_score >= minScore, + ); + } + 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)); + // 通知父组件素材拖拽开始 + if (onAssetDragStart) { + onAssetDragStart(filteredAssets[idx]); + } + }, + [filteredAssets, 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(v)} + 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; diff --git a/apps/web/src/components/AssetSelector/index.ts b/apps/web/src/components/AssetSelector/index.ts new file mode 100644 index 000000000..e99b3d568 --- /dev/null +++ b/apps/web/src/components/AssetSelector/index.ts @@ -0,0 +1,2 @@ +export { default as AssetSelector } from "./AssetSelector"; +export type { AssetSelectorProps } from "./AssetSelector"; diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index a7b38a69a..a7372e14d 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -353,6 +353,19 @@ background: var(--primary-soft); } +/* 素材 Tab — AssetSelector 容器 */ +.ep-left-media { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.ep-left-media-footer { + padding: var(--space-sm) var(--space-md); + border-top: 1px solid var(--border-color); + flex-shrink: 0; +} + /* ── 中间时间线 ──────────────────────────────────────────────── */ .ep-center { flex: 1; diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index 72b5f7576..557d1a22f 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -20,7 +20,6 @@ import { type SaveTemplatePayload, } from "@/api/editingPlanner"; import { - getMediaAssets, createEditPlan, updateEditPlan, type EditPlanClip, @@ -87,12 +86,6 @@ const EditingPlanner: React.FC = () => { queryFn: getTemplateCategories, }); - /* ── 数据查询:素材库 ── */ - const { data: assets = [], isLoading: assetsLoading } = useQuery({ - queryKey: ["media-assets"], - queryFn: getMediaAssets, - }); - /* ── 编辑器状态 ── */ const [currentMode, setCurrentMode] = useState("pip"); const [clips, setClips] = useState([ @@ -297,6 +290,31 @@ const EditingPlanner: React.FC = () => { // 素材拖拽开始时的回调(可用于高亮时间线等) }, []); + /* ── 批量添加素材到时间线 ── */ + const handleBatchAddAssets = useCallback( + (assets: MediaAsset[]) => { + const newClips: EditPlanClip[] = assets.map((asset, i) => ({ + id: newClipId(), + template_segment_id: newSegId(), + media_asset_id: asset.id, + material_type: asset.type as EditPlanClip["material_type"], + script_text: "", + duration: asset.duration || 10, + transition: { type: "none", duration: 0 }, + order: clips.length + i, + })); + setClips((prev) => { + const next = [...prev, ...newClips]; + return next.map((c, i) => ({ ...c, order: i })); + }); + if (newClips.length > 0) { + setSelectedClipId(newClips[0].id); + } + showToast(`已批量添加 ${assets.length} 个素材`, "success"); + }, + [clips.length, showToast], + ); + /* ──────────── 模式切换 ──────────── */ const handleModeChange = (mode: TemplateMode) => { @@ -511,9 +529,8 @@ const EditingPlanner: React.FC = () => { loadedTemplateId={loadedTemplateId} onTemplateSelect={loadTemplate} onNewTemplate={resetEditor} - assets={assets} - isLoadingAssets={assetsLoading} onAssetDragStart={handleAssetDragStart} + onBatchAddAssets={handleBatchAddAssets} /> {/* 中间:时间线 */} @@ -524,6 +541,7 @@ const EditingPlanner: React.FC = () => { onRemoveClip={handleRemoveClip} onReorderClips={handleReorderClips} onAssetDrop={handleAssetDrop} + onBatchAssetDrop={handleBatchAddAssets} onAddClip={handleAddClip} totalDuration={totalDuration} /> diff --git a/apps/web/src/pages/editing-planner/components/MediaPanel.tsx b/apps/web/src/pages/editing-planner/components/MediaPanel.tsx index dc3e4643f..cd2ca78a4 100644 --- a/apps/web/src/pages/editing-planner/components/MediaPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/MediaPanel.tsx @@ -1,10 +1,11 @@ /** * 左侧素材面板 — V21 设计系统 * Tab 切换:模板列表 / 素材库 - * 素材支持拖拽到时间线 + * 素材 Tab 集成 AssetSelector 组件 */ -import React, { useState } from "react"; -import { Input, Select, Tag } from "@/components/ui"; +import React, { useState, useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Input, Select, Tag, Button } from "@/components/ui"; import { MODE_LABELS, MODE_COLORS, @@ -12,8 +13,11 @@ import { type TemplateCategory, type TemplateMode, } from "@/api/editingPlanner"; -import type { MediaAsset } from "@/api/editPlans"; -import { MATERIAL_TYPE_ICONS } from "@/api/editPlans"; +import { + getMediaAssets, + type MediaAsset, +} from "@/api/editPlans"; +import AssetSelector from "@/components/AssetSelector/AssetSelector"; /** antd Tag color → V21 Tag variant */ const modeVariantMap: Record< @@ -37,9 +41,8 @@ interface MediaPanelProps { onTemplateSelect: (tpl: EditingTemplate) => void; onNewTemplate: () => void; /* 素材相关 */ - assets: MediaAsset[]; - isLoadingAssets: boolean; - onAssetDragStart: (asset: MediaAsset) => void; + onAssetDragStart?: (asset: MediaAsset) => void; + onBatchAddAssets?: (assets: MediaAsset[]) => void; } const MediaPanel: React.FC = ({ @@ -49,14 +52,19 @@ const MediaPanel: React.FC = ({ loadedTemplateId, onTemplateSelect, onNewTemplate, - assets, - isLoadingAssets, onAssetDragStart, + onBatchAddAssets, }) => { const [activeTab, setActiveTab] = useState("templates"); const [searchText, setSearchText] = useState(""); const [filterCategory, setFilterCategory] = useState(""); - const [assetFilter, setAssetFilter] = useState(""); + const [selectedAssetIds, setSelectedAssetIds] = useState([]); + + /* 素材数据查询 */ + const { data: assets = [], isLoading: isLoadingAssets } = useQuery({ + queryKey: ["media-assets"], + queryFn: getMediaAssets, + }); /* 过滤模板 */ const filteredTemplates = templates.filter((tpl) => { @@ -66,19 +74,16 @@ const MediaPanel: React.FC = ({ return true; }); - /* 过滤素材 */ - const filteredAssets = assets.filter((a) => { - if (searchText && !a.name.toLowerCase().includes(searchText.toLowerCase())) - return false; - if (assetFilter && a.type !== assetFilter) return false; - return true; - }); + /* 获取选中的素材 */ + const selectedAssets = assets.filter((a) => selectedAssetIds.includes(a.id)); - const handleDragStart = (e: React.DragEvent, asset: MediaAsset) => { - e.dataTransfer.setData("application/x-media-asset", JSON.stringify(asset)); - e.dataTransfer.effectAllowed = "copy"; - onAssetDragStart(asset); - }; + /* 批量添加到时间线 */ + const handleBatchAdd = useCallback(() => { + if (selectedAssets.length > 0 && onBatchAddAssets) { + onBatchAddAssets(selectedAssets); + setSelectedAssetIds([]); + } + }, [selectedAssets, onBatchAddAssets]); return (
@@ -98,137 +103,110 @@ const MediaPanel: React.FC = ({
- {/* 搜索栏 */} -
- setSearchText(e.target.value)} - allowClear - /> - {activeTab === "templates" && ( - setAssetFilter(v || "")} - allowClear - options={[ - { value: "video", label: "🎬 视频" }, - { value: "image", label: "🖼️ 图片" }, - { value: "audio", label: "🎵 音频" }, - ]} - /> - )} -
- {/* 内容区 */} -
- {activeTab === "templates" ? ( - /* ── 模板列表 ── */ - isLoadingTemplates ? ( -
-
-

加载中...

-
- ) : filteredTemplates.length === 0 ? ( -
-
📭
-

暂无模板

-
- ) : ( - filteredTemplates.map((tpl) => { - const modeColor = - MODE_COLORS[tpl.mode as TemplateMode] || "blue"; - const variant = modeVariantMap[modeColor] || "primary"; - return ( -
onTemplateSelect(tpl)} - > -
{tpl.name}
-
- - {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} - - {tpl.tags.slice(0, 2).map((tag) => ( - - {tag} - - ))} -
-
- {tpl.segments.length} 片段 · ~{tpl.estimated_duration}s -
-
- ); - }) - ) - ) : /* ── 素材列表(可拖拽) ── */ - isLoadingAssets ? ( -
-
-

加载素材中...

+ {activeTab === "templates" ? ( + <> + {/* 搜索栏 */} +
+ setSearchText(e.target.value)} + allowClear + /> +