556 lines
20 KiB
TypeScript
556 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import type { ReactNode } from 'react'
|
||
import { useState, useEffect } from 'react'
|
||
import Image from 'next/image'
|
||
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 { cn, useThemeStyles, 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' | '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
|
||
}
|
||
}
|
||
|
||
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'
|
||
}
|
||
|
||
const formatVideoDuration = (seconds?: number | null) => {
|
||
if (!seconds || Number.isNaN(seconds) || seconds <= 0) return ''
|
||
const mins = Math.floor(seconds / 60)
|
||
const secs = Math.floor(seconds % 60)
|
||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||
}
|
||
|
||
export default function MaterialPreviewModal({
|
||
isOpen,
|
||
onClose,
|
||
material,
|
||
imageUrl,
|
||
title,
|
||
description,
|
||
onDownload,
|
||
actionSection,
|
||
}: MaterialPreviewModalProps) {
|
||
const styles = useThemeStyles()
|
||
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)
|
||
|
||
// 当 material 变化时,同步更新 videoStatus
|
||
useEffect(() => {
|
||
if (material?.videoProcessingStatus !== undefined) {
|
||
setVideoStatus(material.videoProcessingStatus)
|
||
}
|
||
}, [material?.videoProcessingStatus])
|
||
|
||
// 主题颜色
|
||
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 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 videoDurationLabel = isVideoMaterial ? formatVideoDuration(material?.videoDuration) : ''
|
||
|
||
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 }) => (
|
||
<span
|
||
key={badge.label}
|
||
className={cn(
|
||
'px-3 py-1 text-[11px] font-semibold text-white rounded-full shadow-sm bg-gradient-to-r',
|
||
badge.gradient
|
||
)}
|
||
>
|
||
{badge.label}
|
||
</span>
|
||
)
|
||
const fileSizeLabel = formatFileSize(material?.zipFileSize)
|
||
const primaryDisplayTitle = material?.id || material?.productModel || title || '素材预览'
|
||
const secondaryTitle =
|
||
material?.productModel && material?.id && material.productModel !== material.id
|
||
? material.productModel
|
||
: !material?.productModel && title && title !== material?.id
|
||
? title
|
||
: null
|
||
|
||
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(
|
||
'flex-shrink min-h-[450px] max-h-[90vh]',
|
||
'overflow-y-auto pr-2 lg:pr-3',
|
||
'lg:max-w-[600px]'
|
||
)}
|
||
>
|
||
<div className="w-auto max-w-[600px]">
|
||
{isVideoMaterial ? (
|
||
videoSource ? (
|
||
<div
|
||
className={cn(
|
||
'w-full rounded-[32px] overflow-hidden shadow-inner 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: '32px',
|
||
}}
|
||
>
|
||
<source src={videoSource} {...(videoSourceMimeType ? { type: videoSourceMimeType } : {})} />
|
||
您的浏览器不支持视频播放。
|
||
</video>
|
||
</div>
|
||
) : (
|
||
<div className="w-full h-[360px] flex flex-col items-center justify-center text-center px-8 bg-gradient-to-b from-slate-900 to-slate-800 text-white rounded-[32px]">
|
||
<svg className="w-10 h-10 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="text-lg font-semibold">{videoStatusMeta?.label || '预览生成中'}</p>
|
||
<p className="text-sm text-white/80 mt-2">
|
||
{videoStatusMeta?.description || '视频压缩任务进行中,稍后可直接在此播放压缩预览。'}
|
||
</p>
|
||
<p className="text-xs text-white/60 mt-3">原始文件已保存,可随时下载使用。</p>
|
||
</div>
|
||
)
|
||
) : galleryImages.length > 0 ? (
|
||
isLongForm ? (
|
||
<div className={cn('w-full rounded-[32px] overflow-hidden shadow-inner leading-[0]', theme === 'dark' ? 'bg-slate-900/70' : 'bg-white/95')}>
|
||
{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-[32px] overflow-hidden shadow-inner 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-[32px] 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-2xl font-bold 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 && (
|
||
<div className="flex items-center gap-3">
|
||
<Avatar name={material.authorName} src={material.user?.avatar || undefined} size="sm" />
|
||
<p className={cn('text-sm font-semibold', textPrimary)}>
|
||
{material.authorName}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<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 || (!material && title)) && (
|
||
<p className={cn('text-sm leading-relaxed', textSecondary)}>
|
||
{description || '该素材使用统一预览弹窗展示'}
|
||
</p>
|
||
)}
|
||
{isVideoMaterial && currentVideoStatus !== 'done' && (
|
||
<div className="p-3 rounded-xl 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 backdrop-blur-md"
|
||
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-full bg-white/10 hover:bg-white/20 text-white 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>
|
||
)
|
||
}
|
||
|