flnxi-Wu/components/MaterialPreviewModal.tsx
WuYongJie 63ec6b7dfb fix: 修复排行榜按钮消失、tag排序和输入框浅色模式问题
1. 排行榜悬浮窗关闭后小按钮消失问题
   - 修复面板居中模式关闭时按钮位置错误
   - 保存并恢复按钮的居中状态

2. 首页tag按钮按素材数量排序
   - 添加按素材数量降序排序逻辑

3. 上传弹窗输入框浅色模式文字不可见
   - 添加 text-slate-900 确保浅色模式下文字可见
   - 添加 placeholder 样式确保占位符正确显示
2025-12-14 21:41:29 +08:00

662 lines
25 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; 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<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])
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 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',
isAudioMaterial ? 'lg:min-w-[500px]' : 'lg:max-w-[600px]'
)}
>
<div className={cn('w-auto', isAudioMaterial ? 'min-w-[450px]' : 'max-w-[600px]')}>
{isAudioMaterial ? (
// 音频素材播放器 - 带圆形频谱可视化
<div
className={cn(
'w-full min-w-[450px] rounded-[32px] overflow-hidden shadow-2xl',
'bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900'
)}
>
{/* 顶部 - 圆形频谱可视化区域 */}
<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="text-xl font-bold text-white mb-2 truncate">
{material?.productModel || '音频素材'}
</h4>
{/* 时长显示 */}
{material?.audioDuration && (
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 text-white/80 text-sm mb-4">
<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="px-3 py-1 text-xs font-medium bg-blue-500/20 text-blue-300 rounded-full border border-blue-500/30">
{material.audioCategory}
</span>
)}
{material?.audioScene && (
<span className="px-3 py-1 text-xs font-medium bg-emerald-500/20 text-emerald-300 rounded-full border border-emerald-500/30">
{material.audioScene}
</span>
)}
</div>
)}
</div>
{/* 音频播放器区域 */}
<div className="px-6 pb-8">
<div className="bg-white/5 rounded-2xl p-4 backdrop-blur-sm border border-white/10">
{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"
style={{
filter: 'invert(1) hue-rotate(180deg) brightness(0.9)',
}}
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="flex items-center justify-center h-12 text-white/50 text-sm">
暂无可播放的音频
</div>
)}
</div>
{/* 文件信息 */}
{fileSizeLabel && (
<div className="mt-4 text-center text-white/50 text-xs">
文件大小: {fileSizeLabel}
</div>
)}
</div>
</div>
) : 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 && (
<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 || (!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>
)
}