2026-08-19 12:08:38 +08:00

1576 lines
65 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 { 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>
)
}