gongxiangp2/components/home/MaterialCard.tsx
chenpei 752b7bf999 fix: 修复批量上传和视频扩展名问题
主要修复:

- 修复批量上传闭包陷阱,防止文件丢失

- 添加 fileName 参数传递原始文件名,保留正确扩展名

- 添加取消检查回调,改进批量上传取消功能

其他改进:

- 优化搜索功能和管理后台

- 添加 FRP 故障诊断文档

- 更新环境验证和网络检测
2026-02-03 11:12:50 +08:00

711 lines
30 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 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
// 筛选状态(用于空状态提示)
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 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 === '矢量文件') {
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>
)
}