'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(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(null) const [showSkuEditModal, setShowSkuEditModal] = useState(false) const { toast, showToast } = useToast() // 统一的Toast提示 // 音频播放状态 const audioRef = useRef(null) const [playingAudio, setPlayingAudio] = useState(false) const [audioProgress, setAudioProgress] = useState(0) // 0-100 // SKU选中状态(用于标准模型类型的批量下载) const [selectedSkuIds, setSelectedSkuIds] = useState>(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 (

加载中...

) } if (!material) { return (

素材不存在

) } return (
{/* 产品全案类型:使用专用详情组件 */} {material.materialType === '产品全案' ? (
{ // 处理下载 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 = { 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()} />
) : ( /* 其他素材类型:使用原有左右布局 */ <> {/* 所有素材类型统一使用左右布局 */}
{/* 左侧:预览图(固定790px宽度) */}
{(() => { // 判断是否为视频类型 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 (
{allPreviewImages.map((imageUrl, index) => (
{`${material.productModel}
))}
) } 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 (