diff --git a/apps/web/src/components/asset-selector/AssetCard.tsx b/apps/web/src/components/asset-selector/AssetCard.tsx new file mode 100644 index 000000000..cd48de38a --- /dev/null +++ b/apps/web/src/components/asset-selector/AssetCard.tsx @@ -0,0 +1,110 @@ +import React from "react" +import type { MediaAsset } from "@/api/template-editor" +import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor" +import { formatSize, formatDuration, getQualityLevel } from "./utils" + +interface AssetCardProps { + asset: MediaAsset + isSelected: boolean + isDragging: boolean + isDragOver: boolean + showCheckbox: boolean + compact?: boolean + onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void + onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void + onDragStart: (e: React.DragEvent) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void + onDragEnd: () => void + onMouseEnter: (e: React.MouseEvent) => void + onMouseLeave: () => void +} + +const AssetCard: React.FC = ({ + asset, + isSelected, + isDragging, + isDragOver, + showCheckbox, + onToggleSelect, + onCardClick, + onDragStart, + onDragOver, + onDrop, + onDragEnd, + onMouseEnter, + onMouseLeave, +}) => { + const qualityLevel = getQualityLevel(asset.quality_score) + + return ( +
onCardClick(asset, e)} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + > + {/* 缩略图 */} +
+ {asset.thumbnail_url ? ( + {asset.name} + ) : ( + {MATERIAL_TYPE_ICONS[asset.type]} + )} + + {/* Checkbox */} + {showCheckbox && ( + { + e.stopPropagation() + onToggleSelect(asset, 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)}
+
+
+ ) +} + +export default AssetCard diff --git a/apps/web/src/components/asset-selector/AssetListItem.tsx b/apps/web/src/components/asset-selector/AssetListItem.tsx new file mode 100644 index 000000000..4d1910bfe --- /dev/null +++ b/apps/web/src/components/asset-selector/AssetListItem.tsx @@ -0,0 +1,99 @@ +import React from "react" +import type { MediaAsset } from "@/api/template-editor" +import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor" +import { formatSize, formatDuration, getQualityColor } from "./utils" + +interface AssetListItemProps { + asset: MediaAsset + isSelected: boolean + isDragging: boolean + isDragOver: boolean + showCheckbox: boolean + onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void + onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void + onDragStart: (e: React.DragEvent) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void + onDragEnd: () => void + onMouseEnter: (e: React.MouseEvent) => void + onMouseLeave: () => void +} + +const AssetListItem: React.FC = ({ + asset, + isSelected, + isDragging, + isDragOver, + showCheckbox, + onToggleSelect, + onCardClick, + onDragStart, + onDragOver, + onDrop, + onDragEnd, + onMouseEnter, + onMouseLeave, +}) => { + return ( +
onCardClick(asset, e)} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + > + {/* 拖拽手柄 */} + + ⠿ + + + {/* Checkbox */} + {showCheckbox && ( + { + e.stopPropagation() + onToggleSelect(asset, 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}分 + + )} +
+ ) +} + +export default AssetListItem diff --git a/apps/web/src/components/asset-selector/AssetSelector.tsx b/apps/web/src/components/asset-selector/AssetSelector.tsx index 2712feac2..86c144d01 100644 --- a/apps/web/src/components/asset-selector/AssetSelector.tsx +++ b/apps/web/src/components/asset-selector/AssetSelector.tsx @@ -9,73 +9,20 @@ * - 筛选增强:类型筛选 + 质量分筛选 * - 视图切换:网格视图 / 列表视图 */ -import React, { useState, useMemo, useCallback, useRef, useEffect } from "react" +import React, { useCallback } from "react" import "./AssetSelector.css" -import { Input, Select, Button } from "@/components/ui" +import type { AssetSelectorProps } from "./types" +import useAssetFilter from "./hooks/useAssetFilter" +import useAssetSelection from "./hooks/useAssetSelection" +import useDragReorder from "./hooks/useDragReorder" +import useAssetPreview from "./hooks/useAssetPreview" +import SelectorToolbar from "./SelectorToolbar" +import SelectorBatchBar from "./SelectorBatchBar" +import AssetCard from "./AssetCard" +import AssetListItem from "./AssetListItem" +import PreviewOverlay from "./PreviewOverlay" +import EmptyState from "./EmptyState" 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, @@ -87,162 +34,41 @@ const AssetSelector: React.FC = ({ showBatchSelect = true, compact = false, }) => { - /* ── 搜索 & 筛选 ── */ - const [searchText, setSearchText] = useState("") - const [filterType, setFilterType] = useState("") - const [filterQuality, setFilterQuality] = useState("") - const [viewMode, setViewMode] = useState("grid") + // 搜索 & 筛选 + const { + searchText, + filterType, + filterQuality, + viewMode, + filteredAssets, + setSearchText, + setFilterType, + setFilterQuality, + setViewMode, + } = useAssetFilter(assets) - /* ── 拖拽状态 ── */ - const [dragIdx, setDragIdx] = useState(null) - const [dragOverIdx, setDragOverIdx] = useState(null) + // 选中操作 + const { selectedSet, toggleSelect, clearSelection } = useAssetSelection({ + filteredAssets, + selectedIds, + onSelectionChange, + }) - /* ── 悬浮预览 ── */ - const [previewAsset, setPreviewAsset] = useState(null) - const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 }) - const previewTimer = useRef | null>(null) + // 拖拽排序 + const { dragIdx, dragOverIdx, handleDragStart, handleDragOver, handleDrop, handleDragEnd } = + useDragReorder({ + filteredAssets, + selectedIds, + onAssetDragStart, + onReorder, + }) - /* ── 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 { previewAsset, previewPos, handleMouseEnter, handleMouseLeave } = useAssetPreview() /* ── 点击卡片 ── */ 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) @@ -250,253 +76,82 @@ const AssetSelector: React.FC = ({ [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) => ( + toggleSelect(a, idx, shiftKey)} + onCardClick={(a, e) => handleCardClick(a, idx, e)} + onDragStart={(e) => handleDragStart(e, idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDrop={(e) => handleDrop(e, idx)} + onDragEnd={handleDragEnd} + onMouseEnter={(e) => handleMouseEnter(asset, e)} + onMouseLeave={handleMouseLeave} + /> + ))}
) : ( - /* ── 列表视图 ── */ + /* 列表视图 */
- {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}分 - - )} -
- ) - })} + {filteredAssets.map((asset, idx) => ( + toggleSelect(a, idx, shiftKey)} + onCardClick={(a, e) => handleCardClick(a, idx, e)} + onDragStart={(e) => handleDragStart(e, idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDrop={(e) => handleDrop(e, idx)} + onDragEnd={handleDragEnd} + onMouseEnter={(e) => handleMouseEnter(asset, e)} + onMouseLeave={handleMouseLeave} + /> + ))}
)}
- {/* ═══ 悬浮预览 ═══ */} - {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(", ")}} -
-
- )} + {/* 悬浮预览 */} + {previewAsset && }
) } diff --git a/apps/web/src/components/asset-selector/EmptyState.tsx b/apps/web/src/components/asset-selector/EmptyState.tsx new file mode 100644 index 000000000..f2105675f --- /dev/null +++ b/apps/web/src/components/asset-selector/EmptyState.tsx @@ -0,0 +1,12 @@ +import React from "react" + +const EmptyState: React.FC = () => { + return ( +
+
📂
+

暂无素材

+
+ ) +} + +export default EmptyState diff --git a/apps/web/src/components/asset-selector/PreviewOverlay.tsx b/apps/web/src/components/asset-selector/PreviewOverlay.tsx new file mode 100644 index 000000000..5b7d2da21 --- /dev/null +++ b/apps/web/src/components/asset-selector/PreviewOverlay.tsx @@ -0,0 +1,33 @@ +import React from "react" +import type { MediaAsset } from "@/api/template-editor" +import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor" +import { formatSize, formatDuration } from "./utils" + +interface PreviewOverlayProps { + asset: MediaAsset + position: { x: number; y: number } +} + +const PreviewOverlay: React.FC = ({ asset, position }) => { + return ( +
+
+ {asset.thumbnail_url ? ( + {asset.name} + ) : ( + {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}} + {asset.tags.length > 0 && 标签: {asset.tags.join(", ")}} +
+
+ ) +} + +export default PreviewOverlay diff --git a/apps/web/src/components/asset-selector/SelectorBatchBar.tsx b/apps/web/src/components/asset-selector/SelectorBatchBar.tsx new file mode 100644 index 000000000..eea9a9c6a --- /dev/null +++ b/apps/web/src/components/asset-selector/SelectorBatchBar.tsx @@ -0,0 +1,22 @@ +import React from "react" +import { Button } from "@/components/ui" + +interface SelectorBatchBarProps { + selectedCount: number + onClear: () => void +} + +const SelectorBatchBar: React.FC = ({ selectedCount, onClear }) => { + return ( +
+ 已选 {selectedCount} 项 +
+ +
+
+ ) +} + +export default SelectorBatchBar diff --git a/apps/web/src/components/asset-selector/SelectorToolbar.tsx b/apps/web/src/components/asset-selector/SelectorToolbar.tsx new file mode 100644 index 000000000..d327dccdf --- /dev/null +++ b/apps/web/src/components/asset-selector/SelectorToolbar.tsx @@ -0,0 +1,74 @@ +import React from "react" +import { Input, Select } from "@/components/ui" +import { TYPE_OPTIONS } from "./constants" +import { QUALITY_OPTIONS } from "@/api/template-editor" +import type { ViewMode } from "./types" + +interface SelectorToolbarProps { + searchText: string + filterType: string + filterQuality: string + viewMode: ViewMode + showQualityFilter: boolean + onSearchChange: (value: string) => void + onTypeChange: (value: string) => void + onQualityChange: (value: string) => void + onViewModeChange: (mode: ViewMode) => void +} + +const SelectorToolbar: React.FC = ({ + searchText, + filterType, + filterQuality, + viewMode, + showQualityFilter, + onSearchChange, + onTypeChange, + onQualityChange, + onViewModeChange, +}) => { + return ( +
+
+
+ onSearchChange(e.target.value)} + prefix="🔍" + /> +
+ onQualityChange(v)} + options={QUALITY_OPTIONS} + /> + )} +
+
+ + +
+
+ ) +} + +export default SelectorToolbar diff --git a/apps/web/src/components/asset-selector/constants.ts b/apps/web/src/components/asset-selector/constants.ts new file mode 100644 index 000000000..baa707e54 --- /dev/null +++ b/apps/web/src/components/asset-selector/constants.ts @@ -0,0 +1,7 @@ +/** 类型筛选选项 */ +export const TYPE_OPTIONS = [ + { value: "", label: "全部类型" }, + { value: "video", label: "🎬 视频" }, + { value: "image", label: "🖼️ 图片" }, + { value: "audio", label: "🎵 音频" }, +] diff --git a/apps/web/src/components/asset-selector/hooks/useAssetFilter.ts b/apps/web/src/components/asset-selector/hooks/useAssetFilter.ts new file mode 100644 index 000000000..2995f36ed --- /dev/null +++ b/apps/web/src/components/asset-selector/hooks/useAssetFilter.ts @@ -0,0 +1,63 @@ +import { useState, useMemo } from "react" +import type { MediaAsset } from "@/api/template-editor" +import { QUALITY_OPTIONS } from "@/api/template-editor" +import type { ViewMode } from "../types" + +interface UseAssetFilterReturn { + searchText: string + filterType: string + filterQuality: string + viewMode: ViewMode + filteredAssets: MediaAsset[] + setSearchText: (value: string) => void + setFilterType: (value: string) => void + setFilterQuality: (value: string) => void + setViewMode: (mode: ViewMode) => void +} + +/** + * 素材筛选 Hook —— 搜索 + 类型 + 质量分 + 视图切换 + */ +const useAssetFilter = (assets: MediaAsset[]): UseAssetFilterReturn => { + const [searchText, setSearchText] = useState("") + const [filterType, setFilterType] = useState("") + const [filterQuality, setFilterQuality] = useState("") + const [viewMode, setViewMode] = useState("grid") + + 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]) + + return { + searchText, + filterType, + filterQuality, + viewMode, + filteredAssets, + setSearchText, + setFilterType, + setFilterQuality, + setViewMode, + } +} + +export default useAssetFilter diff --git a/apps/web/src/components/asset-selector/hooks/useAssetPreview.ts b/apps/web/src/components/asset-selector/hooks/useAssetPreview.ts new file mode 100644 index 000000000..222236942 --- /dev/null +++ b/apps/web/src/components/asset-selector/hooks/useAssetPreview.ts @@ -0,0 +1,54 @@ +import { useState, useRef, useCallback, useEffect } from "react" +import type { MediaAsset } from "@/api/template-editor" + +interface UseAssetPreviewReturn { + previewAsset: MediaAsset | null + previewPos: { x: number; y: number } + handleMouseEnter: (asset: MediaAsset, e: React.MouseEvent) => void + handleMouseLeave: () => void +} + +/** + * 悬浮预览 Hook —— 延迟 400ms 显示预览浮层 + */ +const useAssetPreview = (): UseAssetPreviewReturn => { + const [previewAsset, setPreviewAsset] = useState(null) + const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 }) + const previewTimer = useRef | null>(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) + } + }, []) + + return { + previewAsset, + previewPos, + handleMouseEnter, + handleMouseLeave, + } +} + +export default useAssetPreview diff --git a/apps/web/src/components/asset-selector/hooks/useAssetSelection.ts b/apps/web/src/components/asset-selector/hooks/useAssetSelection.ts new file mode 100644 index 000000000..cfaa62262 --- /dev/null +++ b/apps/web/src/components/asset-selector/hooks/useAssetSelection.ts @@ -0,0 +1,65 @@ +import { useCallback, useRef, useMemo } from "react" +import type { MediaAsset } from "@/api/template-editor" + +interface UseAssetSelectionOptions { + filteredAssets: MediaAsset[] + selectedIds: string[] + onSelectionChange?: (ids: string[]) => void +} + +interface UseAssetSelectionReturn { + selectedSet: Set + toggleSelect: (asset: MediaAsset, idx: number, shiftKey: boolean) => void + clearSelection: () => void +} + +/** + * 素材选择 Hook —— 单选/多选/Shift 连选 + */ +const useAssetSelection = ({ + filteredAssets, + selectedIds, + onSelectionChange, +}: UseAssetSelectionOptions): UseAssetSelectionReturn => { + const lastClickedIdx = useRef(null) + + 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]) + + return { + selectedSet, + toggleSelect, + clearSelection, + } +} + +export default useAssetSelection diff --git a/apps/web/src/components/asset-selector/hooks/useDragReorder.ts b/apps/web/src/components/asset-selector/hooks/useDragReorder.ts new file mode 100644 index 000000000..7be2d0960 --- /dev/null +++ b/apps/web/src/components/asset-selector/hooks/useDragReorder.ts @@ -0,0 +1,89 @@ +import { useState, useCallback } from "react" +import type { MediaAsset } from "@/api/template-editor" + +interface UseDragReorderOptions { + filteredAssets: MediaAsset[] + selectedIds: string[] + onAssetDragStart?: (asset: MediaAsset) => void + onReorder?: (fromIdx: number, toIdx: number) => void +} + +interface UseDragReorderReturn { + dragIdx: number | null + dragOverIdx: number | null + handleDragStart: (e: React.DragEvent, idx: number) => void + handleDragOver: (e: React.DragEvent, idx: number) => void + handleDrop: (e: React.DragEvent, toIdx: number) => void + handleDragEnd: () => void +} + +/** + * 拖拽排序 Hook —— HTML5 DnD,支持批量拖拽携带数据 + */ +const useDragReorder = ({ + filteredAssets, + selectedIds, + onAssetDragStart, + onReorder, +}: UseDragReorderOptions): UseDragReorderReturn => { + const [dragIdx, setDragIdx] = useState(null) + const [dragOverIdx, setDragOverIdx] = useState(null) + + const handleDragStart = useCallback( + (e: React.DragEvent, idx: number) => { + setDragIdx(idx) + e.dataTransfer.effectAllowed = "move" + e.dataTransfer.setData("text/plain", String(idx)) + // 设置素材数据,供 TimelinePanel 接收 + 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) + }, []) + + return { + dragIdx, + dragOverIdx, + handleDragStart, + handleDragOver, + handleDrop, + handleDragEnd, + } +} + +export default useDragReorder diff --git a/apps/web/src/components/asset-selector/index.ts b/apps/web/src/components/asset-selector/index.ts index 9e4fafddf..43ed97916 100644 --- a/apps/web/src/components/asset-selector/index.ts +++ b/apps/web/src/components/asset-selector/index.ts @@ -1,2 +1,2 @@ export { default as AssetSelector } from "./AssetSelector" -export type { AssetSelectorProps } from "./AssetSelector" +export type { AssetSelectorProps, ViewMode } from "./types" diff --git a/apps/web/src/components/asset-selector/types/index.ts b/apps/web/src/components/asset-selector/types/index.ts new file mode 100644 index 000000000..da852a716 --- /dev/null +++ b/apps/web/src/components/asset-selector/types/index.ts @@ -0,0 +1,14 @@ +import type { MediaAsset } 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 +} + +export type ViewMode = "grid" | "list" diff --git a/apps/web/src/components/asset-selector/utils/index.ts b/apps/web/src/components/asset-selector/utils/index.ts new file mode 100644 index 000000000..2bd86d124 --- /dev/null +++ b/apps/web/src/components/asset-selector/utils/index.ts @@ -0,0 +1,33 @@ +/** 格式化文件大小 */ +export 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` +} + +/** 格式化时长 */ +export 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` +} + +/** 获取质量分等级 */ +export 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" +} + +/** 质量分颜色 */ +export 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)" +} diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx old mode 100644 new mode 100755 index 2e0416579..0e2e0b6de --- a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -9,7 +9,7 @@ * - 删除素材 */ import React from "react" -import { AudioOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons" +import { PlusOutlined, RobotOutlined } from "@ant-design/icons" import { Button, Modal } from "@/components/ui" import PageHead from "@/components/layout/PageHead" import { useVoiceMaterials } from "./hooks/useVoiceMaterials" @@ -315,8 +315,8 @@ const VoiceMaterialLibrary: React.FC = () => { voiceId={ttsVoiceId} speed={ttsSpeed} status={ttsStatus} - audioUrl={ttsAudioUrl} - error={ttsError} + audioUrl={ttsAudioUrl ?? ""} + error={ttsError ?? ""} presetVoices={presetVoices} onClose={handleTtsClose} onTextChange={setTtsText} diff --git a/apps/web/src/pages/voice-materials/components/TtsModal.tsx b/apps/web/src/pages/voice-materials/components/TtsModal.tsx old mode 100644 new mode 100755 index 338c33bfb..8ffd176bb --- a/apps/web/src/pages/voice-materials/components/TtsModal.tsx +++ b/apps/web/src/pages/voice-materials/components/TtsModal.tsx @@ -35,7 +35,7 @@ const TtsModal: React.FC = ({ audioUrl, error, presetVoices, - onClose, + onClose: _onClose, onTextChange, onVoiceChange, onSpeedChange,