'use client' import React, { useRef, useEffect, useState } from 'react' import Image from 'next/image' import { format } from 'date-fns' import AudioVisualizer from '../AudioVisualizer' // 素材类型定义 export interface Material { id: string productModel: string categoryName: string sceneName: string materialType: string renderer?: string authorName: string previewUrl: string zipFileName: string zipFileSize: string | number downloadCount: number likeCount: number isLiked: boolean isFavorited: boolean userId: string createdAt: string // 视频相关 videoSourceUrl?: string videoPreviewClipUrl?: string videoPreview540Url?: string videoCoverUrl?: string videoDuration?: number videoProcessingStatus?: string transcodeProgress?: number transcodeStage?: string transcodeAttempts?: number transcodeLastError?: string transcodeQueueWaitingIndex?: number transcodeQueueWaitingTotal?: number transcodeQueueProcessingIds?: string[] transcodeQueueMaxConcurrent?: number } // 用户会话类型 interface UserSession { id: string role: string } interface MaterialCardProps { material: Material session: { user: UserSession } | null // 音频播放状态 playingAudioId: string | null audioElement: HTMLAudioElement | null audioProgress: number audioCurrentTime: number audioDuration: number audioRef: React.RefObject // 下载状态 downloadingId: string | null downloadProgress: number // 主题色 themeColor: string // 事件处理 onAudioPlay: (materialId: string, audioUrl: string) => void onDownload: (id: string) => void onLike: (id: string) => void onFavorite: (id: string) => void onShare: (id: string) => void onEdit: (material: Material) => void onDelete: (id: string) => void onClick: (material: Material) => void onRetryTranscode: (materialId: string) => void showToast: (message: string, options?: { variant?: 'success' | 'error' }) => void } // 根据素材类型获取角标背景颜色 const getMaterialTypeBadgeColor = (materialType: string): string => { const isVideoType = materialType === '主图视频' || materialType === '安装视频' || materialType === '种草视频' if (materialType === '3D场景' || materialType === '3DMAX') { return 'bg-red-500' } else if (isVideoType) { return 'bg-blue-600' } else if (materialType === '音乐') { return 'bg-purple-600' } else if (materialType === 'C4D模型') { return 'bg-blue-500' } else if (materialType === '详情模板' || materialType === '矢量文件') { return 'bg-amber-500' } else { return 'bg-gray-700' } } // 格式化文件大小 const formatFileSize = (bytes: string | number): string => { const size = typeof bytes === 'string' ? parseInt(bytes) : bytes if (size === 0) return '未知' const units = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(size) / Math.log(1024)) return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}` } export default function MaterialCard({ material, session, playingAudioId, audioElement, audioProgress, audioCurrentTime, audioDuration, audioRef, downloadingId, downloadProgress, themeColor, onAudioPlay, onDownload, onLike, onFavorite, onShare, onEdit, onDelete, onClick, onRetryTranscode, showToast, }: MaterialCardProps) { const videoRef = useRef(null) const [posterLoadError, setPosterLoadError] = useState(false) // 判断素材类型 const isVideoType = material.materialType === '主图视频' || material.materialType === '安装视频' || material.materialType === '种草视频' const isAudioMaterial = material.materialType === '音乐' // 视频预览URL const videoPreviewUrl = material.videoPreviewClipUrl || material.videoPreview540Url const posterUrl = material.videoCoverUrl || material.previewUrl // 检测封面图是否可加载 useEffect(() => { if (isVideoType && posterUrl && typeof window !== 'undefined') { setPosterLoadError(false) const img = document.createElement('img') img.onload = () => { setPosterLoadError(false) } img.onerror = () => { console.error('[MaterialCard] 视频封面图预加载失败:', { materialId: material.id, productModel: material.productModel, videoCoverUrl: material.videoCoverUrl, previewUrl: material.previewUrl, posterUrl: posterUrl, timestamp: new Date().toISOString() }) setPosterLoadError(true) } img.src = posterUrl } else { setPosterLoadError(false) } }, [isVideoType, posterUrl, material.id, material.productModel, material.videoCoverUrl, material.previewUrl]) // 音频URL(API已经处理过,直接使用) let audioUrl = '' if (isAudioMaterial && material.zipFileName) { // API已经根据用户网络位置处理了zipFileName: // - 内网用户:/uploads/xxx 或 内网MinIO地址 // - 外网用户:/api/uploads/xxx 或 代理URL audioUrl = material.zipFileName } // 视频鼠标悬停播放控制 const handleVideoMouseEnter = () => { if (videoRef.current) { videoRef.current.play().catch(() => {}) } } const handleVideoMouseLeave = () => { if (videoRef.current) { videoRef.current.pause() videoRef.current.currentTime = 0 } } // 复制ID到剪贴板 const handleCopyId = async (e: React.MouseEvent) => { e.stopPropagation() try { await navigator.clipboard.writeText(material.id) showToast('ID已复制', { variant: 'success' }) } catch { const textarea = document.createElement('textarea') textarea.value = material.id textarea.style.position = 'fixed' textarea.style.left = '-999999px' document.body.appendChild(textarea) textarea.select() try { document.execCommand('copy') showToast('ID已复制', { variant: 'success' }) } catch { showToast('复制失败', { variant: 'error' }) } document.body.removeChild(textarea) } } // 渲染转码进度 const renderTranscodeProgress = () => { if (!isVideoType) return null const status = material.videoProcessingStatus const progress = material.transcodeProgress const stage = material.transcodeStage const attempts = material.transcodeAttempts || 0 const queueIndex = material.transcodeQueueWaitingIndex const queueTotal = material.transcodeQueueWaitingTotal || 0 const processingIds = material.transcodeQueueProcessingIds || [] const maxConcurrent = material.transcodeQueueMaxConcurrent || 2 const isProcessing = processingIds.includes(material.id) // 如果转码完成或没有视频源,不显示进度条 if (!material.videoSourceUrl || status === 'done' || (status !== 'pending' && status !== 'processing' && status !== 'failed' && attempts === 0)) { return null } let label = '' let progressValue = 0 let bgColor = 'bg-gray-700' if (status === 'failed') { const error = material.transcodeLastError || '' const errorMsg = error.replace(/^转码失败[::]\s*/i, '').trim() label = errorMsg ? `转码失败:${errorMsg}` : '转码失败' progressValue = 0 bgColor = 'bg-red-600' } else if (isProcessing) { const stageName = stage === 'preview' ? '预览视频' : stage === 'clip' ? '预览片段' : stage === 'cover' ? '封面图' : '' const processingCount = processingIds.length const waitingCount = Math.max(0, queueTotal - processingCount) if (progress !== null && progress !== undefined && progress > 0) { progressValue = Math.max(5, Math.min(progress, 95)) label = `转码中 ${progress}%${stageName ? ` · ${stageName}` : ''}${attempts > 1 ? ` · 第${attempts}次` : ''}${waitingCount > 0 ? ` · 队列余${waitingCount}` : ''}` } else { progressValue = Math.max(28, Math.min(35 + 55 * Math.min(0.95, 0) + (attempts > 1 ? 5 : 0), 92)) label = `转码中${stageName ? ` · ${stageName}` : ''}${attempts > 1 ? ` · 第${attempts}次` : ''}${waitingCount > 0 ? ` · 队列余${waitingCount}` : ''} · ${processingCount}/${maxConcurrent}并发` } bgColor = 'bg-blue-600' } else if (status === 'processing') { label = `转码中${attempts > 1 ? ` · 第${attempts}次` : ''}` progressValue = 15 bgColor = 'bg-blue-600' } else if (status === 'pending') { label = `等待中${attempts > 1 ? ` · 第${attempts}次` : ''}` progressValue = 5 bgColor = 'bg-gray-600' } else if (queueIndex !== null && queueIndex !== undefined) { const processingCount = processingIds.length label = `排队中 · ${queueIndex}/${queueTotal} · ${processingCount}/${maxConcurrent}执行中` progressValue = 8 bgColor = 'bg-amber-600' } else { label = status || '未知状态' progressValue = 12 bgColor = 'bg-gray-600' } return (
{label} {status === 'failed' && !material.videoPreviewClipUrl && !material.videoPreview540Url && ( )}
) } return (
{/* Preview Image/Video */}
isVideoType && videoPreviewUrl && handleVideoMouseEnter()} onMouseLeave={() => isVideoType && videoPreviewUrl && handleVideoMouseLeave()} onClick={() => onClick(material)} > {/* 视频类型 */} {isVideoType && videoPreviewUrl ? ( <>
) }