flnxi-Wu/components/MaterialPreviewModal.tsx
2026-05-30 17:09:42 +08:00

686 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 }>
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<string | null>(null)
const [videoStatus, setVideoStatus] = useState<string | null | undefined>(material?.videoProcessingStatus)
const [isPolling, setIsPolling] = useState(false)
// 音频播放状态
const audioRef = useRef<HTMLAudioElement | null>(null)
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(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<string, string> = {
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, videoStatus])
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 }) => (
<span
key={badge.label}
className="px-2 py-0.5 text-[11px] font-medium rounded-md border border-slate-200 bg-slate-100 text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
>
{badge.label}
</span>
)
const fileSizeLabel = formatFileSize(material?.zipFileSize)
const primaryDisplayTitle = material?.productModel || title || material?.zipFileName || material?.id || '素材预览'
const handleImageClick = (imgUrl: string) => {
setFullImageUrl(imgUrl)
setShowFullImage(true)
}
// 当预览模态框关闭时,重置全图查看状态
useEffect(() => {
if (!isOpen) {
setShowFullImage(false)
setFullImageUrl(null)
}
}, [isOpen])
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="xlarge"
title={modalTitle}
closeOnOverlayClick
closeOnEscape
showCloseButton
containerStyle={{
width: 'fit-content',
maxWidth: 'min(1000px, 96vw)',
}}
>
<div className="flex flex-col lg:flex-row gap-3 lg:h-[90vh]">
<div
className={cn(
'w-full max-w-full flex-shrink min-h-[360px] max-h-[90vh]',
'overflow-y-auto pr-2 lg:pr-3',
isAudioMaterial ? 'lg:max-w-[500px]' : 'lg:max-w-[600px]'
)}
>
<div className={cn('w-full max-w-full', isAudioMaterial ? 'lg:max-w-[500px]' : 'lg:max-w-[600px]')}>
{isAudioMaterial ? (
// 音频素材播放器
<div
className={cn(
'w-full rounded-lg overflow-hidden border',
theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-white border-slate-200'
)}
>
{/* 顶部 - 音频预览区域 */}
<div className="relative flex items-center justify-center py-8">
<AudioVisualizer
audioElement={audioElement}
isPlaying={isAudioPlaying}
size="md"
coverImage={material?.previewUrl?.startsWith('data:') ? null : material?.previewUrl}
showCoverOnPause={true}
/>
</div>
{/* 音频信息区域 */}
<div className="px-8 py-4 text-center">
<h4 className={cn('text-xl font-semibold mb-2 truncate', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
{material?.productModel || '音频素材'}
</h4>
{/* 时长显示 */}
{material?.audioDuration && (
<div
className={cn(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm mb-4 border',
theme === 'dark'
? 'bg-slate-800 text-slate-200 border-slate-700'
: 'bg-slate-50 text-slate-600 border-slate-200'
)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{material.audioDuration}</span>
</div>
)}
{/* 分类标签 */}
{(material?.audioCategory || material?.audioScene) && (
<div className="flex flex-wrap gap-2 justify-center mb-4">
{material?.audioCategory && (
<span
className={cn(
'px-3 py-1 text-xs font-medium rounded-md border',
theme === 'dark'
? 'bg-slate-800 text-slate-200 border-slate-700'
: 'bg-slate-50 text-slate-600 border-slate-200'
)}
>
{material.audioCategory}
</span>
)}
{material?.audioScene && (
<span
className={cn(
'px-3 py-1 text-xs font-medium rounded-md border',
theme === 'dark'
? 'bg-slate-800 text-slate-200 border-slate-700'
: 'bg-slate-50 text-slate-600 border-slate-200'
)}
>
{material.audioScene}
</span>
)}
</div>
)}
</div>
{/* 音频播放器区域 */}
<div className="px-6 pb-8">
<div
className={cn(
'rounded-lg p-4 border',
theme === 'dark' ? 'bg-slate-950 border-slate-700' : 'bg-slate-50 border-slate-200'
)}
>
{material?.audioUrl || material?.id ? (
<audio
ref={(el) => {
audioRef.current = el
// 当 audio 元素创建时,更新状态以触发 AudioVisualizer 重新渲染
if (el && el !== audioElement) {
setAudioElement(el)
}
}}
controls
controlsList="nodownload"
crossOrigin="anonymous"
className="w-full h-12 rounded-lg"
onPlay={() => setIsAudioPlaying(true)}
onPause={() => setIsAudioPlaying(false)}
onEnded={() => setIsAudioPlaying(false)}
>
{/* 优先使用 API 代理流以避免 CORS 问题,这样 AudioVisualizer 才能正常工作 */}
<source src={material?.id ? `/api/materials/stream?id=${material.id}` : material?.audioUrl || ''} />
您的浏览器不支持音频播放。
</audio>
) : (
<div className={cn('flex items-center justify-center h-12 text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
暂无可播放的音频
</div>
)}
</div>
{/* 文件信息 */}
{fileSizeLabel && (
<div className={cn('mt-4 text-center text-xs', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
文件大小: {fileSizeLabel}
</div>
)}
</div>
</div>
) : isVideoMaterial ? (
videoSource ? (
<div
className={cn(
'w-full rounded-lg overflow-hidden border leading-[0]',
borderColor,
bgColor
)}
>
<video
key={material?.id}
controls
controlsList="nodownload"
poster={videoPoster}
className="block w-auto max-w-[600px] h-auto object-contain align-top"
style={{
maxWidth: '100%',
width: 'auto',
height: 'auto',
objectFit: 'contain',
display: 'block',
borderRadius: '0.5rem',
}}
>
<source src={videoSource} {...(videoSourceMimeType ? { type: videoSourceMimeType } : {})} />
您的浏览器不支持视频播放。
</video>
</div>
) : (
<div className={cn(
'w-full h-[320px] flex flex-col items-center justify-center text-center px-8 rounded-lg border border-dashed',
bgColor,
borderColor,
textSecondary
)}>
<svg className="w-8 h-8 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.868v4.264a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M4.5 6.75v10.5a1.5 1.5 0 001.5 1.5h12a1.5 1.5 0 001.5-1.5V6.75A1.5 1.5 0 0018 5.25H6a1.5 1.5 0 00-1.5 1.5z" />
</svg>
<p className={cn('text-base font-semibold', textPrimary)}>{videoStatusMeta?.label || '预览生成中'}</p>
<p className="text-sm mt-2">
{videoStatusMeta?.description || '系统正在生成压缩预览,稍后可在线播放。'}
</p>
<p className="text-xs mt-3">原始文件已保存,可直接下载。</p>
</div>
)
) : galleryImages.length > 0 ? (
isLongForm ? (
<div className={cn('w-full rounded-lg overflow-hidden leading-[0]', theme === 'dark' ? 'bg-slate-900' : 'bg-white')}>
{galleryImages.map((img, index) => (
<div
key={`${img}-${index}`}
className="relative w-full bg-transparent cursor-pointer"
onClick={() => handleImageClick(img)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img}
alt={`${modalTitle}-preview-${index + 1}`}
className="block w-auto max-w-[600px] h-auto object-contain align-top"
loading={index === 0 ? 'eager' : 'lazy'}
/>
</div>
))}
</div>
) : (
<div
className={cn(
'w-full rounded-lg overflow-hidden border cursor-pointer',
borderColor,
bgColor
)}
onClick={() => handleImageClick(galleryImages[0])}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={galleryImages[0]}
alt={`${modalTitle}-preview`}
className="block w-auto max-w-[600px] h-auto object-contain align-top"
/>
</div>
)
) : (
<div className={cn(
'w-full h-64 rounded-lg flex items-center justify-center border border-dashed',
bgColor,
borderColor,
textSecondary
)}>
暂无预览图
</div>
)}
</div>
</div>
{(material || title || description) && (
<div className={cn(
'w-full lg:w-[300px] flex-shrink-0 space-y-4 border-t lg:border-t-0 lg:border-l lg:pl-4 pt-4 lg:pt-0',
borderColor
)}>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className={cn('text-lg font-semibold break-all', textPrimary)}>
{primaryDisplayTitle}
</h3>
</div>
</div>
{material && (
<div className="flex flex-wrap items-center gap-2">
{material.categoryName && <Tag size="sm">{material.categoryName}</Tag>}
{material.sceneName && <Tag color="green" size="sm">{material.sceneName}</Tag>}
{material.materialType && <Tag color="orange" size="sm">{material.materialType}</Tag>}
{material.materialType === '3DMAX' && material.renderer && (
<Tag color="purple" size="sm">
{material.renderer}
</Tag>
)}
</div>
)}
</div>
{material?.authorName && (
<Link
href={`/author/${encodeURIComponent(material.authorName)}`}
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
title={`查看 ${material.authorName} 的作品`}
>
<Avatar name={material.authorName} src={material.user?.avatar || undefined} size="sm" />
<p className={cn('text-sm font-semibold hover:text-blue-600 dark:hover:text-blue-400 transition-colors', textPrimary)}>
{material.authorName}
</p>
</Link>
)}
<div className={cn('grid grid-cols-2 gap-3 text-sm', textPrimary)}>
{material?.downloadCount !== undefined && (
<div>
<p className={cn('text-xs mb-1', textSecondary)}>下载</p>
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{material.downloadCount}</p>
{downloadBadges.map(renderBadgeChip)}
</div>
</div>
)}
{material?.likeCount !== undefined && (
<div>
<p className={cn('text-xs mb-1', textSecondary)}>点赞</p>
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{material.likeCount}</p>
{likeBadges.map(renderBadgeChip)}
</div>
</div>
)}
{fileSizeLabel && (
<div className="col-span-2">
<p className={cn('text-xs mb-1', textSecondary)}>文件大小</p>
<p className="font-medium text-blue-600 dark:text-blue-400">{fileSizeLabel}</p>
</div>
)}
{material?.zipFileName && (
<div className="col-span-2">
<p className={cn('text-xs mb-1', textSecondary)}>文件名</p>
<p className={cn('text-xs font-medium break-all', textPrimary)}>
{material.zipFileName}
</p>
</div>
)}
{material?.createdAt && (
<div className="col-span-2">
<p className={cn('text-xs mb-1', textSecondary)}>上传时间</p>
<p className="text-sm font-medium">
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
)}
</div>
{description && (
<p className={cn('text-sm leading-relaxed', textSecondary)}>
{description}
</p>
)}
{isVideoMaterial && currentVideoStatus !== 'done' && (
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-xs text-amber-700 dark:text-amber-100">
{isPolling ? '正在检查转码状态...' : '预览生成中,当前可下载原始视频。'}
</div>
)}
{actionSection ? (
<div className="pt-2 space-y-2">{actionSection}</div>
) : (
onDownload && (
<div className="space-y-2 pt-2">
<Button variant="primary" size="md" className="w-full justify-center h-11" onClick={onDownload}>
立即下载
</Button>
</div>
)
)}
<Button
variant="secondary"
size="md"
className="w-full justify-center h-11"
onClick={onClose}
>
关闭
</Button>
</div>
)}
</div>
{/* 全图查看模态框 */}
{showFullImage && fullImageUrl && (
<div
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80"
onClick={() => {
setShowFullImage(false)
setFullImageUrl(null)
}}
>
<div className="relative max-w-[95vw] max-h-[95vh] p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={fullImageUrl || ''}
alt="Full preview"
className="max-w-full max-h-[95vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={() => {
setShowFullImage(false)
setFullImageUrl(null)
}}
className="absolute top-6 right-6 p-2 rounded-md bg-white text-slate-700 hover:bg-slate-100 transition-colors"
aria-label="关闭"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
</Modal>
)
}