gongxiangp2/components/home/MaterialCard.tsx
2026-08-21 13:19:16 +08:00

701 lines
29 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, { 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>
)
}