684 lines
28 KiB
TypeScript
684 lines
28 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'
|
||
|
||
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>
|
||
)
|
||
}
|
||
|