1058 lines
45 KiB
TypeScript
1058 lines
45 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter, useParams } from 'next/navigation'
|
||
import Image from 'next/image'
|
||
import { format } from 'date-fns'
|
||
import Header from '@/components/Header'
|
||
import VideoPlayerModal from '@/components/VideoPlayerModal'
|
||
import SkuList from '@/components/SkuList'
|
||
import SkuEditModal from '@/components/SkuEditModal'
|
||
import ToastBubble from '@/components/ToastBubble'
|
||
import { useToast } from '@/hooks/useToast'
|
||
import AudioVisualizer from '@/components/AudioVisualizer'
|
||
import ProductPackageDetail from '@/components/detail/ProductPackageDetail'
|
||
import { parseDownloadError } from '@/lib/download-errors'
|
||
import { parseApiError, parseFetchError } from '@/lib/error-handler'
|
||
|
||
interface Material {
|
||
id: string
|
||
userId: string
|
||
previewUrl: string
|
||
productModel: string
|
||
categoryName: string
|
||
sceneName: string
|
||
platformName?: string // 平台分类
|
||
productCategoryName?: string // 品类分类
|
||
materialType: string
|
||
authorName: string
|
||
zipFileName: string
|
||
zipFileSize: string | number
|
||
downloadCount: number
|
||
likeCount: number
|
||
displayImages: string[] // PSD展示图片URLs
|
||
createdAt: string
|
||
videoSourceUrl?: string | null // 原始视频URL
|
||
videoPreview540Url?: string | null // 720p预览视频URL(字段名保持540Url以兼容数据库)
|
||
videoPreviewClipUrl?: string | null // 视频片段URL(10秒,无音频)
|
||
videoCoverUrl?: string | null // 视频封面URL
|
||
videoDuration?: number | null // 视频时长(秒)
|
||
videoProcessingStatus?: string | null // 视频处理状态
|
||
isLiked?: boolean // 是否已点赞
|
||
isFavorited?: boolean // 是否已收藏
|
||
taskNo?: string // 产品全案任务号
|
||
taskStatus?: string // 产品全案任务状态
|
||
taskFetchedAt?: string // 产品全案被获取时间
|
||
canDownloadSource?: boolean // 产品全案 PSD 下载权限(参考"详情模板"类型)
|
||
assets?: Array<{
|
||
id: string
|
||
assetType: string
|
||
fileUrl: string
|
||
fileName: string
|
||
fileSize: number
|
||
sortOrder: number
|
||
}> // 产品全案资产列表
|
||
skus?: Array<{
|
||
id: string
|
||
skuName: string
|
||
filePath: string
|
||
fileSize: string
|
||
sortOrder: number
|
||
}>
|
||
}
|
||
|
||
export default function SharePage() {
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const params = useParams()
|
||
const materialId = params?.id as string
|
||
|
||
const [material, setMaterial] = useState<Material | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [downloading, setDownloading] = useState(false)
|
||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||
const [showBackToTop, setShowBackToTop] = useState(false)
|
||
const [showVideoModal, setShowVideoModal] = useState(false)
|
||
const [videoSrc, setVideoSrc] = useState<string | null>(null)
|
||
const [showSkuEditModal, setShowSkuEditModal] = useState(false)
|
||
const { toast, showToast } = useToast() // 统一的Toast提示
|
||
// 音频播放状态
|
||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||
const [playingAudio, setPlayingAudio] = useState(false)
|
||
const [audioProgress, setAudioProgress] = useState(0) // 0-100
|
||
// SKU选中状态(用于标准模型类型的批量下载)
|
||
const [selectedSkuIds, setSelectedSkuIds] = useState<Set<string>>(new Set())
|
||
|
||
useEffect(() => {
|
||
// 分享页面允许未登录访问,立即开始加载,不等待session状态
|
||
// 使用setTimeout确保在页面渲染后立即开始加载
|
||
const timer = setTimeout(() => {
|
||
fetchMaterial()
|
||
}, 0)
|
||
return () => clearTimeout(timer)
|
||
}, [materialId])
|
||
|
||
useEffect(() => {
|
||
// 监听滚动,控制返回顶部按钮显示
|
||
const handleScroll = () => {
|
||
setShowBackToTop(window.scrollY > 400)
|
||
}
|
||
window.addEventListener('scroll', handleScroll)
|
||
return () => window.removeEventListener('scroll', handleScroll)
|
||
}, [])
|
||
|
||
const fetchMaterial = async () => {
|
||
try {
|
||
setLoading(true)
|
||
|
||
// 如果是视频类型,立即开始预加载视频(在获取数据前就开始)
|
||
const isVideoType = materialId && (
|
||
materialId.includes('video') ||
|
||
// 先尝试获取基本信息判断类型(如果可能)
|
||
false
|
||
)
|
||
|
||
const response = await fetch(`/api/materials/share?id=${materialId}`, {
|
||
cache: 'no-store', // 确保获取最新数据
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setMaterial(data)
|
||
setLoading(false) // 数据加载完成,立即显示页面
|
||
|
||
// 如果是视频类型,立即开始预加载视频文件(不等待渲染)
|
||
// 优化:优先使用更小的预览视频(videoPreviewClipUrl < videoPreview540Url < videoSourceUrl)
|
||
if (data.materialType === '主图视频' || data.materialType === '安装视频' || data.materialType === '种草视频') {
|
||
// 优先使用最小的预览视频(10秒片段 < 720p预览 < 源视频)
|
||
const videoUrl = data.videoPreviewClipUrl || data.videoPreview540Url || data.videoSourceUrl
|
||
if (videoUrl) {
|
||
// 清理之前的预加载视频(如果有)
|
||
const existingPreloadVideos = document.querySelectorAll('video[data-preload-video="true"]')
|
||
existingPreloadVideos.forEach(video => {
|
||
try {
|
||
if (video.parentNode) {
|
||
video.parentNode.removeChild(video)
|
||
}
|
||
} catch (e) {
|
||
// 忽略清理错误
|
||
}
|
||
})
|
||
|
||
// 方法1:使用 link rel="prefetch" 预加载(浏览器级别预加载,优先级高)
|
||
const link = document.createElement('link')
|
||
link.rel = 'prefetch'
|
||
link.as = 'video'
|
||
link.href = videoUrl
|
||
// 设置高优先级,确保浏览器优先下载
|
||
link.setAttribute('fetchpriority', 'high')
|
||
document.head.appendChild(link)
|
||
|
||
// 方法2:创建隐藏的video元素开始加载(更激进,立即开始下载)
|
||
// 注意:简化预加载逻辑,避免无限循环和内存泄漏
|
||
const preloadVideo = document.createElement('video')
|
||
preloadVideo.setAttribute('data-preload-video', 'true') // 标记以便清理
|
||
preloadVideo.src = videoUrl
|
||
preloadVideo.preload = 'auto' // 自动预加载
|
||
preloadVideo.muted = true // 静音预加载,避免音频加载,加快速度
|
||
preloadVideo.playsInline = true // 内联播放
|
||
preloadVideo.style.position = 'absolute'
|
||
preloadVideo.style.top = '-9999px'
|
||
preloadVideo.style.left = '-9999px'
|
||
preloadVideo.style.width = '1px'
|
||
preloadVideo.style.height = '1px'
|
||
preloadVideo.style.opacity = '0'
|
||
preloadVideo.style.pointerEvents = 'none'
|
||
document.body.appendChild(preloadVideo)
|
||
|
||
// 立即开始加载(触发下载)- 只调用一次,不重复调用
|
||
preloadVideo.load()
|
||
|
||
// 清理函数
|
||
const cleanup = () => {
|
||
try {
|
||
if (preloadVideo && preloadVideo.parentNode) {
|
||
preloadVideo.parentNode.removeChild(preloadVideo)
|
||
}
|
||
} catch (e) {
|
||
// 忽略清理错误
|
||
}
|
||
}
|
||
|
||
// 加载完成后,延迟清理(给浏览器时间缓存)
|
||
const loadedDataHandler = () => {
|
||
console.log('[Video Preload] 视频预加载完成,已缓存到内存')
|
||
// 延迟清理,确保缓存完成(增加到15秒,确保有足够时间缓存)
|
||
setTimeout(cleanup, 15000)
|
||
preloadVideo.removeEventListener('loadeddata', loadedDataHandler)
|
||
}
|
||
preloadVideo.addEventListener('loadeddata', loadedDataHandler, { once: true })
|
||
|
||
// 监听进度,提前缓存更多数据
|
||
preloadVideo.addEventListener('progress', () => {
|
||
// 继续加载,确保缓存足够的数据
|
||
if (preloadVideo.readyState >= 3) {
|
||
// 已经加载了足够的数据,可以提前清理(可选)
|
||
}
|
||
})
|
||
|
||
const errorHandler = () => {
|
||
console.warn('[Video Preload] 视频预加载失败')
|
||
cleanup()
|
||
preloadVideo.removeEventListener('error', errorHandler)
|
||
}
|
||
preloadVideo.addEventListener('error', errorHandler, { once: true })
|
||
|
||
// 页面卸载时清理
|
||
const beforeUnloadHandler = () => {
|
||
cleanup()
|
||
}
|
||
window.addEventListener('beforeunload', beforeUnloadHandler, { once: true })
|
||
}
|
||
}
|
||
|
||
// 如果是标准模型类型,获取SKU列表(异步,不阻塞页面显示)
|
||
if (data.materialType === '标准模型') {
|
||
fetch(`/api/materials/${materialId}/skus`)
|
||
.then(skuResponse => {
|
||
if (skuResponse.ok) {
|
||
return skuResponse.json()
|
||
}
|
||
return null
|
||
})
|
||
.then(skuData => {
|
||
if (skuData) {
|
||
setMaterial(prev => prev ? { ...prev, skus: skuData.skus } : null)
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.warn('[Share] Failed to fetch SKUs:', err)
|
||
})
|
||
}
|
||
} else {
|
||
showToast('素材不存在或已被删除', { variant: 'error' })
|
||
router.push('/')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching material:', error)
|
||
const { message } = parseApiError(error)
|
||
showToast(message, { variant: 'error' })
|
||
router.push('/')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const formatFileSize = (bytes: string | number): string => {
|
||
const size = typeof bytes === 'string' ? parseInt(bytes) : bytes
|
||
if (size === 0) return '未知'
|
||
const units = ['B', 'KB', 'MB', 'GB']
|
||
const i = Math.floor(Math.log(size) / Math.log(1024))
|
||
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
|
||
}
|
||
|
||
const scrollToTop = () => {
|
||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
}
|
||
|
||
// 音频播放控制
|
||
const handleAudioPlay = useCallback(() => {
|
||
if (!material || material.materialType !== '音乐') {
|
||
console.warn('[Share] handleAudioPlay: material is not music type', material?.materialType)
|
||
return
|
||
}
|
||
|
||
// 如果正在播放,则暂停
|
||
if (playingAudio) {
|
||
if (audioRef.current) {
|
||
audioRef.current.pause()
|
||
}
|
||
setPlayingAudio(false)
|
||
setAudioProgress(0)
|
||
return
|
||
}
|
||
|
||
// 如果音频已经加载,直接播放
|
||
if (audioRef.current && audioRef.current.readyState >= 2) {
|
||
audioRef.current.play().then(() => {
|
||
setPlayingAudio(true)
|
||
}).catch((error) => {
|
||
console.error('[Share] Audio play failed:', error)
|
||
showToast(`播放失败: ${error.message || '未知错误'}`, { variant: 'error' })
|
||
})
|
||
return
|
||
}
|
||
|
||
// 停止之前的音频
|
||
if (audioRef.current) {
|
||
audioRef.current.pause()
|
||
audioRef.current = null
|
||
}
|
||
|
||
// 创建新的音频元素
|
||
// API已经根据用户网络位置处理了zipFileName:
|
||
// - 内网用户:/uploads/xxx 或 内网MinIO地址
|
||
// - 外网用户:/api/uploads/xxx 或 代理URL
|
||
let audioUrl = ''
|
||
if (material.zipFileName) {
|
||
audioUrl = material.zipFileName
|
||
}
|
||
|
||
console.log('[Share] Audio URL:', audioUrl, 'Material:', { id: material.id, zipFileName: material.zipFileName })
|
||
|
||
if (!audioUrl) {
|
||
console.error('[Share] No audio URL found for material:', material.id)
|
||
showToast('音频文件不存在', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
const audio = new Audio(audioUrl)
|
||
audio.preload = 'auto' // 预加载音频
|
||
audioRef.current = audio
|
||
|
||
// 监听加载错误
|
||
audio.addEventListener('error', (e) => {
|
||
const error = audio.error
|
||
console.error('[Share] Audio error:', {
|
||
code: error?.code,
|
||
message: error?.message,
|
||
audioUrl,
|
||
materialId: material.id
|
||
})
|
||
showToast(`音频加载失败: ${error?.message || '未知错误'}`, { variant: 'error' })
|
||
setPlayingAudio(false)
|
||
setAudioProgress(0)
|
||
audioRef.current = null
|
||
})
|
||
|
||
// 监听可以播放(使用canplay事件,有足够数据就开始播放,减少延迟)
|
||
const handleCanPlay = () => {
|
||
console.log('[Share] Audio can play:', audioUrl)
|
||
audio.play().then(() => {
|
||
console.log('[Share] Audio playback started:', audioUrl)
|
||
setPlayingAudio(true)
|
||
}).catch((error) => {
|
||
console.error('[Share] Audio play failed:', error, 'URL:', audioUrl)
|
||
showToast(`播放失败: ${error.message || '未知错误'}`, { variant: 'error' })
|
||
setPlayingAudio(false)
|
||
audioRef.current = null
|
||
})
|
||
audio.removeEventListener('canplay', handleCanPlay)
|
||
}
|
||
audio.addEventListener('canplay', handleCanPlay)
|
||
|
||
// 如果音频已经可以播放,立即播放
|
||
if (audio.readyState >= 2) {
|
||
handleCanPlay()
|
||
}
|
||
|
||
// 监听播放进度
|
||
audio.addEventListener('timeupdate', () => {
|
||
if (audio.duration) {
|
||
setAudioProgress((audio.currentTime / audio.duration) * 100)
|
||
}
|
||
})
|
||
|
||
// 监听播放结束
|
||
audio.addEventListener('ended', () => {
|
||
setPlayingAudio(false)
|
||
setAudioProgress(0)
|
||
})
|
||
|
||
// 开始加载音频
|
||
audio.load()
|
||
}, [material, playingAudio, showToast])
|
||
|
||
// 组件卸载时清理音频
|
||
useEffect(() => {
|
||
return () => {
|
||
if (audioRef.current) {
|
||
audioRef.current.pause()
|
||
audioRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
// 批量下载选中的SKU(标准模型类型)
|
||
const handleBatchDownloadSkus = async () => {
|
||
if (!material || selectedSkuIds.size === 0) {
|
||
showToast('请先选择要下载的SKU', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
setDownloading(true)
|
||
setDownloadProgress(0)
|
||
|
||
try {
|
||
const response = await fetch(`/api/materials/${materialId}/skus/batch-download`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
skuIds: Array.from(selectedSkuIds)
|
||
})
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json()
|
||
const { message } = parseDownloadError(error)
|
||
showToast(message, { variant: 'error' })
|
||
setDownloading(false)
|
||
setDownloadProgress(0)
|
||
return
|
||
}
|
||
|
||
// 获取ZIP文件并下载
|
||
const blob = await response.blob()
|
||
const url = window.URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
|
||
// 从Content-Disposition获取文件名
|
||
const contentDisposition = response.headers.get('Content-Disposition')
|
||
let filename = `SKU批量下载_${Date.now()}.zip`
|
||
if (contentDisposition) {
|
||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||
if (filenameMatch && filenameMatch[1]) {
|
||
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
|
||
}
|
||
}
|
||
|
||
a.download = filename
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
// 延迟清理 blob URL,确保下载已经开始
|
||
setTimeout(() => {
|
||
a.remove()
|
||
window.URL.revokeObjectURL(url)
|
||
}, 100)
|
||
|
||
showToast(`成功下载 ${selectedSkuIds.size} 个SKU`, { variant: 'success' })
|
||
setSelectedSkuIds(new Set()) // 清空选择
|
||
} catch (error) {
|
||
console.error('批量下载 SKU 失败:', error)
|
||
const { message } = parseDownloadError(error)
|
||
showToast(message, { variant: 'error' })
|
||
} finally {
|
||
setDownloading(false)
|
||
setDownloadProgress(0)
|
||
}
|
||
}
|
||
|
||
// 下载整个素材(非标准模型类型,或标准模型类型但未选中SKU时)
|
||
const handleDownload = () => {
|
||
if (!material) return
|
||
|
||
// 如果是标准模型类型且有选中的SKU,则批量下载SKU
|
||
if (material.materialType === '标准模型' && selectedSkuIds.size > 0) {
|
||
handleBatchDownloadSkus()
|
||
return
|
||
}
|
||
|
||
// 否则下载整个素材
|
||
setDownloading(true)
|
||
setDownloadProgress(0)
|
||
|
||
const xhr = new XMLHttpRequest()
|
||
|
||
xhr.addEventListener('progress', (e) => {
|
||
if (e.lengthComputable) {
|
||
const percentComplete = Math.round((e.loaded / e.total) * 100)
|
||
setDownloadProgress(percentComplete)
|
||
}
|
||
})
|
||
|
||
xhr.addEventListener('load', () => {
|
||
if (xhr.status === 200) {
|
||
try {
|
||
const blob = new Blob([xhr.response], { type: 'application/zip' })
|
||
|
||
const contentDisposition = xhr.getResponseHeader('Content-Disposition')
|
||
let filename = 'material.zip'
|
||
if (contentDisposition) {
|
||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||
if (filenameMatch && filenameMatch[1]) {
|
||
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
|
||
}
|
||
}
|
||
|
||
const url = window.URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = filename
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
// 延迟清理 blob URL,确保下载已经开始
|
||
setTimeout(() => {
|
||
a.remove()
|
||
window.URL.revokeObjectURL(url)
|
||
}, 100)
|
||
} catch (error) {
|
||
console.error('下载处理失败:', error)
|
||
const { message } = parseDownloadError(error)
|
||
showToast(message, { variant: 'error' })
|
||
}
|
||
} else {
|
||
try {
|
||
const errorText = xhr.responseText
|
||
const errorData = errorText ? JSON.parse(errorText) : { error: '下载失败' }
|
||
const { message } = parseDownloadError(errorData)
|
||
showToast(message, { variant: 'error' })
|
||
} catch {
|
||
const { message } = parseDownloadError({ code: 'UNKNOWN_ERROR' })
|
||
showToast(message, { variant: 'error' })
|
||
}
|
||
}
|
||
setDownloading(false)
|
||
setDownloadProgress(0)
|
||
})
|
||
|
||
xhr.addEventListener('error', () => {
|
||
const { message } = parseDownloadError({ code: 'NETWORK_ERROR' })
|
||
showToast(message, { variant: 'error' })
|
||
setDownloading(false)
|
||
setDownloadProgress(0)
|
||
})
|
||
|
||
xhr.open('GET', `/api/materials/download?id=${material.id}`)
|
||
xhr.responseType = 'arraybuffer'
|
||
xhr.send()
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||
<main className="container mx-auto px-4 py-8">
|
||
<div className="text-center py-16">
|
||
<p className="text-gray-500 dark:text-gray-400 text-lg">加载中...</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!material) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||
<Header session={session} />
|
||
<main className="container mx-auto px-4 py-8">
|
||
<div className="text-center py-16">
|
||
<p className="text-gray-500 dark:text-gray-400 text-lg">素材不存在</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||
<Header session={session} />
|
||
<main className="container mx-auto px-4 py-8">
|
||
{/* 产品全案类型:使用专用详情组件 */}
|
||
{material.materialType === '产品全案' ? (
|
||
<div className="max-w-7xl mx-auto">
|
||
<ProductPackageDetail
|
||
material={{
|
||
id: material.id,
|
||
taskNo: material.taskNo || '',
|
||
productModel: material.productModel,
|
||
categoryName: material.categoryName,
|
||
sceneName: material.sceneName,
|
||
taskStatus: material.taskStatus || 'pending',
|
||
taskFetchedAt: material.taskFetchedAt,
|
||
assets: material.assets || [],
|
||
}}
|
||
canDownloadSource={material.canDownloadSource ?? false} // 产品全案PSD下载权限参考"详情模板"类型的 canDownloadSource 权限
|
||
showToast={showToast}
|
||
onDownload={async (type, assetId) => {
|
||
// 处理下载
|
||
let downloadUrl = `/api/materials/download?id=${material.id}&type=${type}`;
|
||
if (assetId) {
|
||
downloadUrl += `&assetId=${assetId}`;
|
||
}
|
||
// 使用 fetch 下载,这样可以监听下载完成并重置状态
|
||
try {
|
||
const response = await fetch(downloadUrl);
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ error: '下载失败' }));
|
||
const { message } = parseDownloadError(errorData);
|
||
throw new Error(message);
|
||
}
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
|
||
// 根据类型和assetId生成文件名
|
||
if (assetId && type === 'psd') {
|
||
// 单个PSD文件下载,从响应头获取文件名
|
||
const contentDisposition = response.headers.get('Content-Disposition');
|
||
if (contentDisposition) {
|
||
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||
if (fileNameMatch && fileNameMatch[1]) {
|
||
const fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
|
||
a.download = fileName;
|
||
} else {
|
||
a.download = 'source.psd';
|
||
}
|
||
} else {
|
||
a.download = 'source.psd';
|
||
}
|
||
} else {
|
||
// 批量下载,使用ZIP格式
|
||
const typeNames: Record<string, string> = {
|
||
all: '全部',
|
||
main: '主图',
|
||
sku: 'SKU图',
|
||
detail: '详情图',
|
||
psd: 'PSD源文件',
|
||
};
|
||
a.download = `${material.productModel}_${typeNames[type] || type}.zip`;
|
||
}
|
||
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
// 延迟清理 blob URL,确保下载已经开始
|
||
setTimeout(() => {
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
}, 100);
|
||
// 下载成功,通知组件重置状态
|
||
return Promise.resolve();
|
||
} catch (error) {
|
||
console.error('下载失败:', error);
|
||
const { message } = parseDownloadError(error);
|
||
showToast(message, { variant: 'error' });
|
||
return Promise.reject(error);
|
||
}
|
||
}}
|
||
onBack={() => router.back()}
|
||
/>
|
||
</div>
|
||
) : (
|
||
/* 其他素材类型:使用原有左右布局 */
|
||
<>
|
||
{/* 所有素材类型统一使用左右布局 */}
|
||
<div className="flex gap-8 max-w-7xl mx-auto">
|
||
{/* 左侧:预览图(固定790px宽度) */}
|
||
<div className="flex-shrink-0" style={{ width: '790px' }}>
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
|
||
{(() => {
|
||
// 判断是否为视频类型
|
||
const isVideoType = material.materialType === '主图视频' || material.materialType === '安装视频' || material.materialType === '种草视频'
|
||
const videoUrl = material.videoPreviewClipUrl || material.videoPreview540Url || material.videoSourceUrl
|
||
const posterUrl = material.videoCoverUrl || material.previewUrl
|
||
|
||
// 检查是否有 displayImages(多张预览图)
|
||
const hasDisplayImages = material.displayImages && material.displayImages.length > 0
|
||
|
||
// 构建所有预览图数组:previewUrl(第一张)+ displayImages(其他张)
|
||
// 注意:displayImages 不包含第一张预览图,所以需要合并
|
||
const allPreviewImages = material.previewUrl
|
||
? (hasDisplayImages ? [material.previewUrl, ...material.displayImages] : [material.previewUrl])
|
||
: (hasDisplayImages ? material.displayImages : [])
|
||
|
||
// 品牌VI灵感:优先显示展示图,如果没有展示图则显示PDF预览
|
||
if (material.materialType === '品牌VI灵感') {
|
||
if (hasDisplayImages && allPreviewImages.length > 0) {
|
||
/* 有展示图:显示多张展示图 */
|
||
return (
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
|
||
{allPreviewImages.map((imageUrl, index) => (
|
||
<div key={index} className="relative" style={{ maxWidth: '790px', width: '100%' }}>
|
||
<Image
|
||
src={imageUrl}
|
||
alt={`${material.productModel} - 展示图 ${index + 1}`}
|
||
width={790}
|
||
height={1500}
|
||
unoptimized
|
||
className="w-full h-auto"
|
||
style={{ display: 'block', maxWidth: '790px' }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
} else {
|
||
/* 没有展示图:显示PDF预览 */
|
||
// 构建PDF URL
|
||
let pdfUrl = ''
|
||
if (material.zipFileName) {
|
||
if (material.zipFileName.startsWith('http://') || material.zipFileName.startsWith('https://')) {
|
||
pdfUrl = material.zipFileName
|
||
} else {
|
||
pdfUrl = `/uploads/${material.zipFileName}`
|
||
}
|
||
}
|
||
|
||
if (pdfUrl) {
|
||
return (
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700" style={{ minHeight: '800px' }}>
|
||
<iframe
|
||
src={`${pdfUrl}#toolbar=0&navpanes=0&scrollbar=1`}
|
||
className="w-full"
|
||
style={{ height: '800px', border: 'none' }}
|
||
title={`${material.productModel} - PDF预览`}
|
||
/>
|
||
</div>
|
||
)
|
||
} else {
|
||
return (
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center" style={{ minHeight: '400px' }}>
|
||
<p className="text-gray-500 dark:text-gray-400">PDF文件不存在</p>
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果有 displayImages 或有多张预览图,显示多张预览图(适用于所有类型,除了视频和音乐)
|
||
if (allPreviewImages.length > 1 && !isVideoType && material.materialType !== '音乐') {
|
||
/* 显示多张预览图 */
|
||
return (
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
|
||
{allPreviewImages.map((imageUrl, index) => (
|
||
<div key={index} className="relative" style={{ maxWidth: '790px', width: '100%' }}>
|
||
<Image
|
||
src={imageUrl}
|
||
alt={`${material.productModel} - 预览图 ${index + 1}`}
|
||
width={790}
|
||
height={1500}
|
||
unoptimized
|
||
className="w-full h-auto"
|
||
style={{ display: 'block', maxWidth: '790px' }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
} else if (isVideoType && videoUrl) {
|
||
/* 视频类型:显示视频预览(可点击播放完整视频) */
|
||
return (
|
||
<div
|
||
className="relative w-full bg-gray-200 dark:bg-gray-700 cursor-pointer group"
|
||
onClick={() => {
|
||
// 优先使用720p预览,其次使用10秒片段,最后使用源视频
|
||
const playUrl = material.videoPreview540Url || material.videoPreviewClipUrl || material.videoSourceUrl
|
||
if (playUrl) {
|
||
setVideoSrc(playUrl)
|
||
setShowVideoModal(true)
|
||
}
|
||
}}
|
||
>
|
||
<video
|
||
src={material.videoPreviewClipUrl || material.videoPreview540Url || undefined}
|
||
poster={posterUrl || undefined}
|
||
className="w-full h-auto object-contain"
|
||
muted
|
||
loop
|
||
playsInline
|
||
preload="auto"
|
||
/>
|
||
{/* 播放按钮覆盖层 */}
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-all">
|
||
<div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
{/* 视频时长角标 */}
|
||
{material.videoDuration && (
|
||
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
|
||
{Math.floor(material.videoDuration / 60)}:{(material.videoDuration % 60).toString().padStart(2, '0')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
} else if (material.materialType === '音乐') {
|
||
/* 音频类型:显示音频播放器和可视化 */
|
||
return (
|
||
<div className="relative w-full bg-gradient-to-b from-slate-800 to-slate-900 flex flex-col items-center justify-center" style={{ minHeight: '500px' }}>
|
||
{/* 圆形频谱可视化背景 - 只在播放时显示 */}
|
||
{playingAudio && (
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<AudioVisualizer
|
||
audioElement={audioRef.current}
|
||
isPlaying={true}
|
||
size="lg"
|
||
coverImage={material.previewUrl || null}
|
||
showCoverOnPause={true}
|
||
/>
|
||
</div>
|
||
)}
|
||
{/* 播放进度条(底部) - 可点击和拖动 */}
|
||
{playingAudio && (
|
||
<div
|
||
className="absolute bottom-0 left-0 right-0 h-2 bg-slate-700 z-20 cursor-pointer hover:h-3 transition-all group"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (audioRef.current) {
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const percent = (e.clientX - rect.left) / rect.width
|
||
audioRef.current.currentTime = audioRef.current.duration * percent
|
||
}
|
||
}}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation()
|
||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||
if (audioRef.current) {
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const percent = Math.max(0, Math.min(1, (moveEvent.clientX - rect.left) / rect.width))
|
||
audioRef.current.currentTime = audioRef.current.duration * percent
|
||
}
|
||
}
|
||
const handleMouseUp = () => {
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
}
|
||
document.addEventListener('mousemove', handleMouseMove)
|
||
document.addEventListener('mouseup', handleMouseUp)
|
||
}}
|
||
>
|
||
<div
|
||
className="h-full bg-blue-500 transition-all duration-100 relative"
|
||
style={{ width: `${audioProgress}%` }}
|
||
>
|
||
{/* 拖动手柄 */}
|
||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* 信息覆盖层 */}
|
||
<div className="relative z-10 flex flex-col items-center justify-center p-8">
|
||
{/* 封面图(如果有,且未播放时显示) */}
|
||
{material.previewUrl && !playingAudio && (
|
||
<div className="mb-6">
|
||
<Image
|
||
src={material.previewUrl}
|
||
alt={material.productModel}
|
||
width={400}
|
||
height={400}
|
||
unoptimized
|
||
className="rounded-2xl shadow-xl"
|
||
/>
|
||
</div>
|
||
)}
|
||
{/* 播放/暂停按钮 */}
|
||
<button
|
||
type="button"
|
||
onClick={handleAudioPlay}
|
||
className={`w-20 h-20 rounded-full backdrop-blur-sm flex items-center justify-center border transition-all ${
|
||
playingAudio
|
||
? "bg-white/30 border-white/50 hover:bg-white/40"
|
||
: "bg-white/20 border-white/30 hover:scale-110 hover:bg-white/30"
|
||
}`}
|
||
>
|
||
{playingAudio ? (
|
||
<svg className="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||
</svg>
|
||
) : (
|
||
<svg className="w-10 h-10 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
{/* 音频名称 */}
|
||
<p className="text-white text-xl font-medium text-center px-4 mt-6 drop-shadow-lg">{material.productModel}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
} else {
|
||
/* 其他类型:显示单张预览图(如果没有多张预览图) */
|
||
// 如果没有 previewUrl,不显示任何内容
|
||
if (!material.previewUrl) {
|
||
return (
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center" style={{ minHeight: '400px' }}>
|
||
<p className="text-gray-500 dark:text-gray-400">暂无预览图</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||
<Image
|
||
src={material.previewUrl}
|
||
alt={material.productModel}
|
||
width={790}
|
||
height={1000}
|
||
unoptimized
|
||
className="w-full h-auto object-contain"
|
||
style={{ display: 'block', maxWidth: '790px' }}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:素材信息(固定) */}
|
||
<div className={`${material.materialType === '标准模型' ? 'w-[500px]' : 'w-80'} flex-shrink-0`}>
|
||
<div className="sticky top-24 bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 space-y-6">
|
||
{/* 标题和标签 - 统一间距 */}
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||
{material.productModel}
|
||
</h1>
|
||
<div className="flex flex-wrap gap-2">
|
||
<span className="inline-flex items-center px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-sm font-medium">
|
||
{material.categoryName}
|
||
</span>
|
||
<span className="inline-flex items-center px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-sm font-medium">
|
||
{material.sceneName}
|
||
</span>
|
||
{material.materialType && (
|
||
<span className="inline-flex items-center px-3 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-full text-sm font-medium">
|
||
{material.materialType}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 标准模型类型:显示SKU列表 */}
|
||
{material.materialType === '标准模型' ? (
|
||
<SkuList
|
||
materialId={materialId}
|
||
canEdit={!!(session?.user && ((session.user as any)?.id === material.userId || (session.user as any)?.role === 'ADMIN'))}
|
||
onEditClick={() => setShowSkuEditModal(true)}
|
||
onSelectionChange={setSelectedSkuIds}
|
||
/>
|
||
) : (
|
||
/* 其他类型:显示素材信息 */
|
||
<div className="space-y-4">
|
||
<div>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">作者</p>
|
||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.authorName}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件大小</p>
|
||
<p className="text-base font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">下载次数</p>
|
||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.downloadCount}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">点赞数</p>
|
||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.likeCount}</p>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件名</p>
|
||
<p className="text-sm text-gray-700 dark:text-gray-300 break-all">{material.zipFileName}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">上传时间</p>
|
||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 操作按钮 - 统一样式 */}
|
||
<div className="space-y-3 pt-2">
|
||
<button
|
||
onClick={handleDownload}
|
||
disabled={downloading}
|
||
className="w-full btn-primary py-3 text-base font-medium relative overflow-hidden disabled:opacity-75"
|
||
>
|
||
{downloading ? (
|
||
<>
|
||
<div
|
||
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
|
||
style={{ width: `${downloadProgress}%` }}
|
||
/>
|
||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
下载中 {downloadProgress}%
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||
</svg>
|
||
{material.materialType === '标准模型' && selectedSkuIds.size > 0
|
||
? `立即下载 (${selectedSkuIds.size}个)`
|
||
: '立即下载'}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
|
||
<button
|
||
onClick={() => router.back()}
|
||
className="w-full py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl text-base font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||
>
|
||
返回上一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</main>
|
||
|
||
{/* 返回顶部按钮 */}
|
||
{showBackToTop && (
|
||
<button
|
||
onClick={scrollToTop}
|
||
className="fixed bottom-8 right-8 w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all z-50"
|
||
title="返回顶部"
|
||
>
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* Video Player Modal */}
|
||
{showVideoModal && videoSrc && (
|
||
<VideoPlayerModal
|
||
videoSrc={videoSrc}
|
||
onClose={() => {
|
||
setShowVideoModal(false)
|
||
setVideoSrc(null)
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* SKU编辑模态框 */}
|
||
{showSkuEditModal && material && (
|
||
<SkuEditModal
|
||
materialId={materialId}
|
||
isOpen={showSkuEditModal}
|
||
onClose={() => {
|
||
setShowSkuEditModal(false)
|
||
}}
|
||
onSaved={() => {
|
||
setShowSkuEditModal(false)
|
||
// 重新获取SKU列表
|
||
if (material.materialType === '标准模型') {
|
||
fetch(`/api/materials/${materialId}/skus`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
setMaterial(prev => prev ? { ...prev, skus: data.skus } : null)
|
||
})
|
||
.catch(console.error)
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 统一的Toast提示 */}
|
||
{toast && toast.show && (
|
||
<ToastBubble message={toast.message} variant={toast.variant} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|