1228 lines
54 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'
import ProductPackageDetail from '@/components/detail/ProductPackageDetail'
import TaskPageDetail from '@/components/detail/TaskPageDetail'
import ThreeDOriginalDetail from '@/components/detail/ThreeDOriginalDetail'
import DocumentAssetViewer, { type ViewerAssetItem } from '@/components/DocumentAssetViewer'
import { parseDownloadError } from '@/lib/download-errors'
import { validateAndTriggerDownload } from '@/lib/client-download'
import { parseApiError, parseFetchError } from '@/lib/error-handler'
import { getCertificateReportDisplayTitle, isCertificateReportMaterialType } from '@/lib/certificate-report'
import { isVideoMaterialType } from '@/lib/video-material-types'
interface Material {
id: string
userId: string
previewUrl: string
productModel: string
reportTitle?: string | null
categoryName: string
sceneName: string
platformName?: string // 平台分类
productCategoryName?: string // 品类分类
materialType: string
taskDetailSourceType?: string | null
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 // 产品全案任务号
dingtalkApprovalNo?: string | null // 钉钉审批编号
taskStatus?: string // 产品全案任务状态
taskFetchedAt?: string // 产品全案被获取时间
isFullSet?: boolean
canDownloadSource?: boolean // 产品全案 PSD 下载权限(参考"详情模板"类型)
sourceFileUrl?: string | null
assets?: Array<{
id: string
assetType: string
fileUrl: string
fileName: string
fileSize: number
sortOrder: number
fileKind?: 'pdf' | 'image'
}> // 产品全案资产列表
skus?: Array<{
id: string
skuName: string
filePath: string
fileSize: string
sortOrder: number
whiteBackgroundUploaded?: boolean
whiteBackgroundComplete?: boolean
whiteBackgroundFixedCount?: number
whiteBackgroundOtherCount?: number
whiteBackgroundCount?: 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
// SKU选中状态(用于标准模型类型的批量下载)
const [selectedSkuIds, setSelectedSkuIds] = useState<Set<string>>(new Set())
const [downloadingBatchType, setDownloadingBatchType] = useState<'model' | 'white-background' | null>(null)
const [skuListRefreshKey, setSkuListRefreshKey] = useState(0)
const materialDisplayTitle = material
? getCertificateReportDisplayTitle({
productModel: material.productModel,
reportTitle: material.reportTitle,
})
: ''
const canManageStandardModelSku = Boolean(
session?.user && material,
)
const reportViewerItems: ViewerAssetItem[] = material && isCertificateReportMaterialType(material.materialType)
? (material.assets || [])
.filter((asset) => asset.assetType === 'report-pdf' || asset.assetType === 'report-image')
.map((asset) => ({
id: asset.id,
fileName: asset.fileName || `文件-${asset.sortOrder + 1}`,
fileUrl: asset.fileUrl,
fileKind: asset.fileKind === 'pdf' ? 'pdf' : 'image',
}))
: []
const brandViViewerItems: ViewerAssetItem[] = material && material.materialType === '品牌VI灵感'
? [
...(material.sourceFileUrl
? [{
id: 'brand-vi-source-pdf',
fileName: material.zipFileName || `${materialDisplayTitle || '品牌VI灵感'}.pdf`,
fileUrl: material.sourceFileUrl,
fileKind: 'pdf' as const,
}]
: []),
...((material.displayImages || []).map((imageUrl, index) => ({
id: `brand-vi-image-${index}`,
fileName: `附图-${index + 1}.jpg`,
fileUrl: imageUrl,
fileKind: 'image' as const,
}))),
]
: []
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 (isVideoMaterialType(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 {
const error = await parseFetchError(response)
showToast(error.message, { 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 (downloadType: 'model' | 'white-background' = 'model') => {
if (!material || selectedSkuIds.size === 0) {
showToast('请先选择要下载的SKU', { variant: 'error' })
return
}
let targetSkuIds = Array.from(selectedSkuIds)
if (downloadType === 'white-background') {
const availableWhiteBgSkuIds = (material.skus || [])
.filter((sku: any) => (sku?.whiteBackgroundCount || 0) > 0 || sku?.whiteBackgroundUploaded)
.map((sku: any) => sku.id)
targetSkuIds = targetSkuIds.filter((skuId) => availableWhiteBgSkuIds.includes(skuId))
if (targetSkuIds.length === 0) {
showToast('选中的 SKU 中没有可下载的白底图', { variant: 'error' })
return
}
} else {
const availableModelSkuIds = (material.skus || [])
.filter((sku: any) => sku?.zipFilePath && String(sku.zipFilePath).trim().length > 0)
.map((sku: any) => sku.id)
targetSkuIds = targetSkuIds.filter((skuId) => availableModelSkuIds.includes(skuId))
if (targetSkuIds.length === 0) {
showToast('选中的 SKU 中没有可下载的模型文件', { variant: 'error' })
return
}
}
setDownloading(true)
setDownloadingBatchType(downloadType)
setDownloadProgress(15)
if (downloadType === 'white-background') {
showToast('正在打包白底图,请稍候', { variant: 'success' })
}
try {
const response = await fetch(`/api/materials/${materialId}/skus/batch-download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skuIds: targetSkuIds,
downloadType,
})
})
if (!response.ok) {
const error = await response.json()
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
setDownloading(false)
setDownloadingBatchType(null)
setDownloadProgress(0)
return
}
// 获取ZIP文件并下载
setDownloadProgress(70)
const blob = await response.blob()
setDownloadProgress(95)
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_${downloadType === 'white-background' ? 'white-background' : 'model'}_${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(`成功下载 ${targetSkuIds.length} 个SKU${downloadType === 'white-background' ? '白底图' : '模型'}`, { variant: 'success' })
setSelectedSkuIds(new Set()) // 清空选择
} catch (error) {
console.error('批量下载 SKU 失败:', error)
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
} finally {
setDownloading(false)
setDownloadingBatchType(null)
setDownloadProgress(0)
}
}
const handleDownloadByDirectLink = () => {
if (!material) {
return
}
if (material.materialType === '标准模型' && selectedSkuIds.size > 0) {
void handleBatchDownloadSkus('model')
return
}
const runDownload = async () => {
setDownloading(true)
setDownloadProgress(10)
try {
const downloadUrl = `/api/materials/download?id=${encodeURIComponent(material.id)}`
await validateAndTriggerDownload(downloadUrl)
setDownloadProgress(100)
} catch (error) {
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
} finally {
setTimeout(() => {
setDownloading(false)
setDownloadProgress(0)
}, 300)
}
}
void runDownload()
}
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>
)
}
// 类型映射函数:将老类型映射到新类型
const mapMaterialType = (type: string): string => {
if (type === '3DMAX') return '3D场景'
if (type === '产品全案') return '任务-详情'
if (type === '详情页') return '详情模板'
return type
}
// 获取映射后的素材类型
const mappedMaterialType = mapMaterialType(material.materialType)
const isTaskPageType = mappedMaterialType === '任务-页面'
// 检查是否为3D原图/AI原图/实拍原图/视频截图类型(带"任务-"前缀)
const is3DOriginalType = ['任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', '任务-其他'].includes(mappedMaterialType)
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">
{/* 任务-详情类型:使用专用详情组件 */}
{mappedMaterialType === '任务-详情' ? (
<div className="max-w-7xl mx-auto">
<ProductPackageDetail
material={{
id: material.id,
taskNo: material.taskNo || '',
dingtalkApprovalNo: material.dingtalkApprovalNo || null,
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
taskDetailSourceType: material.taskDetailSourceType || null,
isFullSet: material.isFullSet,
taskStatus: material.taskStatus || 'pending',
taskFetchedAt: material.taskFetchedAt,
assets: material.assets || [],
}}
canDownloadSource={material.canDownloadSource ?? false} // 产品全案PSD下载权限参考"详情模板"类型的 canDownloadSource 权限
showToast={showToast}
onDownload={async (type, assetId) => {
try {
let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}`
if (assetId) {
directDownloadUrl += `&assetId=${assetId}`
}
await validateAndTriggerDownload(directDownloadUrl)
return Promise.resolve()
} catch (error) {
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
return Promise.reject(error)
}
}}
onBack={() => router.back()}
/>
</div>
) : isTaskPageType ? (
<div className="max-w-7xl mx-auto">
<TaskPageDetail
material={{
id: material.id,
taskNo: material.taskNo || '',
dingtalkApprovalNo: material.dingtalkApprovalNo || null,
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
taskStatus: material.taskStatus || 'pending',
taskFetchedAt: material.taskFetchedAt,
assets: material.assets || [],
}}
canDownloadSource={material.canDownloadSource ?? false}
showToast={showToast}
onDownload={async (type, assetId) => {
try {
let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}`
if (assetId) {
directDownloadUrl += `&assetId=${assetId}`
}
await validateAndTriggerDownload(directDownloadUrl)
return Promise.resolve()
} catch (error) {
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
return Promise.reject(error)
}
}}
onBack={() => router.back()}
/>
</div>
) : is3DOriginalType ? (
/* 3D原图/AI原图/实拍原图/视频截图类型:使用专用详情组件 */
<div className="max-w-7xl mx-auto">
<ThreeDOriginalDetail
material={{
id: material.id,
taskNo: material.taskNo || '',
dingtalkApprovalNo: material.dingtalkApprovalNo || null,
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
materialType: mappedMaterialType,
taskStatus: material.taskStatus || 'pending',
taskFetchedAt: material.taskFetchedAt,
assets: material.assets || [],
}}
canDownloadSource={material.canDownloadSource ?? false}
showToast={showToast}
onDownload={async (type, assetId) => {
try {
let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}`
if (assetId) {
directDownloadUrl += `&assetId=${assetId}`
}
await validateAndTriggerDownload(directDownloadUrl)
return Promise.resolve()
} catch (error) {
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
return Promise.reject(error)
}
}}
onBack={() => router.back()}
/>
</div>
) : (
/* 其他素材类型:使用原有左右布局 */
<>
{/* 所有素材类型统一使用左右布局 */}
<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 = isVideoMaterialType(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 : [])
if (isCertificateReportMaterialType(material.materialType) && reportViewerItems.length > 0) {
return (
<DocumentAssetViewer
title={materialDisplayTitle}
items={reportViewerItems}
/>
)
}
// 品牌VI灵感:优先使用原始PDF多页预览,其次显示附图,最后才回退到旧的首图预览
if (material.materialType === '品牌VI灵感') {
if (brandViViewerItems.length > 0) {
return (
<DocumentAssetViewer
title={materialDisplayTitle}
items={brandViViewerItems}
/>
)
}
if (material.previewUrl) {
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
<div className="relative" style={{ maxWidth: '790px', width: '100%' }}>
<Image
src={material.previewUrl}
alt={`${materialDisplayTitle} - 预览图`}
width={790}
height={1500}
unoptimized
className="w-full h-auto"
style={{ display: 'block', maxWidth: '790px' }}
/>
</div>
</div>
)
}
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center" style={{ minHeight: '400px' }}>
<p className="text-gray-500 dark:text-gray-400">PDF文件不存在</p>
</div>
)
}
// 如果有 displayImages 或有多张预览图,显示多张预览图(适用于所有类型,除了视频和音乐)
if (allPreviewImages.length > 1 && !isVideoType && material.materialType !== '音乐') {
/* 显示多张预览图 */
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
{allPreviewImages.map((imageUrl, index) => (
<div key={index} className="relative" style={{ maxWidth: '790px', width: '100%' }}>
<Image
src={imageUrl}
alt={`${materialDisplayTitle} - 预览图 ${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="auto"
/>
{/* 播放按钮覆盖层 */}
<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={materialDisplayTitle}
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">{materialDisplayTitle}</p>
</div>
</div>
)
} else {
/* 其他类型:显示单张预览图(如果没有多张预览图) */
// 如果没有 previewUrl,不显示任何内容
if (!material.previewUrl) {
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center" style={{ minHeight: '400px' }}>
<p className="text-gray-500 dark:text-gray-400">暂无预览图</p>
</div>
)
}
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<Image
src={material.previewUrl}
alt={materialDisplayTitle}
width={790}
height={1000}
unoptimized
className="w-full h-auto object-contain"
style={{ display: 'block', maxWidth: '790px' }}
/>
</div>
)
}
})()}
</div>
</div>
{/* 右侧:素材信息(固定) */}
<div className={`${material.materialType === '标准模型' ? 'w-[500px]' : '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">
{materialDisplayTitle}
</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>
{/* 标准模型类型:显示SKU列表 */}
{material.materialType === '标准模型' ? (
<SkuList
materialId={materialId}
canEdit={canManageStandardModelSku}
onEditClick={() => setShowSkuEditModal(true)}
onSelectionChange={setSelectedSkuIds}
refreshKey={skuListRefreshKey}
/>
) : (
/* 其他类型:显示素材信息 */
<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>
{material.materialType?.startsWith('任务-') && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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 break-all">
{material.taskNo || '未填写'}
</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 break-all">
{material.dingtalkApprovalNo || '未填写'}
</p>
</div>
</div>
)}
{isCertificateReportMaterialType(material.materialType) ? (
<>
<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.reportTitle || '未填写'}</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">{reportViewerItems.length}</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>
<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>
{!isCertificateReportMaterialType(material.materialType) && (
<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">
{material.materialType === '标准模型' ? (
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => {
if (selectedSkuIds.size > 0) {
void handleBatchDownloadSkus('model')
} else {
showToast('请先选择要下载模型的 SKU', { variant: 'error' })
}
}}
disabled={downloading}
className="w-full py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-xl text-base font-medium transition-colors disabled:opacity-75"
>
{downloading && downloadingBatchType === 'model' ? (
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<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>
打包模型中...
</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>
{selectedSkuIds.size > 0 ? `下载模型 (${selectedSkuIds.size}个)` : '下载模型'}
</span>
)}
</button>
<button
onClick={() => {
if (selectedSkuIds.size > 0) {
void handleBatchDownloadSkus('white-background')
} else {
showToast('请先选择要下载白底图的 SKU', { variant: 'error' })
}
}}
disabled={downloading}
className="w-full py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-xl text-base font-medium transition-colors disabled:opacity-75"
>
{downloading && downloadingBatchType === 'white-background' ? (
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<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>
正在打包白底图...
</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>
{selectedSkuIds.size > 0 ? `下载白底图 (${selectedSkuIds.size}个)` : '下载白底图'}
</span>
)}
</button>
</div>
) : isCertificateReportMaterialType(material.materialType) ? (
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-700/40 dark:text-gray-300">
报告文件请在左侧文件列表中打开或下载
</div>
) : (
<button
onClick={handleDownloadByDirectLink}
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)
setSkuListRefreshKey((prev) => prev + 1)
// 重新获取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>
)
}