701 lines
29 KiB
TypeScript
701 lines
29 KiB
TypeScript
'use client'
|
||
|
||
import React, { useEffect, useState } from 'react'
|
||
import Image from 'next/image'
|
||
import { format } from 'date-fns'
|
||
import AudioVisualizer from '../AudioVisualizer'
|
||
import { getTaskDetailSourceTypeLabel } from '@/lib/task-detail-source-type'
|
||
import { getCertificateReportDisplayTitle } from '@/lib/certificate-report'
|
||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||
import {
|
||
getVideoDownloadAvailability,
|
||
getVideoDownloadUnavailableMessage,
|
||
VIDEO_DOWNLOAD_WAIT_DAYS,
|
||
} from '@/lib/video-download-policy'
|
||
|
||
// 素材类型定义
|
||
export interface Material {
|
||
id: string
|
||
productModel: string
|
||
reportTitle?: string | null
|
||
categoryName: string
|
||
sceneName: string
|
||
platformName?: string
|
||
productCategoryName?: string
|
||
materialType: string
|
||
isAiGenerated?: boolean
|
||
taskDetailSourceType?: string | null
|
||
renderer?: string
|
||
authorName: string
|
||
previewUrl: string
|
||
zipFileName: string
|
||
zipFileSize: string | number
|
||
downloadCount: number
|
||
likeCount: number
|
||
isLiked: boolean
|
||
isFavorited: boolean
|
||
userId: string
|
||
createdAt: string
|
||
// 视频相关
|
||
videoSourceUrl?: string
|
||
videoPreviewClipUrl?: string
|
||
videoPreview540Url?: string
|
||
videoCoverUrl?: string
|
||
videoDuration?: number
|
||
videoProcessingStatus?: string
|
||
videoPreviewBlocked?: boolean
|
||
videoPreviewUnavailableMessage?: string | null
|
||
videoPreviewRemainingDays?: number | null
|
||
transcodeProgress?: number
|
||
transcodeStage?: string
|
||
transcodeAttempts?: number
|
||
transcodeLastError?: string
|
||
transcodeQueueWaitingIndex?: number
|
||
transcodeQueueWaitingTotal?: number
|
||
transcodeQueueProcessingIds?: string[]
|
||
transcodeQueueMaxConcurrent?: number
|
||
}
|
||
|
||
// 用户会话类型
|
||
interface UserSession {
|
||
id: string
|
||
role: string
|
||
}
|
||
|
||
interface MaterialCardProps {
|
||
material: Material
|
||
session: { user: UserSession } | null
|
||
// 音频播放状态
|
||
playingAudioId: string | null
|
||
audioElement: HTMLAudioElement | null
|
||
audioProgress: number
|
||
audioCurrentTime: number
|
||
audioDuration: number
|
||
audioRef: React.RefObject<HTMLAudioElement | null>
|
||
// 下载状态
|
||
downloadingId: string | null
|
||
downloadProgress: number
|
||
// 主题色
|
||
themeColor: string
|
||
// 筛选状态(用于空状态提示)
|
||
hasFilters?: boolean
|
||
// 事件处理
|
||
onAudioPlay: (materialId: string, audioUrl: string) => void
|
||
onDownload: (id: string) => void
|
||
onLike: (id: string) => void
|
||
onFavorite: (id: string) => void
|
||
onShare: (id: string) => void
|
||
onEdit: (material: Material) => void
|
||
onDelete: (id: string) => void
|
||
onClick: (material: Material) => void
|
||
onRetryTranscode: (materialId: string) => void
|
||
showToast: (message: string, options?: { variant?: 'success' | 'error' }) => void
|
||
}
|
||
|
||
const OVERLAY_CHIP_CLASS = 'bg-white/92 text-[#1a1c1d] border border-[#e7e8ea] px-2 py-1 rounded-[8px] text-xs font-medium'
|
||
|
||
function formatFileSize(bytes: string | number): string {
|
||
const size = typeof bytes === 'string' ? Number.parseInt(bytes, 10) : bytes
|
||
if (!size || Number.isNaN(size) || size <= 0) return ''
|
||
const units = ['B', 'KB', 'MB', 'GB']
|
||
const unitIndex = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1)
|
||
return `${(size / Math.pow(1024, unitIndex)).toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`
|
||
}
|
||
|
||
function DownloadGlyph() {
|
||
return (
|
||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 4v11m0 0l-4-4m4 4l4-4M5 19h14" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function ShareGlyph() {
|
||
return (
|
||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M7 11l5-6 5 6M12 5v10M6 19h12" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function HeartGlyph({ filled }: { filled: boolean }) {
|
||
return (
|
||
<svg className="h-3.5 w-3.5" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 20s-7-4.4-7-9.2C5 8 6.8 6.4 9 6.4c1.3 0 2.4.6 3 1.6.6-1 1.7-1.6 3-1.6 2.2 0 4 1.6 4 4.4 0 4.8-7 9.2-7 9.2z" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
export default function MaterialCard({
|
||
material,
|
||
session,
|
||
playingAudioId,
|
||
audioElement,
|
||
audioProgress,
|
||
audioCurrentTime,
|
||
audioDuration,
|
||
audioRef,
|
||
downloadingId,
|
||
downloadProgress,
|
||
onAudioPlay,
|
||
onDownload,
|
||
onLike,
|
||
onFavorite,
|
||
onShare,
|
||
onEdit,
|
||
onDelete,
|
||
onClick,
|
||
onRetryTranscode,
|
||
showToast,
|
||
}: MaterialCardProps) {
|
||
const [posterLoadError, setPosterLoadError] = useState(false)
|
||
const [shouldLoadVideoPreview, setShouldLoadVideoPreview] = useState(false)
|
||
|
||
// 判断素材类型
|
||
const isVideoType = isVideoMaterialType(material.materialType)
|
||
const videoDownloadAvailability = getVideoDownloadAvailability(
|
||
material.materialType,
|
||
material.createdAt,
|
||
)
|
||
const isDownloadWaiting = !videoDownloadAvailability.allowed
|
||
const isPreviewWaiting = isVideoType && (material.videoPreviewBlocked || isDownloadWaiting)
|
||
const previewUnavailableMessage =
|
||
material.videoPreviewUnavailableMessage ||
|
||
getVideoDownloadUnavailableMessage(videoDownloadAvailability).replace('下载', '查看详情')
|
||
const isDownloadDisabled = downloadingId === material.id
|
||
const isAudioMaterial = material.materialType === '音乐'
|
||
|
||
// 视频预览URL
|
||
const videoPreviewUrl = isPreviewWaiting ? null : material.videoPreviewClipUrl || material.videoPreview540Url
|
||
const posterUrl = material.videoCoverUrl || material.previewUrl
|
||
const materialDisplayTitle = getCertificateReportDisplayTitle({
|
||
productModel: material.productModel,
|
||
reportTitle: material.reportTitle,
|
||
})
|
||
|
||
// 素材切换时回到封面态;视频只在用户悬停时加载,避免列表首屏并发预载全部视频。
|
||
useEffect(() => {
|
||
setPosterLoadError(false)
|
||
setShouldLoadVideoPreview(false)
|
||
}, [material.id, posterUrl])
|
||
|
||
// 音频URL(API已经处理过,直接使用)
|
||
let audioUrl = ''
|
||
if (isAudioMaterial && material.zipFileName) {
|
||
// API已经根据用户网络位置处理了zipFileName:
|
||
// - 内网用户:/uploads/xxx 或 内网MinIO地址
|
||
// - 外网用户:/api/uploads/xxx 或 代理URL
|
||
audioUrl = material.zipFileName
|
||
}
|
||
|
||
const isTaskDetailMaterial = material.materialType === '任务-详情'
|
||
|
||
const handleMaterialClick = () => {
|
||
if (isPreviewWaiting) {
|
||
showToast(previewUnavailableMessage, { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
onClick(material)
|
||
}
|
||
|
||
// 视频鼠标悬停播放控制:离开后卸载 video,立即释放媒体连接。
|
||
const handleVideoMouseEnter = () => {
|
||
setShouldLoadVideoPreview(true)
|
||
}
|
||
|
||
const handleVideoMouseLeave = () => {
|
||
setShouldLoadVideoPreview(false)
|
||
}
|
||
|
||
// 复制ID到剪贴板
|
||
const handleCopyId = async (e: React.MouseEvent) => {
|
||
e.stopPropagation()
|
||
try {
|
||
await navigator.clipboard.writeText(material.id)
|
||
showToast('ID已复制', { variant: 'success' })
|
||
} catch {
|
||
const textarea = document.createElement('textarea')
|
||
textarea.value = material.id
|
||
textarea.style.position = 'fixed'
|
||
textarea.style.left = '-999999px'
|
||
document.body.appendChild(textarea)
|
||
textarea.select()
|
||
try {
|
||
document.execCommand('copy')
|
||
showToast('ID已复制', { variant: 'success' })
|
||
} catch {
|
||
showToast('复制失败', { variant: 'error' })
|
||
}
|
||
document.body.removeChild(textarea)
|
||
}
|
||
}
|
||
|
||
// 渲染转码进度
|
||
const renderTranscodeProgress = () => {
|
||
if (!isVideoType) return null
|
||
|
||
const status = material.videoProcessingStatus
|
||
const progress = material.transcodeProgress
|
||
const stage = material.transcodeStage
|
||
const attempts = material.transcodeAttempts || 0
|
||
const queueIndex = material.transcodeQueueWaitingIndex
|
||
const queueTotal = material.transcodeQueueWaitingTotal || 0
|
||
const processingIds = material.transcodeQueueProcessingIds || []
|
||
const maxConcurrent = material.transcodeQueueMaxConcurrent || 1
|
||
const isProcessing = processingIds.includes(material.id)
|
||
|
||
// 15天策略会隐藏 videoSourceUrl;进度只能依据公开的处理状态判断。
|
||
if (status === 'done' || (status !== 'pending' && status !== 'processing' && status !== 'failed')) {
|
||
return null
|
||
}
|
||
|
||
let label = ''
|
||
let progressValue = 0
|
||
let bgColor = 'bg-gray-700'
|
||
|
||
if (status === 'failed') {
|
||
label = '转码失败,请稍后重试'
|
||
progressValue = 0
|
||
bgColor = 'bg-red-600'
|
||
} else if (isProcessing) {
|
||
const stageName = stage === 'preview' ? '预览视频' : stage === 'clip' ? '预览片段' : stage === 'cover' ? '封面图' : ''
|
||
const processingCount = processingIds.length
|
||
const waitingCount = Math.max(0, queueTotal - processingCount)
|
||
|
||
if (progress !== null && progress !== undefined && progress > 0) {
|
||
progressValue = Math.max(5, Math.min(progress, 95))
|
||
label = `转码中 ${progress}%${stageName ? ` · ${stageName}` : ''}${attempts > 1 ? ` · 第${attempts}次` : ''}${waitingCount > 0 ? ` · 队列余${waitingCount}` : ''}`
|
||
} else {
|
||
progressValue = Math.max(28, Math.min(35 + 55 * Math.min(0.95, 0) + (attempts > 1 ? 5 : 0), 92))
|
||
label = `转码中${stageName ? ` · ${stageName}` : ''}${attempts > 1 ? ` · 第${attempts}次` : ''}${waitingCount > 0 ? ` · 队列余${waitingCount}` : ''} · ${processingCount}/${maxConcurrent}并发`
|
||
}
|
||
bgColor = 'bg-[#1a1c1d]'
|
||
} else if (status === 'processing') {
|
||
label = `转码中${attempts > 1 ? ` · 第${attempts}次` : ''}`
|
||
progressValue = 15
|
||
bgColor = 'bg-[#1a1c1d]'
|
||
} else if (status === 'pending') {
|
||
label = `等待中${attempts > 1 ? ` · 第${attempts}次` : ''}`
|
||
progressValue = 5
|
||
bgColor = 'bg-[#86898e]'
|
||
} else if (queueIndex !== null && queueIndex !== undefined) {
|
||
const processingCount = processingIds.length
|
||
label = `排队中 · ${queueIndex}/${queueTotal} · ${processingCount}/${maxConcurrent}执行中`
|
||
progressValue = 8
|
||
bgColor = 'bg-[#6e79c8]'
|
||
} else {
|
||
label = status || '未知状态'
|
||
progressValue = 12
|
||
bgColor = 'bg-[#86898e]'
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{status !== 'failed' && (
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-10 z-30 flex justify-center">
|
||
<span className="rounded-[8px] border border-[#e7e8ea] bg-white px-3 py-1.5 text-[12px] text-[#1a1c1d]">
|
||
{label}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div
|
||
className="absolute bottom-0 left-0 right-0 z-40 border-t border-[#e7e8ea] bg-white/95 p-2 text-xs text-[#1a1c1d]"
|
||
role="status"
|
||
aria-live={status === 'failed' ? 'assertive' : 'polite'}
|
||
aria-atomic="true"
|
||
>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="truncate">{label}</span>
|
||
{status === 'failed' && !material.videoPreviewClipUrl && !material.videoPreview540Url && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onRetryTranscode(material.id)
|
||
}}
|
||
className="ml-2 bg-[#1a1c1d] text-white px-2 py-0.5 rounded-[8px] text-xs hover:bg-[#2a2d2f] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
|
||
>
|
||
重试转码
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div
|
||
className="h-1.5 w-full overflow-hidden rounded-[8px] bg-[#e7e8ea]"
|
||
role="progressbar"
|
||
aria-label={label}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
aria-valuenow={progressValue}
|
||
>
|
||
<div
|
||
className={`h-full transition-all duration-300 ${bgColor}`}
|
||
style={{ width: `${progressValue}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
const fileSizeLabel = formatFileSize(material.zipFileSize)
|
||
|
||
return (
|
||
<article data-card className="bg-transparent">
|
||
{/* Preview Image/Video */}
|
||
<div
|
||
className="relative h-[400px] w-full cursor-pointer overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-[#f3f4f5] group"
|
||
data-material-link="true"
|
||
onMouseEnter={() => isVideoType && videoPreviewUrl && handleVideoMouseEnter()}
|
||
onMouseLeave={() => isVideoType && videoPreviewUrl && handleVideoMouseLeave()}
|
||
onClick={handleMaterialClick}
|
||
>
|
||
{/* 视频类型 */}
|
||
{isVideoType && videoPreviewUrl ? (
|
||
<>
|
||
{shouldLoadVideoPreview ? (
|
||
<video
|
||
src={videoPreviewUrl}
|
||
poster={!posterLoadError && posterUrl ? posterUrl : undefined}
|
||
aria-label={`${materialDisplayTitle} 视频预览`}
|
||
className="h-full w-full object-cover"
|
||
muted
|
||
loop
|
||
playsInline
|
||
autoPlay
|
||
preload="metadata"
|
||
onError={() => {
|
||
console.error('[MaterialCard] 视频加载失败:', {
|
||
materialId: material.id,
|
||
productModel: materialDisplayTitle,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
}}
|
||
/>
|
||
) : !posterLoadError && posterUrl ? (
|
||
<Image
|
||
src={posterUrl}
|
||
alt={`${materialDisplayTitle} 视频封面`}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
sizes="(max-width: 768px) 100vw, 300px"
|
||
onError={() => {
|
||
console.error('[MaterialCard] 视频封面图加载失败:', {
|
||
materialId: material.id,
|
||
productModel: materialDisplayTitle,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
setPosterLoadError(true)
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="absolute inset-0 w-full h-full flex items-center justify-center bg-[#eceef0] z-10">
|
||
<span className="text-[#86898e] text-sm">封面加载失败</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : isAudioMaterial ? (
|
||
// 音频素材卡片
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-b from-slate-800 to-slate-900 overflow-hidden">
|
||
{/* 圆形频谱可视化背景 */}
|
||
{playingAudioId === material.id && audioElement && (
|
||
<div className="absolute inset-0 flex items-center justify-center z-0">
|
||
<AudioVisualizer
|
||
audioElement={audioElement}
|
||
isPlaying={true}
|
||
size="sm"
|
||
coverImage={material.previewUrl || null}
|
||
showCoverOnPause={false}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 播放进度条 - 独立的交互层 */}
|
||
{playingAudioId === material.id && (
|
||
<div
|
||
className="absolute bottom-0 left-0 right-0 h-6 z-[60] cursor-pointer"
|
||
style={{ pointerEvents: 'auto' }}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
e.preventDefault()
|
||
if (audioRef?.current && audioRef.current.duration) {
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
audioRef.current.currentTime = audioRef.current.duration * percent
|
||
}
|
||
}}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation()
|
||
e.preventDefault()
|
||
const progressBar = e.currentTarget
|
||
let isDragging = true
|
||
|
||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||
if (!isDragging) return
|
||
moveEvent.preventDefault()
|
||
moveEvent.stopPropagation()
|
||
if (audioRef?.current && audioRef.current.duration) {
|
||
const rect = progressBar.getBoundingClientRect()
|
||
const percent = Math.max(0, Math.min(1, (moveEvent.clientX - rect.left) / rect.width))
|
||
audioRef.current.currentTime = audioRef.current.duration * percent
|
||
}
|
||
}
|
||
|
||
const handleMouseUp = (upEvent: MouseEvent) => {
|
||
upEvent.preventDefault()
|
||
upEvent.stopPropagation()
|
||
isDragging = false
|
||
document.removeEventListener('mousemove', handleMouseMove)
|
||
document.removeEventListener('mouseup', handleMouseUp)
|
||
}
|
||
|
||
document.addEventListener('mousemove', handleMouseMove, { passive: false })
|
||
document.addEventListener('mouseup', handleMouseUp, { passive: false, once: true })
|
||
}}
|
||
>
|
||
<div className="absolute bottom-0 left-0 right-0 h-2 bg-slate-700 group hover:h-3 transition-all pointer-events-none">
|
||
<div
|
||
className="h-full bg-blue-500 transition-all duration-100 relative pointer-events-none"
|
||
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 pointer-events-none" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* 时长显示 */}
|
||
{playingAudioId === material.id && audioDuration > 0 && (
|
||
<div className={`absolute bottom-2 left-2 z-20 ${OVERLAY_CHIP_CLASS}`}>
|
||
{Math.floor(audioCurrentTime / 60)}:{Math.floor(audioCurrentTime % 60).toString().padStart(2, '0')} / {Math.floor(audioDuration / 60)}:{Math.floor(audioDuration % 60).toString().padStart(2, '0')}
|
||
</div>
|
||
)}
|
||
|
||
{/* 信息覆盖层 - 确保按钮在最上层,但不覆盖底部进度条区域 */}
|
||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center pointer-events-none" style={{ bottom: playingAudioId === material.id ? '24px' : '0' }}>
|
||
{material.previewUrl && playingAudioId !== material.id && (
|
||
<Image
|
||
src={material.previewUrl}
|
||
alt={materialDisplayTitle}
|
||
fill
|
||
unoptimized
|
||
className="object-cover opacity-50"
|
||
sizes="(max-width: 768px) 100vw, 300px"
|
||
/>
|
||
)}
|
||
{/* 播放/暂停按钮 */}
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
e.preventDefault()
|
||
if (audioUrl) {
|
||
onAudioPlay(material.id, audioUrl)
|
||
}
|
||
}}
|
||
className={`w-14 h-14 rounded-full backdrop-blur-sm flex items-center justify-center border transition-all pointer-events-auto relative z-40 ${
|
||
playingAudioId === material.id
|
||
? "bg-white/30 border-white/50 hover:bg-white/40"
|
||
: "bg-white/20 border-white/30 group-hover:scale-110 hover:bg-white/30"
|
||
}`}
|
||
>
|
||
{playingAudioId === material.id ? (
|
||
<svg className="w-7 h-7 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||
</svg>
|
||
) : (
|
||
<svg className="w-7 h-7 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
{playingAudioId !== material.id && (
|
||
<p className="text-white text-sm font-medium text-center px-4 line-clamp-1 drop-shadow-lg mt-3">{materialDisplayTitle}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : material.previewUrl ? (
|
||
<>
|
||
<Image
|
||
src={material.previewUrl}
|
||
alt={material.sceneName}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
sizes="(max-width: 768px) 100vw, 300px"
|
||
onError={(e) => {
|
||
// 图片加载失败时,隐藏图片并显示占位符
|
||
const target = e.target as HTMLImageElement
|
||
const failedUrl = target.src
|
||
console.error('[MaterialCard] 预览图加载失败:', {
|
||
materialId: material.id,
|
||
productModel: materialDisplayTitle,
|
||
previewUrl: material.previewUrl,
|
||
failedUrl: failedUrl,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
|
||
if (target.parentElement) {
|
||
target.style.display = 'none'
|
||
const placeholder = target.parentElement.querySelector('.image-placeholder')
|
||
if (placeholder) {
|
||
(placeholder as HTMLElement).style.display = 'flex'
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
<div className="image-placeholder hidden w-full h-full items-center justify-center bg-[#eceef0]">
|
||
<span className="text-[#86898e] text-sm">图片加载失败</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-[#eceef0] audio-placeholder">
|
||
<span className="text-[#86898e]">
|
||
{material.videoProcessingStatus === 'failed'
|
||
? '预览生成失败'
|
||
: material.videoProcessingStatus === 'pending' || material.videoProcessingStatus === 'processing'
|
||
? '预览生成中'
|
||
: '暂无预览图'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 转码进度显示 */}
|
||
{renderTranscodeProgress()}
|
||
|
||
{/* 视频时长角标 */}
|
||
{isVideoType && material.videoDuration && (
|
||
<div className={`absolute bottom-2 left-2 z-10 ${OVERLAY_CHIP_CLASS}`}>
|
||
{Math.floor(material.videoDuration / 60)}:{(material.videoDuration % 60).toString().padStart(2, '0')}
|
||
</div>
|
||
)}
|
||
{isPreviewWaiting && (
|
||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-[#fafafa]/55 px-4 text-center">
|
||
<div className="rounded-[8px] border border-[#e7e8ea] bg-white px-3 py-2 text-xs font-medium text-[#1a1c1d]">
|
||
{material.videoPreviewRemainingDays || videoDownloadAvailability.remainingDays || VIDEO_DOWNLOAD_WAIT_DAYS}天后可查看详情
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="pt-4">
|
||
<h3 className="truncate font-display text-[16px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">
|
||
{materialDisplayTitle}
|
||
</h3>
|
||
<p className="mt-1.5 truncate text-[12px] leading-relaxed text-[#86898e]">
|
||
{fileSizeLabel && <><span className="font-en tabular-nums tracking-[0.04em]">{fileSizeLabel}</span> · </>}
|
||
{material.authorName && <><span>{material.authorName}</span> · </>}
|
||
<span className="font-en tabular-nums tracking-[0.04em]">{format(new Date(material.createdAt), 'yyyy.MM.dd')}</span>
|
||
{material.materialType && <> · <span>{material.materialType}</span></>}
|
||
{material.categoryName && <> · <span>{material.categoryName}</span></>}
|
||
{material.sceneName && <> · <span>{material.sceneName}</span></>}
|
||
{isTaskDetailMaterial && <> · <span>{getTaskDetailSourceTypeLabel(material.taskDetailSourceType)}</span></>}
|
||
{!isTaskDetailMaterial && material.isAiGenerated && <> · <span>AI生成</span></>}
|
||
{material.materialType === '3D场景' && material.renderer && <> · <span>{material.renderer}</span></>}
|
||
</p>
|
||
<p
|
||
className="mt-1 cursor-pointer truncate font-en text-[11px] tracking-[0.04em] text-[#86898e] hover:text-[#1a1c1d]"
|
||
onClick={handleCopyId}
|
||
title="点击复制完整ID"
|
||
>
|
||
ID {material.id}
|
||
</p>
|
||
|
||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (isDownloadWaiting) {
|
||
showToast(getVideoDownloadUnavailableMessage(videoDownloadAvailability), { variant: 'error' })
|
||
return
|
||
}
|
||
onDownload(material.id)
|
||
}}
|
||
disabled={isDownloadDisabled}
|
||
aria-disabled={isDownloadWaiting}
|
||
className={`inline-flex items-center gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${
|
||
isDownloadWaiting || isDownloadDisabled ? 'cursor-not-allowed text-[#86898e]' : 'text-[#1a1c1d] hover:text-[#5a66b8]'
|
||
}`}
|
||
title={
|
||
videoDownloadAvailability.allowed
|
||
? '下载'
|
||
: getVideoDownloadUnavailableMessage(videoDownloadAvailability)
|
||
}
|
||
>
|
||
<DownloadGlyph />
|
||
{downloadingId === material.id
|
||
? `${downloadProgress}%`
|
||
: isDownloadWaiting
|
||
? `${videoDownloadAvailability.remainingDays || VIDEO_DOWNLOAD_WAIT_DAYS}天后可下载`
|
||
: '下载'}
|
||
{downloadingId !== material.id && !isDownloadWaiting && material.downloadCount > 0 && (
|
||
<span className="font-en text-[11px] tabular-nums text-[#86898e]">{material.downloadCount}</span>
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onShare(material.id)}
|
||
aria-label="分享"
|
||
className="inline-flex items-center gap-1.5 text-[#1a1c1d] transition-colors hover:text-[#5a66b8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
|
||
title="分享"
|
||
>
|
||
<ShareGlyph />
|
||
分享
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onLike(material.id)
|
||
}}
|
||
className={`inline-flex items-center gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${
|
||
material.isLiked ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'
|
||
}`}
|
||
title={material.isLiked ? '取消点赞' : '点赞'}
|
||
>
|
||
点赞 <span className="font-en text-[12px] tabular-nums tracking-[0.06em]">{material.likeCount}</span>
|
||
</button>
|
||
{session?.user && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onFavorite(material.id)
|
||
}}
|
||
className={`inline-flex items-center gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm ${
|
||
material.isFavorited ? 'text-[#1a1c1d]' : 'text-[#86898e] hover:text-[#1a1c1d]'
|
||
}`}
|
||
title={material.isFavorited ? '取消收藏' : '收藏'}
|
||
>
|
||
<HeartGlyph filled={material.isFavorited} />
|
||
{material.isFavorited ? '已收藏' : '收藏'}
|
||
</button>
|
||
)}
|
||
{session?.user && (material.userId === session.user.id || session.user.role === 'ADMIN') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onEdit(material)}
|
||
className="text-[#86898e] transition-colors hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
|
||
title="编辑"
|
||
>
|
||
编辑
|
||
</button>
|
||
)}
|
||
{session?.user && session.user.role === 'ADMIN' && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onDelete(material.id)
|
||
}}
|
||
className="text-[#86898e] transition-colors hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 rounded-sm"
|
||
title="删除"
|
||
>
|
||
删除
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
)
|
||
}
|