684 lines
28 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 { 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'
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 // 是否已收藏
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
useEffect(() => {
// 分享页面允许未登录访问
if (status !== 'loading') {
fetchMaterial()
}
}, [status, materialId])
useEffect(() => {
// 监听滚动,控制返回顶部按钮显示
const handleScroll = () => {
setShowBackToTop(window.scrollY > 400)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const fetchMaterial = async () => {
try {
const response = await fetch(`/api/materials/share?id=${materialId}`)
if (response.ok) {
const data = await response.json()
setMaterial(data)
// 如果是标准模型类型,获取SKU列表
if (data.materialType === '标准模型') {
const skuResponse = await fetch(`/api/materials/${materialId}/skus`)
if (skuResponse.ok) {
const skuData = await skuResponse.json()
setMaterial(prev => prev ? { ...prev, skus: skuData.skus } : null)
}
}
} else {
showToast('素材不存在或已被删除', { variant: 'error' })
router.push('/')
}
} catch (error) {
console.error('Error fetching material:', error)
showToast('加载失败', { 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
}
// 创建新的音频元素
// 如果zipFileName是HTTP URL(老素材),直接使用;否则加上/uploads/前缀(新素材)
let audioUrl = ''
if (material.zipFileName) {
const zipFileName = material.zipFileName
if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) {
// 老素材:zipFileName是完整的S3 URL
audioUrl = zipFileName
} else {
// 新素材:zipFileName只是文件名,需要加上/uploads/前缀
audioUrl = `/uploads/${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
}
}
}, [])
const handleDownload = () => {
if (!material) 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()
a.remove()
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('下载处理失败:', error)
showToast('下载失败,请稍后重试', { variant: 'error' })
}
} else {
showToast('下载失败', { variant: 'error' })
}
setDownloading(false)
setDownloadProgress(0)
})
xhr.addEventListener('error', () => {
showToast('下载失败,请稍后重试', { 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">
{/* 所有素材类型统一使用左右布局 */}
<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
if ((material.materialType === '详情页' || material.materialType === '模特') && material.displayImages && material.displayImages.length > 0) {
/* 详情页/模特类型:显示多张展示图 */
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
{material.displayImages.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="metadata"
/>
{/* 播放按钮覆盖层 */}
<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 {
/* 其他类型:显示单张预览图(自适应高度) */
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>
{/* 标准模型类型:显示SKU列表 */}
{material.materialType === '标准模型' && (
<div className="mt-8">
<SkuList
materialId={materialId}
canEdit={!!(session?.user && ((session.user as any)?.id === material.userId || (session.user as any)?.role === 'ADMIN'))}
onEditClick={() => setShowSkuEditModal(true)}
/>
</div>
)}
</div>
{/* 右侧:素材信息(固定) */}
<div className="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>
{/* 素材信息 - 统一字体和间距 */}
<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>
立即下载
</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>
)
}