'use client' import type { ReactNode } from 'react' import { useState, useEffect, useRef } from 'react' import Link from 'next/link' import { format } from 'date-fns' import Modal from './ui/Modal' import Tag from './ui/Tag' import Avatar from './ui/Avatar' import Button from './ui/Button' import AudioVisualizer from './AudioVisualizer' import { cn, useTheme } from '@/lib/ui-styles' const VIDEO_STATUS_META = { pending: { label: '等待转码', description: '素材上传完成,后台即将启动压缩任务。', }, processing: { label: '转码中', description: '正在生成 540P 预览与首页 10 秒预览片段。', }, done: { label: '可预览', description: '已生成压缩版本,可直接播放预览。', }, failed: { label: '转码失败', description: '压缩任务失败,请联系管理员重新处理。', }, } as const interface PreviewMaterialInfo { id?: string productModel?: string materialKind?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER' previewUrl?: string videoCoverUrl?: string | null videoPreview540Url?: string | null videoPreviewClipUrl?: string | null videoProcessingStatus?: string | null videoDuration?: number | null displayImages?: string[] materialType?: string categoryName?: string sceneName?: string renderer?: string authorName?: string downloadCount?: number likeCount?: number createdAt?: string zipFileName?: string zipFileSize?: string | number rankingBadges?: Array<{ label: string; gradient: string }> isPremium?: boolean isFavorited?: boolean user?: { username?: string avatar?: string | null } // 音频相关字段 audioDuration?: string | null audioCategory?: string | null audioScene?: string | null audioUrl?: string | null } interface MaterialPreviewModalProps { isOpen: boolean onClose: () => void material?: PreviewMaterialInfo | null imageUrl?: string title?: string description?: string onDownload?: () => void actionSection?: ReactNode } const formatFileSize = (bytes?: string | number) => { if (bytes === undefined || bytes === null) return '' const size = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes if (!size || Number.isNaN(size)) return '' const units = ['B', 'KB', 'MB', 'GB'] const i = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024))) return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}` } const resolveVideoStatus = (status?: string | null): 'pending' | 'processing' | 'done' | 'failed' => { const normalized = (status || '').trim().toLowerCase() if (normalized === 'processing' || normalized === 'done' || normalized === 'failed') { return normalized } return 'pending' } export default function MaterialPreviewModal({ isOpen, onClose, material, imageUrl, title, description, onDownload, actionSection, }: MaterialPreviewModalProps) { const theme = useTheme() const [showFullImage, setShowFullImage] = useState(false) const [fullImageUrl, setFullImageUrl] = useState(null) const [videoStatus, setVideoStatus] = useState(material?.videoProcessingStatus) const [isPolling, setIsPolling] = useState(false) // 音频播放状态 const audioRef = useRef(null) const [audioElement, setAudioElement] = useState(null) const [isAudioPlaying, setIsAudioPlaying] = useState(false) // 当 material 变化时,同步更新 videoStatus useEffect(() => { if (material?.videoProcessingStatus !== undefined) { setVideoStatus(material.videoProcessingStatus) } }, [material?.videoProcessingStatus]) // 当弹窗关闭时停止音频播放并重置状态 useEffect(() => { if (!isOpen) { if (audioRef.current) { audioRef.current.pause() } setIsAudioPlaying(false) setAudioElement(null) } }, [isOpen]) // 主题颜色 const textPrimary = theme === 'dark' ? 'text-white' : 'text-slate-900' const textSecondary = theme === 'dark' ? 'text-slate-400' : 'text-slate-500' const borderColor = theme === 'dark' ? 'border-slate-700/70' : 'border-slate-200/70' const bgColor = theme === 'dark' ? 'bg-slate-800' : 'bg-slate-100' const resolvedMaterialKind = (material?.materialKind || '').toUpperCase() const isVideoMaterial = resolvedMaterialKind === 'VIDEO' || material?.materialType === '视频' const isAudioMaterial = resolvedMaterialKind === 'AUDIO' || material?.materialType === '音频' const currentVideoStatus = isVideoMaterial ? resolveVideoStatus(videoStatus) : 'done' const inferVideoMimeType = () => { if (!material?.zipFileName) return 'video/mp4' const ext = material.zipFileName.split('.').pop()?.toLowerCase() const videoMap: Record = { mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', avi: 'video/x-msvideo', wmv: 'video/x-ms-wmv', flv: 'video/x-flv', mkv: 'video/x-matroska', } return (ext && videoMap[ext]) || 'video/mp4' } const videoMimeType = inferVideoMimeType() const previewVideoSource = isVideoMaterial && currentVideoStatus === 'done' ? material?.videoPreview540Url || material?.videoPreviewClipUrl || null : null const fallbackVideoSource = isVideoMaterial && currentVideoStatus === 'done' && material?.id ? `/api/materials/stream?id=${material.id}` : null const videoSource = previewVideoSource || fallbackVideoSource const videoSourceMimeType = previewVideoSource ? undefined : videoMimeType const fallbackCover = material?.videoCoverUrl || (material?.displayImages && material.displayImages.length > 0 ? material.displayImages[0] : null) || material?.previewUrl || imageUrl const videoStatusMeta = isVideoMaterial ? VIDEO_STATUS_META[currentVideoStatus] : null const galleryImages = material ? material.displayImages && material.displayImages.length > 0 ? material.displayImages : fallbackCover ? [fallbackCover] : [] : imageUrl ? [imageUrl] : [] const videoPoster = isVideoMaterial ? fallbackCover || galleryImages[0] : undefined // 视频转码状态轮询 useEffect(() => { if (!isOpen || !isVideoMaterial || !material?.id) { setIsPolling(false) return } const currentStatus = resolveVideoStatus(videoStatus) // 只在 pending 或 processing 状态时轮询 if (currentStatus !== 'pending' && currentStatus !== 'processing') { setIsPolling(false) return } setIsPolling(true) let pollInterval: NodeJS.Timeout | null = null let pollTimeout: NodeJS.Timeout | null = null let isCleanedUp = false const pollStatus = async () => { if (isCleanedUp) return try { const response = await fetch(`/api/materials/transcode?materialId=${material.id}`) if (!response.ok) { return } const data = await response.json() const newStatus = data.status || 'pending' if (isCleanedUp) return setVideoStatus(newStatus) // 如果状态变为 done 或 failed,停止轮询 if (newStatus === 'done' || newStatus === 'failed') { setIsPolling(false) if (pollInterval) { clearInterval(pollInterval) pollInterval = null } if (pollTimeout) { clearTimeout(pollTimeout) pollTimeout = null } // 不再自动刷新页面,让用户继续查看弹窗 // 如果需要刷新数据,可以通过父组件的事件回调来实现 } } catch (error) { console.error('[MaterialPreview] Failed to poll transcode status:', error) } } // 立即执行一次 pollStatus() // 每 5 秒轮询一次 pollInterval = setInterval(pollStatus, 5000) // 最多轮询 10 分钟(120 次) pollTimeout = setTimeout(() => { setIsPolling(false) if (pollInterval) { clearInterval(pollInterval) pollInterval = null } }, 10 * 60 * 1000) return () => { isCleanedUp = true if (pollInterval) { clearInterval(pollInterval) } if (pollTimeout) { clearTimeout(pollTimeout) } setIsPolling(false) } }, [isOpen, isVideoMaterial, material?.id]) const modalTitle = material?.productModel || title || '素材预览' const isLongForm = material?.materialType === 'PSD' || material?.materialType === '模特' || (material?.displayImages && material.displayImages.length > 1) const rankingBadges = material?.rankingBadges ?? [] const downloadBadges = rankingBadges.filter((badge) => badge.label.includes('下载')) const likeBadges = rankingBadges.filter((badge) => badge.label.includes('点赞')) const renderBadgeChip = (badge: { label: string; gradient: string }) => ( {badge.label} ) const fileSizeLabel = formatFileSize(material?.zipFileSize) const primaryDisplayTitle = material?.id || material?.productModel || title || '素材预览' const handleImageClick = (imgUrl: string) => { setFullImageUrl(imgUrl) setShowFullImage(true) } // 当预览模态框关闭时,重置全图查看状态 useEffect(() => { if (!isOpen) { setShowFullImage(false) setFullImageUrl(null) } }, [isOpen]) return (
{isAudioMaterial ? ( // 音频素材播放器 - 带圆形频谱可视化
{/* 顶部 - 圆形频谱可视化区域 */}
{/* 音频信息区域 */}

{material?.productModel || '音频素材'}

{/* 时长显示 */} {material?.audioDuration && (
{material.audioDuration}
)} {/* 分类标签 */} {(material?.audioCategory || material?.audioScene) && (
{material?.audioCategory && ( {material.audioCategory} )} {material?.audioScene && ( {material.audioScene} )}
)}
{/* 音频播放器区域 */}
{material?.audioUrl || material?.id ? ( ) : (
暂无可播放的音频
)}
{/* 文件信息 */} {fileSizeLabel && (
文件大小: {fileSizeLabel}
)}
) : isVideoMaterial ? ( videoSource ? (
) : (

{videoStatusMeta?.label || '预览生成中'}

{videoStatusMeta?.description || '视频压缩任务进行中,稍后可直接在此播放压缩预览。'}

原始文件已保存,可随时下载使用。

) ) : galleryImages.length > 0 ? ( isLongForm ? (
{galleryImages.map((img, index) => (
handleImageClick(img)} > {/* eslint-disable-next-line @next/next/no-img-element */} {`${modalTitle}-preview-${index
))}
) : (
handleImageClick(galleryImages[0])} > {/* eslint-disable-next-line @next/next/no-img-element */} {`${modalTitle}-preview`}
) ) : (
暂无预览图
)}
{(material || title || description) && (

{primaryDisplayTitle}

{material && (
{material.categoryName && {material.categoryName}} {material.sceneName && {material.sceneName}} {material.materialType && {material.materialType}} {material.materialType === '3DMAX' && material.renderer && ( {material.renderer} )}
)}
{material?.authorName && (

{material.authorName}

)}
{material?.downloadCount !== undefined && (

下载

{material.downloadCount}

{downloadBadges.map(renderBadgeChip)}
)} {material?.likeCount !== undefined && (

点赞

{material.likeCount}

{likeBadges.map(renderBadgeChip)}
)} {fileSizeLabel && (

文件大小

{fileSizeLabel}

)} {material?.zipFileName && (

文件名

{material.zipFileName}

)} {material?.createdAt && (

上传时间

{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}

)}
{(description || (!material && title)) && (

{description || '该素材使用统一预览弹窗展示'}

)} {isVideoMaterial && currentVideoStatus !== 'done' && (
{isPolling ? '正在检查转码状态...' : '压缩预览生成中,片刻后刷新即可在线播放,当前已可直接下载原始视频。'}
)} {actionSection ? (
{actionSection}
) : ( onDownload && (
) )}
)}
{/* 全图查看模态框 */} {showFullImage && fullImageUrl && (
{ setShowFullImage(false) setFullImageUrl(null) }} >
{/* eslint-disable-next-line @next/next/no-img-element */} Full preview e.stopPropagation()} />
)}
) }