'use client' import React, { useEffect, useState } from 'react' import Image from 'next/image' import { format } from 'date-fns' import AudioVisualizer from '../AudioVisualizer' import { getTaskDetailSourceTypeLabel } from '@/lib/task-detail-source-type' import { getCertificateReportDisplayTitle } from '@/lib/certificate-report' import { isVideoMaterialType } from '@/lib/video-material-types' import { getVideoDownloadAvailability, getVideoDownloadUnavailableMessage, VIDEO_DOWNLOAD_WAIT_DAYS, } from '@/lib/video-download-policy' // 素材类型定义 export interface Material { id: string productModel: string reportTitle?: string | null categoryName: string sceneName: string platformName?: string productCategoryName?: string materialType: string isAiGenerated?: boolean taskDetailSourceType?: string | null 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 videoPreviewBlocked?: boolean videoPreviewUnavailableMessage?: string | null videoPreviewRemainingDays?: number | null 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 // 筛选状态(用于空状态提示) hasFilters?: boolean // 事件处理 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 OVERLAY_CHIP_CLASS = 'bg-white/92 text-[#1a1c1d] border border-[#e7e8ea] px-2 py-1 rounded-[8px] text-xs font-medium' function formatFileSize(bytes: string | number): string { const size = typeof bytes === 'string' ? Number.parseInt(bytes, 10) : bytes if (!size || Number.isNaN(size) || size <= 0) return '' const units = ['B', 'KB', 'MB', 'GB'] const unitIndex = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1) return `${(size / Math.pow(1024, unitIndex)).toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}` } function DownloadGlyph() { return ( ) } function ShareGlyph() { return ( ) } function HeartGlyph({ filled }: { filled: boolean }) { return ( ) } export default function MaterialCard({ material, session, playingAudioId, audioElement, audioProgress, audioCurrentTime, audioDuration, audioRef, downloadingId, downloadProgress, onAudioPlay, onDownload, onLike, onFavorite, onShare, onEdit, onDelete, onClick, onRetryTranscode, showToast, }: MaterialCardProps) { const [posterLoadError, setPosterLoadError] = useState(false) const [shouldLoadVideoPreview, setShouldLoadVideoPreview] = useState(false) // 判断素材类型 const isVideoType = isVideoMaterialType(material.materialType) const videoDownloadAvailability = getVideoDownloadAvailability( material.materialType, material.createdAt, ) const isDownloadWaiting = !videoDownloadAvailability.allowed const isPreviewWaiting = isVideoType && (material.videoPreviewBlocked || isDownloadWaiting) const previewUnavailableMessage = material.videoPreviewUnavailableMessage || getVideoDownloadUnavailableMessage(videoDownloadAvailability).replace('下载', '查看详情') const isDownloadDisabled = downloadingId === material.id const isAudioMaterial = material.materialType === '音乐' // 视频预览URL const videoPreviewUrl = isPreviewWaiting ? null : material.videoPreviewClipUrl || material.videoPreview540Url const posterUrl = material.videoCoverUrl || material.previewUrl const materialDisplayTitle = getCertificateReportDisplayTitle({ productModel: material.productModel, reportTitle: material.reportTitle, }) // 素材切换时回到封面态;视频只在用户悬停时加载,避免列表首屏并发预载全部视频。 useEffect(() => { setPosterLoadError(false) setShouldLoadVideoPreview(false) }, [material.id, posterUrl]) // 音频URL(API已经处理过,直接使用) let audioUrl = '' if (isAudioMaterial && material.zipFileName) { // API已经根据用户网络位置处理了zipFileName: // - 内网用户:/uploads/xxx 或 内网MinIO地址 // - 外网用户:/api/uploads/xxx 或 代理URL audioUrl = material.zipFileName } const isTaskDetailMaterial = material.materialType === '任务-详情' const handleMaterialClick = () => { if (isPreviewWaiting) { showToast(previewUnavailableMessage, { variant: 'error' }) return } onClick(material) } // 视频鼠标悬停播放控制:离开后卸载 video,立即释放媒体连接。 const handleVideoMouseEnter = () => { setShouldLoadVideoPreview(true) } const handleVideoMouseLeave = () => { setShouldLoadVideoPreview(false) } // 复制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 || 1 const isProcessing = processingIds.includes(material.id) // 15天策略会隐藏 videoSourceUrl;进度只能依据公开的处理状态判断。 if (status === 'done' || (status !== 'pending' && status !== 'processing' && status !== 'failed')) { return null } let label = '' let progressValue = 0 let bgColor = 'bg-gray-700' if (status === 'failed') { label = '转码失败,请稍后重试' 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-[#1a1c1d]' } else if (status === 'processing') { label = `转码中${attempts > 1 ? ` · 第${attempts}次` : ''}` progressValue = 15 bgColor = 'bg-[#1a1c1d]' } else if (status === 'pending') { label = `等待中${attempts > 1 ? ` · 第${attempts}次` : ''}` progressValue = 5 bgColor = 'bg-[#86898e]' } else if (queueIndex !== null && queueIndex !== undefined) { const processingCount = processingIds.length label = `排队中 · ${queueIndex}/${queueTotal} · ${processingCount}/${maxConcurrent}执行中` progressValue = 8 bgColor = 'bg-[#6e79c8]' } else { label = status || '未知状态' progressValue = 12 bgColor = 'bg-[#86898e]' } return ( <> {status !== 'failed' && (
{label}
)}
{label} {status === 'failed' && !material.videoPreviewClipUrl && !material.videoPreview540Url && ( )}
) } const fileSizeLabel = formatFileSize(material.zipFileSize) return (
{/* Preview Image/Video */}
isVideoType && videoPreviewUrl && handleVideoMouseEnter()} onMouseLeave={() => isVideoType && videoPreviewUrl && handleVideoMouseLeave()} onClick={handleMaterialClick} > {/* 视频类型 */} {isVideoType && videoPreviewUrl ? ( <> {shouldLoadVideoPreview ? (
) }