465 lines
20 KiB
TypeScript
465 lines
20 KiB
TypeScript
/**
|
||
* SharePage 组件
|
||
*
|
||
* 素材分享页面
|
||
* 显示素材详情和预览图
|
||
* 支持PSD/模特类型的多图展示
|
||
* 使用三层架构样式系统,支持深浅色主题
|
||
*
|
||
* @created 2025-01-XX
|
||
* @component
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useEffect, useState, 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 { useDialog } from '@/components/ui/DialogProvider'
|
||
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
|
||
|
||
interface Material {
|
||
id: string
|
||
previewUrl: string
|
||
productModel: string
|
||
brandName?: string | null
|
||
seriesName?: string | null
|
||
skuCode?: string | null
|
||
materialTexture?: string | null
|
||
categoryName: string
|
||
sceneName: string
|
||
materialType: string
|
||
renderer?: string | null
|
||
renderTime?: string | null
|
||
imageResolution?: string | null
|
||
colorMode?: string | null
|
||
authorName: string
|
||
zipFileName: string
|
||
zipFileSize: string | number
|
||
videoCoverUrl?: string | null
|
||
videoPreview540Url?: string | null
|
||
videoPreviewClipUrl?: string | null
|
||
audioUrl?: string | null
|
||
downloadCount: number
|
||
likeCount: number
|
||
displayImages: string[] // PSD展示图片URLs
|
||
createdAt: string
|
||
}
|
||
|
||
export default function SharePage() {
|
||
const styles = useThemeStyles()
|
||
const theme = useTheme()
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const { alert } = useDialog()
|
||
const params = useParams()
|
||
const materialId = params.id as string
|
||
const [shareToken, setShareToken] = useState('')
|
||
|
||
const [material, setMaterial] = useState<Material | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [showBackToTop, setShowBackToTop] = useState(false)
|
||
|
||
const fetchMaterial = useCallback(async () => {
|
||
try {
|
||
const tokenParam = new URLSearchParams(window.location.search).get('token') || ''
|
||
setShareToken(tokenParam)
|
||
const response = await fetch(
|
||
`/api/materials/share?id=${encodeURIComponent(materialId)}&token=${encodeURIComponent(tokenParam)}`
|
||
)
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setMaterial(data)
|
||
// 记录浏览历史(仅登录用户)
|
||
if (session?.user) {
|
||
fetch('/api/user/history', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ materialId }),
|
||
}).catch((err) => console.warn('记录浏览历史失败:', err))
|
||
}
|
||
} else {
|
||
alert({
|
||
title: '错误',
|
||
message: '素材不存在或已被删除',
|
||
variant: 'error',
|
||
})
|
||
router.push('/')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching material:', error)
|
||
alert({
|
||
title: '加载失败',
|
||
message: '加载失败',
|
||
variant: 'error',
|
||
})
|
||
router.push('/')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [materialId, router, session, alert])
|
||
|
||
useEffect(() => {
|
||
if (status !== 'loading') {
|
||
fetchMaterial()
|
||
}
|
||
}, [status, fetchMaterial])
|
||
|
||
useEffect(() => {
|
||
// 监听滚动,控制返回顶部按钮显示
|
||
const handleScroll = () => {
|
||
setShowBackToTop(window.scrollY > 400)
|
||
}
|
||
window.addEventListener('scroll', handleScroll)
|
||
return () => window.removeEventListener('scroll', handleScroll)
|
||
}, [])
|
||
|
||
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 renderAdditionalInfo = (variant: 'compact' | 'full' = 'compact') => {
|
||
if (!material) return null
|
||
const entries = [
|
||
{ label: '品牌', value: material.brandName },
|
||
{ label: '系列', value: material.seriesName },
|
||
{ label: 'SKU', value: material.skuCode },
|
||
{ label: '材质 / 工艺', value: material.materialTexture },
|
||
{ label: '渲染耗时', value: material.renderTime },
|
||
{ label: '输出分辨率', value: material.imageResolution },
|
||
{ label: '颜色模式', value: material.colorMode },
|
||
].filter(entry => entry.value)
|
||
|
||
if (entries.length === 0) return null
|
||
|
||
const labelClass = variant === 'compact'
|
||
? 'text-xs text-slate-500 dark:text-slate-400 mb-1'
|
||
: 'text-sm text-slate-500 dark:text-slate-400 mb-1'
|
||
const valueClass = variant === 'compact'
|
||
? 'text-sm font-medium text-slate-900 dark:text-white break-all'
|
||
: 'text-base font-medium text-slate-900 dark:text-white break-all'
|
||
|
||
return (
|
||
<div className={cn(
|
||
'grid grid-cols-1',
|
||
variant === 'compact'
|
||
? 'gap-3 mt-4 pt-4 border-t border-slate-100 dark:border-slate-700'
|
||
: 'gap-4 mt-6 pt-6 border-t border-slate-200 dark:border-slate-700'
|
||
)}>
|
||
{entries.map((entry, index) => (
|
||
<div key={`${entry.label}-${index}`}>
|
||
<p className={labelClass}>{entry.label}</p>
|
||
<p className={valueClass}>{entry.value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||
<main className="container mx-auto px-4 py-8">
|
||
<div className="text-center py-16">
|
||
<p className="text-slate-500 dark:text-slate-400 text-lg">加载中...</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!material) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||
<Header session={session} />
|
||
<main className="container mx-auto px-4 py-8">
|
||
<div className="text-center py-16">
|
||
<p className="text-slate-500 dark:text-slate-400 text-lg">素材不存在</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||
<Header session={session} />
|
||
<main className="container mx-auto px-4 py-8">
|
||
{(material.materialType === 'PSD' || material.materialType === '模特') && material.displayImages && material.displayImages.length > 0 ? (
|
||
/* PSD/模特类型:左右布局 */
|
||
<div className="flex gap-8 max-w-7xl mx-auto">
|
||
{/* 左侧:展示图片(固定790px宽度) */}
|
||
<div className="flex-shrink-0" style={{ width: '790px' }}>
|
||
<div className={cn('rounded-lg shadow-sm overflow-hidden', styles.cardStyles.base)}>
|
||
<div className="w-full bg-slate-200 dark:bg-slate-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>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:素材信息(固定) */}
|
||
<div className="w-80 flex-shrink-0">
|
||
<div className={cn('sticky top-24 rounded-lg shadow-sm p-6 space-y-6', styles.cardStyles.base)}>
|
||
<div>
|
||
<h1 className={cn('text-2xl font-bold mb-3', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
|
||
{material.productModel}
|
||
</h1>
|
||
<div className="flex flex-wrap gap-2">
|
||
<span className="inline-flex items-center px-2.5 py-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-md text-xs font-medium">
|
||
{material.categoryName}
|
||
</span>
|
||
<span className="inline-flex items-center px-2.5 py-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-md text-xs font-medium">
|
||
{material.sceneName}
|
||
</span>
|
||
<span className="inline-flex items-center px-2.5 py-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-md text-xs font-medium">
|
||
{material.materialType}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>作者</p>
|
||
<p className={cn('text-sm font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.authorName}</p>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件大小</p>
|
||
<p className={cn('text-sm font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{formatFileSize(material.zipFileSize)}</p>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>下载</p>
|
||
<p className={cn('text-sm font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.downloadCount}</p>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>点赞</p>
|
||
<p className={cn('text-sm font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.likeCount}</p>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件名</p>
|
||
<p className={cn('text-xs break-all', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>{material.zipFileName}</p>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-xs mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>上传时间</p>
|
||
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>
|
||
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
|
||
</p>
|
||
</div>
|
||
{renderAdditionalInfo('compact')}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => router.back()}
|
||
className={cn(
|
||
'w-full py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||
styles.buttonStyles.secondary
|
||
)}
|
||
>
|
||
返回上一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : material.materialType === '视频' ? (
|
||
/* 视频类型:视频播放布局 */
|
||
<div className="max-w-6xl mx-auto">
|
||
<div className={cn('rounded-lg shadow-sm overflow-hidden', styles.cardStyles.base)}>
|
||
{/* 视频播放器 */}
|
||
<div className="relative w-full bg-black">
|
||
<video
|
||
controls
|
||
className="w-full h-auto max-h-[800px]"
|
||
poster={material.videoCoverUrl || material.previewUrl}
|
||
preload="metadata"
|
||
>
|
||
<source
|
||
src={`/api/materials/stream?id=${material.id}&shareToken=${encodeURIComponent(shareToken)}`}
|
||
type="video/mp4"
|
||
/>
|
||
您的浏览器不支持视频播放。
|
||
</video>
|
||
</div>
|
||
|
||
{/* Material Info */}
|
||
<div className="p-8">
|
||
<div className="mb-6">
|
||
<h1 className={cn('text-3xl font-bold mb-2', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
|
||
{material.productModel}
|
||
</h1>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<span className="inline-flex items-center px-2.5 py-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-md text-sm font-medium">
|
||
{material.categoryName}
|
||
</span>
|
||
<span className="inline-flex items-center px-2.5 py-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-md text-sm font-medium">
|
||
{material.sceneName}
|
||
</span>
|
||
<span className="inline-flex items-center px-2.5 py-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-md text-sm font-medium">
|
||
视频
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||
<div>
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>作者</p>
|
||
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.authorName}</p>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件大小</p>
|
||
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{formatFileSize(material.zipFileSize)}</p>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>下载次数</p>
|
||
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.downloadCount}</p>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>点赞数</p>
|
||
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.likeCount}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-6">
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件名</p>
|
||
<p className={cn('text-sm break-all', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>{material.zipFileName}</p>
|
||
</div>
|
||
|
||
<div className="mb-6">
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>上传时间</p>
|
||
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>
|
||
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => router.push('/')}
|
||
className={cn(
|
||
'w-full mt-4 py-3 rounded-lg font-medium transition-colors',
|
||
styles.buttonStyles.secondary
|
||
)}
|
||
>
|
||
返回首页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* 非PSD/非视频类型:统一布局 */
|
||
<div className="max-w-4xl mx-auto">
|
||
<div className={cn('rounded-lg shadow-sm overflow-hidden', styles.cardStyles.base)}>
|
||
{/* 普通预览图 */}
|
||
<div className={cn('relative w-full h-96', theme === 'dark' ? 'bg-slate-800' : 'bg-slate-200')}>
|
||
<Image
|
||
src={material.previewUrl}
|
||
alt={material.productModel}
|
||
fill
|
||
unoptimized
|
||
className="object-contain"
|
||
sizes="(max-width: 1024px) 100vw, 1024px"
|
||
/>
|
||
</div>
|
||
|
||
{/* Material Info */}
|
||
<div className="p-8">
|
||
<div className="mb-6">
|
||
<h1 className={cn('text-3xl font-bold mb-2', theme === 'dark' ? 'text-white' : 'text-slate-900')}>
|
||
{material.productModel}
|
||
</h1>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<span className="inline-flex items-center px-2.5 py-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-md text-sm font-medium">
|
||
{material.categoryName}
|
||
</span>
|
||
<span className="inline-flex items-center px-2.5 py-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-md text-sm font-medium">
|
||
{material.sceneName}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||
<div>
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>作者</p>
|
||
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.authorName}</p>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件大小</p>
|
||
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{formatFileSize(material.zipFileSize)}</p>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>下载次数</p>
|
||
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.downloadCount}</p>
|
||
</div>
|
||
<div>
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>点赞数</p>
|
||
<p className={cn('text-lg font-medium', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{material.likeCount}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-6">
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>文件名</p>
|
||
<p className={cn('text-sm break-all', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>{material.zipFileName}</p>
|
||
</div>
|
||
|
||
<div className="mb-6">
|
||
<p className={cn('text-sm mb-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>上传时间</p>
|
||
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-300' : 'text-slate-700')}>
|
||
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
|
||
</p>
|
||
</div>
|
||
|
||
{renderAdditionalInfo('full')}
|
||
|
||
<button
|
||
onClick={() => router.push('/')}
|
||
className={cn(
|
||
'w-full mt-4 py-3 rounded-lg font-medium transition-colors',
|
||
styles.buttonStyles.secondary
|
||
)}
|
||
>
|
||
返回首页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{/* 返回顶部按钮 */}
|
||
{showBackToTop && (
|
||
<button
|
||
onClick={scrollToTop}
|
||
className="fixed bottom-8 right-8 w-12 h-12 bg-slate-800 hover:bg-slate-700 text-white rounded-full shadow-sm flex items-center justify-center transition-colors z-50 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white"
|
||
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>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|