709 lines
30 KiB
TypeScript
709 lines
30 KiB
TypeScript
'use client'
|
||
|
||
import React, { useRef, useEffect, useState } from 'react'
|
||
import Image from 'next/image'
|
||
import { format } from 'date-fns'
|
||
import AudioVisualizer from '../AudioVisualizer'
|
||
|
||
// 素材类型定义
|
||
export interface Material {
|
||
id: string
|
||
productModel: string
|
||
categoryName: string
|
||
sceneName: string
|
||
materialType: string
|
||
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
|
||
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
|
||
// 事件处理
|
||
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 getMaterialTypeBadgeColor = (materialType: string): string => {
|
||
const isVideoType = materialType === '主图视频' || materialType === '安装视频' || materialType === '种草视频'
|
||
|
||
if (materialType === '3D场景' || materialType === '3DMAX') {
|
||
return 'bg-red-500'
|
||
} else if (isVideoType) {
|
||
return 'bg-blue-600'
|
||
} else if (materialType === '音乐') {
|
||
return 'bg-purple-600'
|
||
} else if (materialType === 'C4D模型') {
|
||
return 'bg-blue-500'
|
||
} else if (materialType === '详情页' || materialType === '详情模板' || materialType === '矢量文件') {
|
||
return 'bg-amber-500'
|
||
} else {
|
||
return 'bg-gray-700'
|
||
}
|
||
}
|
||
|
||
// 格式化文件大小
|
||
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]}`
|
||
}
|
||
|
||
export default function MaterialCard({
|
||
material,
|
||
session,
|
||
playingAudioId,
|
||
audioElement,
|
||
audioProgress,
|
||
audioCurrentTime,
|
||
audioDuration,
|
||
audioRef,
|
||
downloadingId,
|
||
downloadProgress,
|
||
themeColor,
|
||
onAudioPlay,
|
||
onDownload,
|
||
onLike,
|
||
onFavorite,
|
||
onShare,
|
||
onEdit,
|
||
onDelete,
|
||
onClick,
|
||
onRetryTranscode,
|
||
showToast,
|
||
}: MaterialCardProps) {
|
||
const videoRef = useRef<HTMLVideoElement>(null)
|
||
const [posterLoadError, setPosterLoadError] = useState(false)
|
||
|
||
// 判断素材类型
|
||
const isVideoType = material.materialType === '主图视频' || material.materialType === '安装视频' || material.materialType === '种草视频'
|
||
const isAudioMaterial = material.materialType === '音乐'
|
||
|
||
// 视频预览URL
|
||
const videoPreviewUrl = material.videoPreviewClipUrl || material.videoPreview540Url
|
||
const posterUrl = material.videoCoverUrl || material.previewUrl
|
||
|
||
// 检测封面图是否可加载
|
||
useEffect(() => {
|
||
if (isVideoType && posterUrl && typeof window !== 'undefined') {
|
||
setPosterLoadError(false)
|
||
const img = document.createElement('img')
|
||
img.onload = () => {
|
||
setPosterLoadError(false)
|
||
}
|
||
img.onerror = () => {
|
||
console.error('[MaterialCard] 视频封面图预加载失败:', {
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
videoCoverUrl: material.videoCoverUrl,
|
||
previewUrl: material.previewUrl,
|
||
posterUrl: posterUrl,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
setPosterLoadError(true)
|
||
}
|
||
img.src = posterUrl
|
||
} else {
|
||
setPosterLoadError(false)
|
||
}
|
||
}, [isVideoType, posterUrl, material.id, material.productModel, material.videoCoverUrl, material.previewUrl])
|
||
|
||
// 音频URL(API已经处理过,直接使用)
|
||
let audioUrl = ''
|
||
if (isAudioMaterial && material.zipFileName) {
|
||
// API已经根据用户网络位置处理了zipFileName:
|
||
// - 内网用户:/uploads/xxx 或 内网MinIO地址
|
||
// - 外网用户:/api/uploads/xxx 或 代理URL
|
||
audioUrl = material.zipFileName
|
||
}
|
||
|
||
// 视频鼠标悬停播放控制
|
||
const handleVideoMouseEnter = () => {
|
||
if (videoRef.current) {
|
||
videoRef.current.play().catch(() => {})
|
||
}
|
||
}
|
||
|
||
const handleVideoMouseLeave = () => {
|
||
if (videoRef.current) {
|
||
videoRef.current.pause()
|
||
videoRef.current.currentTime = 0
|
||
}
|
||
}
|
||
|
||
// 复制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 || 2
|
||
const isProcessing = processingIds.includes(material.id)
|
||
|
||
// 如果转码完成或没有视频源,不显示进度条
|
||
if (!material.videoSourceUrl || status === 'done' || (status !== 'pending' && status !== 'processing' && status !== 'failed' && attempts === 0)) {
|
||
return null
|
||
}
|
||
|
||
let label = ''
|
||
let progressValue = 0
|
||
let bgColor = 'bg-gray-700'
|
||
|
||
if (status === 'failed') {
|
||
const error = material.transcodeLastError || ''
|
||
const errorMsg = error.replace(/^转码失败[::]\s*/i, '').trim()
|
||
label = errorMsg ? `转码失败:${errorMsg}` : '转码失败'
|
||
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-blue-600'
|
||
} else if (status === 'processing') {
|
||
label = `转码中${attempts > 1 ? ` · 第${attempts}次` : ''}`
|
||
progressValue = 15
|
||
bgColor = 'bg-blue-600'
|
||
} else if (status === 'pending') {
|
||
label = `等待中${attempts > 1 ? ` · 第${attempts}次` : ''}`
|
||
progressValue = 5
|
||
bgColor = 'bg-gray-600'
|
||
} else if (queueIndex !== null && queueIndex !== undefined) {
|
||
const processingCount = processingIds.length
|
||
label = `排队中 · ${queueIndex}/${queueTotal} · ${processingCount}/${maxConcurrent}执行中`
|
||
progressValue = 8
|
||
bgColor = 'bg-amber-600'
|
||
} else {
|
||
label = status || '未知状态'
|
||
progressValue = 12
|
||
bgColor = 'bg-gray-600'
|
||
}
|
||
|
||
return (
|
||
<div className="absolute bottom-0 left-0 right-0 bg-black/80 text-white text-xs p-2 z-20">
|
||
<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-white/90 text-gray-900 px-2 py-0.5 rounded text-xs hover:bg-white transition-colors"
|
||
>
|
||
重试转码
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="w-full bg-gray-700 rounded-full h-1.5 overflow-hidden">
|
||
<div
|
||
className={`h-full transition-all duration-300 ${bgColor}`}
|
||
style={{ width: `${progressValue}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="card-apple overflow-hidden dark:bg-gray-800 dark:border-gray-700">
|
||
{/* Preview Image/Video */}
|
||
<div
|
||
className="relative w-full h-[400px] bg-gray-200 dark:bg-gray-700 cursor-pointer group overflow-hidden"
|
||
data-material-link="true"
|
||
onMouseEnter={() => isVideoType && videoPreviewUrl && handleVideoMouseEnter()}
|
||
onMouseLeave={() => isVideoType && videoPreviewUrl && handleVideoMouseLeave()}
|
||
onClick={() => onClick(material)}
|
||
>
|
||
{/* 视频类型 */}
|
||
{isVideoType && videoPreviewUrl ? (
|
||
<>
|
||
<video
|
||
ref={videoRef}
|
||
src={videoPreviewUrl}
|
||
poster={!posterLoadError && posterUrl ? posterUrl : undefined}
|
||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||
muted
|
||
loop
|
||
playsInline
|
||
preload="metadata"
|
||
onError={(e) => {
|
||
// 视频加载失败时的错误处理
|
||
console.error('[MaterialCard] 视频加载失败:', {
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
videoPreviewUrl: videoPreviewUrl,
|
||
posterUrl: posterUrl,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
}}
|
||
/>
|
||
{/* 视频封面加载失败时的占位符 */}
|
||
{posterLoadError && (
|
||
<div className="absolute inset-0 w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600 z-10">
|
||
<span className="text-gray-500 dark:text-gray-400 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 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium z-20">
|
||
{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={material.productModel}
|
||
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">{material.productModel}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : material.previewUrl ? (
|
||
<>
|
||
<Image
|
||
src={material.previewUrl}
|
||
alt={material.sceneName}
|
||
fill
|
||
unoptimized
|
||
className="object-cover transition-transform group-hover:scale-105"
|
||
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: material.productModel,
|
||
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-gray-300 dark:bg-gray-600">
|
||
<span className="text-gray-500 dark:text-gray-400 text-sm">图片加载失败</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600 audio-placeholder">
|
||
<span className="text-gray-500 dark:text-gray-400">暂无预览图</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* SPU标签 - 左上角 */}
|
||
<div className="absolute top-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium z-10">
|
||
{material.productModel}
|
||
</div>
|
||
{/* 素材类型角标 - 右上角 */}
|
||
<div className={`absolute top-2 right-2 text-white px-2 py-1 rounded text-xs font-medium z-10 ${getMaterialTypeBadgeColor(material.materialType)}`}>
|
||
{material.materialType}
|
||
</div>
|
||
{/* 放大提示 */}
|
||
{!isVideoType && material.materialType !== '音乐' && (
|
||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-all flex items-center justify-center">
|
||
<svg className="w-12 h-12 text-white opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7" />
|
||
</svg>
|
||
</div>
|
||
)}
|
||
|
||
{/* 转码进度显示 */}
|
||
{renderTranscodeProgress()}
|
||
|
||
{/* 视频时长角标 */}
|
||
{isVideoType && material.videoDuration && (
|
||
<div className="absolute bottom-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium z-10">
|
||
{Math.floor(material.videoDuration / 60)}:{(material.videoDuration % 60).toString().padStart(2, '0')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info */}
|
||
<div className="p-4">
|
||
{/* 标签 */}
|
||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||
{material.categoryName && (
|
||
<span className="inline-flex items-center px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs font-medium">
|
||
{material.categoryName}
|
||
</span>
|
||
)}
|
||
{material.sceneName && (
|
||
<span className="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-xs font-medium">
|
||
{material.sceneName}
|
||
</span>
|
||
)}
|
||
{material.materialType && (
|
||
<span className="inline-flex items-center px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded text-xs font-medium">
|
||
{material.materialType}
|
||
</span>
|
||
)}
|
||
{material.materialType === '3D场景' && material.renderer && (
|
||
<span className="inline-flex items-center px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs font-medium">
|
||
{material.renderer}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 作者和时间 */}
|
||
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||
<p className="truncate">
|
||
<span className="font-medium">作者:</span>
|
||
{material.authorName}
|
||
</p>
|
||
<p
|
||
className="truncate text-xs cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||
onClick={handleCopyId}
|
||
title="点击复制完整ID"
|
||
>
|
||
<span className="font-medium">ID:</span>
|
||
<span className="text-blue-600 dark:text-blue-400">{material.id}</span>
|
||
</p>
|
||
<p className="text-xs">
|
||
<span className="font-medium">大小:</span>
|
||
<span className="text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</span>
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||
{format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')}
|
||
</p>
|
||
</div>
|
||
|
||
{/* 统计数据 */}
|
||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-3 pb-3 border-b border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center gap-3">
|
||
<span className="flex items-center gap-1">
|
||
<svg className="w-4 h-4" 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>
|
||
{material.downloadCount}
|
||
</span>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onLike(material.id)
|
||
}}
|
||
className={`flex items-center gap-1 transition-colors ${
|
||
material.isLiked
|
||
? 'text-red-600 dark:text-red-400'
|
||
: 'text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400'
|
||
}`}
|
||
title={material.isLiked ? '取消点赞' : '点赞'}
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill={material.isLiked ? 'currentColor' : 'none'}
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
strokeWidth={material.isLiked ? 0 : 2}
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||
</svg>
|
||
{material.likeCount}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex gap-2 items-center">
|
||
{/* 编辑按钮 */}
|
||
{session?.user && (
|
||
material.userId === session.user.id || session.user.role === 'ADMIN'
|
||
) && (
|
||
<button
|
||
onClick={() => onEdit(material)}
|
||
className="flex-none h-10 w-10 flex items-center justify-center rounded-xl border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-all"
|
||
title="编辑"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
{/* 删除按钮 */}
|
||
{session?.user && session.user.role === 'ADMIN' && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onDelete(material.id)
|
||
}}
|
||
className="flex-none h-10 w-10 flex items-center justify-center rounded-xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/50 transition-all"
|
||
title="删除"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
{/* 收藏按钮 */}
|
||
{session?.user && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onFavorite(material.id)
|
||
}}
|
||
className={`flex-none h-10 w-10 flex items-center justify-center rounded-xl border transition-all ${
|
||
material.isFavorited
|
||
? 'border-pink-500 bg-pink-500 text-white hover:bg-pink-600'
|
||
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||
}`}
|
||
title={material.isFavorited ? '取消收藏' : '收藏'}
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
viewBox="0 0 24 24"
|
||
fill={material.isFavorited ? 'currentColor' : 'none'}
|
||
stroke="currentColor"
|
||
strokeWidth={material.isFavorited ? 0 : 2}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M6 4h12a1 1 0 011 1v15.382a.5.5 0 01-.777.416L12 17.5l-6.223 3.298A.5.5 0 015 20.382V5a1 1 0 011-1z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* 分享按钮 */}
|
||
<button
|
||
onClick={() => onShare(material.id)}
|
||
className="flex-none h-10 w-10 flex items-center justify-center rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
|
||
title="分享"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||
</svg>
|
||
</button>
|
||
{/* 下载按钮 */}
|
||
<button
|
||
onClick={() => onDownload(material.id)}
|
||
disabled={downloadingId === material.id}
|
||
className="flex-1 h-10 btn-primary text-sm font-medium rounded-xl transition-all disabled:opacity-50 flex items-center justify-center relative overflow-hidden whitespace-nowrap"
|
||
style={{ backgroundColor: themeColor }}
|
||
>
|
||
{downloadingId === material.id ? (
|
||
<>
|
||
<div
|
||
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
|
||
style={{ width: `${downloadProgress}%` }}
|
||
/>
|
||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||
</svg>
|
||
{downloadProgress}%
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className="whitespace-nowrap">下载</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|