flnxi-Wu/app/share/[id]/page.tsx
2026-05-28 18:02:59 +08:00

465 lines
20 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.

/**
* 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>
)
}