1576 lines
65 KiB
TypeScript
1576 lines
65 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||
import { useGSAP } from '@gsap/react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter, useParams } from 'next/navigation'
|
||
import Image from 'next/image'
|
||
import { format } from 'date-fns'
|
||
import gsap from 'gsap'
|
||
import { CalendarClock, CheckCircle2, RefreshCw, ShieldCheck } from 'lucide-react'
|
||
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 MaterialDetailHeader, {
|
||
type MaterialDetailCapabilities,
|
||
} from '@/components/detail/MaterialDetailHeader'
|
||
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
|
||
import DocumentAssetViewer, { type ViewerAssetItem } from '@/components/DocumentAssetViewer'
|
||
import { DownloadErrorCode, 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'
|
||
import { buildPublicMaterialShareUrl } from '@/lib/public-app-url'
|
||
|
||
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 // 是否已收藏
|
||
capabilities?: MaterialDetailCapabilities
|
||
taskNo?: string // 产品全案任务号
|
||
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
|
||
}>
|
||
}
|
||
|
||
interface VideoShareWaitState {
|
||
message: string
|
||
availableAt: string | null
|
||
remainingDays: number
|
||
}
|
||
|
||
const VIDEO_SHARE_PROTECTION_DAYS = 15
|
||
|
||
gsap.registerPlugin(useGSAP)
|
||
|
||
function parseVideoShareWaitState(value: unknown): VideoShareWaitState | null {
|
||
if (!value || typeof value !== 'object') return null
|
||
|
||
const payload = value as Record<string, unknown>
|
||
if (payload.code !== DownloadErrorCode.VIDEO_WAIT_PERIOD) return null
|
||
|
||
const parsedRemainingDays = Number(payload.remainingDays)
|
||
const remainingDays = Number.isFinite(parsedRemainingDays)
|
||
? Math.max(1, Math.ceil(parsedRemainingDays))
|
||
: 15
|
||
|
||
return {
|
||
message:
|
||
typeof payload.details === 'string' && payload.details.trim()
|
||
? payload.details
|
||
: '视频类素材上传后满 15 天才能查看详情',
|
||
availableAt:
|
||
typeof payload.availableAt === 'string' && payload.availableAt.trim()
|
||
? payload.availableAt
|
||
: null,
|
||
remainingDays,
|
||
}
|
||
}
|
||
|
||
interface VideoShareWaitPanelProps {
|
||
checking: boolean
|
||
onCheckAgain: () => void
|
||
waitState: VideoShareWaitState
|
||
}
|
||
|
||
function VideoShareWaitPanel({ checking, onCheckAgain, waitState }: VideoShareWaitPanelProps) {
|
||
const panelRef = useRef<HTMLElement>(null)
|
||
const availableAt = waitState.availableAt
|
||
? new Date(waitState.availableAt)
|
||
: null
|
||
const availableAtLabel = availableAt && !Number.isNaN(availableAt.getTime())
|
||
? format(availableAt, 'yyyy年M月d日 HH:mm')
|
||
: null
|
||
const elapsedDays = Math.max(
|
||
0,
|
||
Math.min(VIDEO_SHARE_PROTECTION_DAYS, VIDEO_SHARE_PROTECTION_DAYS - waitState.remainingDays),
|
||
)
|
||
const progress = elapsedDays / VIDEO_SHARE_PROTECTION_DAYS
|
||
|
||
useGSAP(() => {
|
||
if (typeof window.matchMedia !== 'function') return
|
||
|
||
const motion = gsap.matchMedia()
|
||
motion.add('(prefers-reduced-motion: no-preference)', () => {
|
||
gsap.fromTo(
|
||
'[data-video-share-reveal]',
|
||
{ autoAlpha: 0, y: 12 },
|
||
{
|
||
autoAlpha: 1,
|
||
clearProps: 'opacity,visibility,transform',
|
||
duration: 0.45,
|
||
ease: 'power2.out',
|
||
stagger: 0.06,
|
||
y: 0,
|
||
},
|
||
)
|
||
gsap.fromTo(
|
||
'[data-video-share-progress]',
|
||
{ scaleX: 0 },
|
||
{
|
||
duration: 0.7,
|
||
ease: 'power2.out',
|
||
scaleX: progress,
|
||
transformOrigin: 'left center',
|
||
},
|
||
)
|
||
})
|
||
|
||
return () => motion.revert()
|
||
}, { scope: panelRef, dependencies: [progress], revertOnUpdate: true })
|
||
|
||
return (
|
||
<section
|
||
ref={panelRef}
|
||
aria-labelledby="video-share-wait-title"
|
||
className="relative mx-auto max-w-2xl overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-white px-5 py-7 text-left sm:px-9 sm:py-9"
|
||
>
|
||
<div className="absolute inset-x-0 top-0 h-px bg-[#e7e8ea]" aria-hidden="true" />
|
||
|
||
<div data-video-share-reveal className="flex items-center gap-3">
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#eef0fa] text-[#5a66b8]">
|
||
<CheckCircle2 className="h-5 w-5" aria-hidden="true" />
|
||
</span>
|
||
<p className="text-[13px] font-semibold text-[#1a1c1d]">
|
||
分享链接已生效
|
||
</p>
|
||
</div>
|
||
|
||
<h1
|
||
id="video-share-wait-title"
|
||
data-video-share-reveal
|
||
className="mt-5 text-[24px] font-semibold tracking-[-0.03em] text-[#1a1c1d] sm:text-[28px]"
|
||
>
|
||
视频预计 {waitState.remainingDays} 天后开放
|
||
</h1>
|
||
<p
|
||
data-video-share-reveal
|
||
className="mt-3 max-w-xl text-[15px] leading-7 text-[#6b7075]"
|
||
>
|
||
视频正处于 15 天保护期,期间暂不提供预览或下载。保护期结束后,当前链接会自动开放。
|
||
</p>
|
||
|
||
<div data-video-share-reveal className="mt-8">
|
||
<div className="flex items-center justify-between gap-4 text-sm">
|
||
<span className="font-medium text-[#1a1c1d]">15 天保护期</span>
|
||
<span className="text-[#86898e]">还需约 {waitState.remainingDays} 天</span>
|
||
</div>
|
||
<div
|
||
role="progressbar"
|
||
aria-label="视频分享保护期进度"
|
||
aria-valuemin={0}
|
||
aria-valuemax={VIDEO_SHARE_PROTECTION_DAYS}
|
||
aria-valuenow={elapsedDays}
|
||
aria-valuetext={`预计还需 ${waitState.remainingDays} 天开放`}
|
||
className="mt-3 h-2 overflow-hidden rounded-full bg-[#e7e8ea]"
|
||
>
|
||
<div
|
||
data-video-share-progress
|
||
className="h-full w-full rounded-full bg-[#1a1c1d]"
|
||
style={{ transform: `scaleX(${progress})`, transformOrigin: 'left center' }}
|
||
/>
|
||
</div>
|
||
<div className="mt-2 flex items-start justify-between gap-4 text-xs text-[#86898e]">
|
||
<span>链接已生效</span>
|
||
<span className="text-right">{availableAtLabel ?? '保护期结束后开放'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{availableAtLabel && (
|
||
<div
|
||
data-video-share-reveal
|
||
className="mt-6 flex items-start gap-3 border-t border-[#e7e8ea] pt-5"
|
||
>
|
||
<CalendarClock className="mt-0.5 h-5 w-5 shrink-0 text-[#6e79c8]" aria-hidden="true" />
|
||
<div>
|
||
<p className="text-sm font-medium text-[#1a1c1d]">预计开放时间</p>
|
||
<time
|
||
dateTime={waitState.availableAt ?? undefined}
|
||
className="mt-0.5 block text-sm text-[#6b7075]"
|
||
>
|
||
{availableAtLabel}
|
||
</time>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div data-video-share-reveal className="mt-5 flex items-start gap-3">
|
||
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-[#86898e]" aria-hidden="true" />
|
||
<div>
|
||
<p className="text-sm font-medium text-[#1a1c1d]">无需重新分享</p>
|
||
<p className="mt-0.5 text-sm leading-6 text-[#6b7075]">
|
||
开放后,访问者继续使用这个链接即可查看视频。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div data-video-share-reveal className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||
<button
|
||
type="button"
|
||
onClick={onCheckAgain}
|
||
disabled={checking}
|
||
aria-describedby="video-share-check-status"
|
||
className="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-[#1a1c1d] px-5 text-sm font-medium text-white transition-colors hover:bg-[#2a2d2f] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-50 sm:w-auto"
|
||
>
|
||
<RefreshCw className={`h-4 w-4 ${checking ? 'motion-safe:animate-spin' : ''}`} aria-hidden="true" />
|
||
{checking ? '正在检查…' : '重新检查'}
|
||
</button>
|
||
<p
|
||
id="video-share-check-status"
|
||
role="status"
|
||
aria-live="polite"
|
||
className="text-center text-xs text-[#86898e] sm:text-left"
|
||
>
|
||
{checking ? '正在确认视频是否已经开放' : '可以随时回来检查开放状态'}
|
||
</p>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
export default function SharePage() {
|
||
const { data: session } = useSession()
|
||
const router = useRouter()
|
||
const params = useParams()
|
||
const materialId = params?.id as string
|
||
|
||
const [material, setMaterial] = useState<Material | null>(null)
|
||
const [videoShareWait, setVideoShareWait] = useState<VideoShareWaitState | null>(null)
|
||
const [checkingVideoAvailability, setCheckingVideoAvailability] = useState(false)
|
||
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 [detailActionBusy, setDetailActionBusy] = useState<'favorite' | 'share' | 'delete' | null>(null)
|
||
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
|
||
const pageRef = useRef<HTMLDivElement>(null)
|
||
|
||
useGSAP(() => {
|
||
if (loading || !material || typeof window.matchMedia !== 'function') return
|
||
if (!pageRef.current?.querySelector('[data-detail-preview], [data-detail-meta]')) return
|
||
|
||
const motion = gsap.matchMedia()
|
||
motion.add('(prefers-reduced-motion: no-preference)', () => {
|
||
gsap.fromTo(
|
||
'[data-detail-preview]',
|
||
{ y: 14, autoAlpha: 0 },
|
||
{ y: 0, autoAlpha: 1, duration: 0.42, ease: 'power3.out', clearProps: 'opacity,visibility,transform' },
|
||
)
|
||
gsap.fromTo(
|
||
'[data-detail-meta]',
|
||
{ y: 12, autoAlpha: 0 },
|
||
{
|
||
y: 0,
|
||
autoAlpha: 1,
|
||
duration: 0.38,
|
||
stagger: 0.05,
|
||
delay: 0.06,
|
||
ease: 'power2.out',
|
||
clearProps: 'opacity,visibility,transform',
|
||
},
|
||
)
|
||
})
|
||
return () => motion.revert()
|
||
}, { scope: pageRef, dependencies: [loading, material?.id] })
|
||
|
||
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 ({ preserveVideoWait = false } = {}) => {
|
||
try {
|
||
if (!preserveVideoWait) {
|
||
setLoading(true)
|
||
setVideoShareWait(null)
|
||
}
|
||
|
||
// 如果是视频类型,立即开始预加载视频(在获取数据前就开始)
|
||
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)
|
||
setVideoShareWait(null)
|
||
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 errorPayload = await response.json().catch(() => null)
|
||
const waitState = parseVideoShareWaitState(errorPayload)
|
||
if (response.status === 403 && waitState) {
|
||
setMaterial(null)
|
||
setVideoShareWait(waitState)
|
||
return
|
||
}
|
||
|
||
const error = errorPayload
|
||
? parseApiError(errorPayload)
|
||
: await parseFetchError(response)
|
||
showToast(error.message, { variant: 'error' })
|
||
router.push('/assets/material-library')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching material:', error)
|
||
const { message } = parseApiError(error)
|
||
showToast(message, { variant: 'error' })
|
||
router.push('/assets/material-library')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const checkVideoAvailability = async () => {
|
||
if (checkingVideoAvailability) return
|
||
|
||
setCheckingVideoAvailability(true)
|
||
try {
|
||
await fetchMaterial({ preserveVideoWait: true })
|
||
} finally {
|
||
setCheckingVideoAvailability(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()
|
||
}
|
||
|
||
const handleShareMaterial = async () => {
|
||
const shareUrl = buildPublicMaterialShareUrl(materialId)
|
||
setDetailActionBusy('share')
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(shareUrl)
|
||
} else {
|
||
const textarea = document.createElement('textarea')
|
||
textarea.value = shareUrl
|
||
textarea.style.position = 'fixed'
|
||
textarea.style.opacity = '0'
|
||
document.body.appendChild(textarea)
|
||
textarea.select()
|
||
const copied = document.execCommand('copy')
|
||
textarea.remove()
|
||
if (!copied) throw new Error('复制失败')
|
||
}
|
||
showToast('分享链接已复制', { variant: 'success' })
|
||
} catch (error) {
|
||
console.error('Share material error:', error)
|
||
showToast('复制失败,请从地址栏复制链接', { variant: 'error' })
|
||
} finally {
|
||
setDetailActionBusy(null)
|
||
}
|
||
}
|
||
|
||
const handleFavoriteMaterial = async () => {
|
||
if (!material?.capabilities?.canFavorite) return
|
||
setDetailActionBusy('favorite')
|
||
try {
|
||
const response = await fetch('/api/materials/favorite', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ materialId }),
|
||
})
|
||
if (!response.ok) {
|
||
const error = await parseFetchError(response)
|
||
throw new Error(error.message)
|
||
}
|
||
|
||
const result = await response.json()
|
||
setMaterial((current) => current ? {
|
||
...current,
|
||
isFavorited: Boolean(result.isFavorited),
|
||
} : current)
|
||
showToast(result.isFavorited ? '已收藏' : '已取消收藏', { variant: 'success' })
|
||
} catch (error) {
|
||
const { message } = parseApiError(error)
|
||
showToast(message, { variant: 'error' })
|
||
} finally {
|
||
setDetailActionBusy(null)
|
||
}
|
||
}
|
||
|
||
const handleDeleteMaterial = async () => {
|
||
if (!material?.capabilities?.canDelete) return
|
||
|
||
await confirmAction({
|
||
title: '删除这条素材?',
|
||
description: '素材将移至回收站。',
|
||
confirmLabel: '删除素材',
|
||
loadingLabel: '正在删除...',
|
||
tone: 'danger',
|
||
action: async () => {
|
||
setDetailActionBusy('delete')
|
||
try {
|
||
const response = await fetch(`/api/materials?id=${encodeURIComponent(materialId)}`, {
|
||
method: 'DELETE',
|
||
})
|
||
if (!response.ok) {
|
||
const error = await parseFetchError(response)
|
||
throw new Error(error.message)
|
||
}
|
||
|
||
sessionStorage.removeItem('home_materials_cache')
|
||
showToast('素材已移至回收站', { variant: 'success' })
|
||
window.setTimeout(() => router.replace('/assets/material-library'), 500)
|
||
} finally {
|
||
setDetailActionBusy(null)
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
||
<main className="mx-auto max-w-[1280px] px-6 py-16">
|
||
<div className="text-center">
|
||
<p className="text-[15px] text-[#86898e]">加载中...</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (videoShareWait) {
|
||
return (
|
||
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
||
<Header session={session} />
|
||
<main className="mx-auto flex min-h-[calc(100vh-5rem)] max-w-[1280px] items-start justify-center px-6 py-8 sm:items-center sm:py-12">
|
||
<VideoShareWaitPanel
|
||
waitState={videoShareWait}
|
||
checking={checkingVideoAvailability}
|
||
onCheckAgain={() => void checkVideoAvailability()}
|
||
/>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
if (!material) {
|
||
return (
|
||
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
||
<Header session={session} />
|
||
<main className="mx-auto max-w-[1280px] px-6 py-16">
|
||
<div className="text-center">
|
||
<p className="text-[15px] text-[#86898e]">素材不存在</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 ref={pageRef} className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
||
<Header session={session} />
|
||
<main className="mx-auto max-w-[1280px] px-6 py-6 sm:py-8">
|
||
<MaterialDetailHeader
|
||
title={materialDisplayTitle || material.productModel || material.materialType}
|
||
materialType={mappedMaterialType}
|
||
materialId={material.id}
|
||
isFavorited={Boolean(material.isFavorited)}
|
||
capabilities={material.capabilities || {
|
||
canFavorite: false,
|
||
canEdit: false,
|
||
canDelete: false,
|
||
}}
|
||
busyAction={detailActionBusy}
|
||
onBack={() => {
|
||
if (window.history.length > 1) {
|
||
router.back()
|
||
return
|
||
}
|
||
router.replace('/assets/material-library')
|
||
}}
|
||
onFavorite={() => void handleFavoriteMaterial()}
|
||
onShare={() => void handleShareMaterial()}
|
||
onEdit={() => router.push(`/edit/${material.id}`)}
|
||
onDelete={() => void handleDeleteMaterial()}
|
||
/>
|
||
|
||
{/* 任务-详情类型:使用专用详情组件 */}
|
||
{mappedMaterialType === '任务-详情' ? (
|
||
<div data-detail-preview className="mx-auto">
|
||
<ProductPackageDetail
|
||
material={{
|
||
id: material.id,
|
||
taskNo: material.taskNo || '',
|
||
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)
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
) : isTaskPageType ? (
|
||
<div data-detail-preview className="mx-auto">
|
||
<TaskPageDetail
|
||
material={{
|
||
id: material.id,
|
||
taskNo: material.taskNo || '',
|
||
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)
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
) : is3DOriginalType ? (
|
||
/* 3D原图/AI原图/实拍原图/视频截图类型:使用专用详情组件 */
|
||
<div data-detail-preview className="mx-auto">
|
||
<ThreeDOriginalDetail
|
||
material={{
|
||
id: material.id,
|
||
taskNo: material.taskNo || '',
|
||
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)
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
/* 其他素材类型:使用原有左右布局 */
|
||
<>
|
||
{/* 所有素材类型统一使用左右布局 */}
|
||
<div className="mx-auto grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-8">
|
||
{/* 左侧:素材预览 */}
|
||
<div data-detail-preview className="min-w-0">
|
||
<div className="overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-white">
|
||
{(() => {
|
||
// 判断是否为视频类型
|
||
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-[#fafafa] 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-[#fafafa] flex items-center justify-center" style={{ minHeight: '400px' }}>
|
||
<p className="text-[#86898e]">PDF文件不存在</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 如果有 displayImages 或有多张预览图,显示多张预览图(适用于所有类型,除了视频和音乐)
|
||
if (allPreviewImages.length > 1 && !isVideoType && material.materialType !== '音乐') {
|
||
/* 显示多张预览图 */
|
||
return (
|
||
<div className="w-full bg-[#fafafa] 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-[#fafafa] 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="relative h-full bg-[#1a1c1d] transition-all duration-100"
|
||
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-[#fafafa] flex items-center justify-center" style={{ minHeight: '400px' }}>
|
||
<p className="text-[#86898e]">暂无预览图</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="w-full bg-[#fafafa] 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 data-detail-meta className="min-w-0">
|
||
<div className="space-y-6 rounded-[12px] border border-[#e7e8ea] bg-white p-5 lg:sticky lg:top-24 lg:p-6">
|
||
{/* 标题和标签 - 统一间距 */}
|
||
<div>
|
||
<h2 className="mb-3 text-[20px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">
|
||
{materialDisplayTitle}
|
||
</h2>
|
||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-[#86898e]">
|
||
<span>{material.categoryName}</span>
|
||
<span>{material.sceneName}</span>
|
||
{material.materialType && (
|
||
<span>{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="mb-1 text-[12px] text-[#86898e]">作者</p>
|
||
<p className="text-[15px] font-medium text-[#1a1c1d]">{material.authorName}</p>
|
||
</div>
|
||
{isCertificateReportMaterialType(material.materialType) ? (
|
||
<>
|
||
<div>
|
||
<p className="mb-1 text-[12px] text-[#86898e]">报告名称 / 检测对象</p>
|
||
<p className="text-[15px] font-medium text-[#1a1c1d]">{material.reportTitle || '未填写'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 text-[12px] text-[#86898e]">文件数量</p>
|
||
<p className="text-[15px] font-medium text-[#1a1c1d]">{reportViewerItems.length}</p>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 text-[12px] text-[#86898e]">总大小</p>
|
||
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{formatFileSize(material.zipFileSize)}</p>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div>
|
||
<p className="mb-1 text-[12px] text-[#86898e]">文件大小</p>
|
||
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{formatFileSize(material.zipFileSize)}</p>
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<p className="mb-1 text-[12px] text-[#86898e]">下载次数</p>
|
||
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{material.downloadCount}</p>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 text-[12px] text-[#86898e]">点赞数</p>
|
||
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{material.likeCount}</p>
|
||
</div>
|
||
</div>
|
||
{!isCertificateReportMaterialType(material.materialType) && (
|
||
<div>
|
||
<p className="mb-1 text-[12px] text-[#86898e]">文件名</p>
|
||
<p className="break-all text-[13px] text-[#1a1c1d]">{material.zipFileName}</p>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<p className="mb-1 text-[12px] text-[#86898e]">上传时间</p>
|
||
<p className="text-[13px] text-[#1a1c1d]">
|
||
{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 rounded-[8px] bg-[#1a1c1d] py-3 text-[14px] font-medium text-white transition-colors hover:bg-[#2a2d2f] 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 rounded-[8px] bg-[#1a1c1d] py-3 text-[14px] font-medium text-white transition-colors hover:bg-[#2a2d2f] 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-[8px] border border-[#e7e8ea] bg-[#fafafa] px-4 py-3 text-[13px] text-[#6b7075]">
|
||
报告文件请在左侧文件列表中打开或下载
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={handleDownloadByDirectLink}
|
||
disabled={downloading}
|
||
className="relative w-full overflow-hidden rounded-[8px] bg-[#1a1c1d] py-3 text-[14px] font-medium text-white hover:bg-[#2a2d2f] disabled:opacity-75"
|
||
>
|
||
{downloading ? (
|
||
<>
|
||
<div
|
||
className="absolute bottom-0 left-0 top-0 bg-[#2a2d2f] 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>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</main>
|
||
|
||
{/* 返回顶部按钮 */}
|
||
{showBackToTop && (
|
||
<button
|
||
onClick={scrollToTop}
|
||
className="fixed bottom-8 right-8 z-50 flex h-12 w-12 items-center justify-center rounded-[8px] border border-[#e7e8ea] bg-white text-[#1a1c1d] transition-colors hover:bg-[#1a1c1d] hover:text-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>
|
||
)}
|
||
|
||
{/* 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} />
|
||
)}
|
||
{confirmActionDialog}
|
||
</div>
|
||
)
|
||
}
|