686 lines
26 KiB
TypeScript
686 lines
26 KiB
TypeScript
'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>
|
||
)
|
||
}
|