| 1 | /** |
| 2 | * AgentAssetCard - AI 生成素材卡片组件 |
| 3 | * 展示单个 AI 生成素材 |
| 4 | * 特点: |
| 5 | * - 支持图片和视频类型 |
| 6 | * - 视频显示播放图标和时长 |
| 7 | * - 视频无封面时显示占位图 |
| 8 | * - 只读模式(无删除按钮) |
| 9 | */ |
| 10 | |
| 11 | 'use client' |
| 12 | |
| 13 | import type { AssetVo } from '@/types/agent-asset' |
| 14 | import { Play } from 'lucide-react' |
| 15 | import { memo, useCallback } from 'react' |
| 16 | import { OssImage } from '@/components/common/OssImage' |
| 17 | import { useVideoThumbnail } from '@/hooks/useVideoThumbnail' |
| 18 | import { getAssetMediaType, getAssetThumbUrl } from '@/utils/agent/asset' |
| 19 | import { cn } from '@/utils/className' |
| 20 | import { formatSeconds } from '@/utils/format' |
| 21 | import { VideoPlaceholder } from './VideoPlaceholder' |
| 22 | |
| 23 | interface AgentAssetCardProps { |
| 24 | /** 素材数据 */ |
| 25 | asset: AssetVo |
| 26 | /** 预览按钮无文件名时的可访问性文案 */ |
| 27 | previewLabel: string |
| 28 | /** 点击回调 */ |
| 29 | onClick?: (asset: AssetVo) => void |
| 30 | } |
| 31 | |
| 32 | export const AgentAssetCard = memo(({ asset, previewLabel, onClick }: AgentAssetCardProps) => { |
| 33 | const mediaType = getAssetMediaType(asset) |
| 34 | const rawThumbUrl = getAssetThumbUrl(asset) |
| 35 | const isVideo = mediaType === 'video' |
| 36 | const autoThumbUrl = useVideoThumbnail(isVideo && !rawThumbUrl ? asset.url : null) |
| 37 | const thumbUrl = rawThumbUrl || autoThumbUrl |
| 38 | const hasThumb = Boolean(thumbUrl) |
| 39 | const duration = asset.metadata?.duration |
| 40 | |
| 41 | // 处理点击 |
| 42 | const handleClick = useCallback(() => { |
| 43 | onClick?.(asset) |
| 44 | }, [asset, onClick]) |
| 45 | |
| 46 | return ( |
| 47 | <button |
| 48 | type="button" |
| 49 | aria-label={asset.filename || previewLabel} |
| 50 | className={cn( |
| 51 | 'group relative block w-full rounded-lg overflow-hidden bg-muted cursor-pointer min-h-[120px] text-left', |
| 52 | 'transition-all duration-200', |
| 53 | 'hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', |
| 54 | )} |
| 55 | onClick={handleClick} |
| 56 | > |
| 57 | {hasThumb ? ( |
| 58 | // 有封面:图片自然撑开高度,实现瀑布流错落效果 |
| 59 | <div className="relative"> |
| 60 | <OssImage |
| 61 | src={thumbUrl} |
| 62 | alt={asset.filename || ''} |
| 63 | width={400} |
| 64 | height={300} |
| 65 | className="w-full h-auto block" |
| 66 | sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw" |
| 67 | /> |
| 68 | |
| 69 | {/* 视频播放图标 */} |
| 70 | {isVideo && ( |
| 71 | <div className="absolute inset-0 flex items-center justify-center"> |
| 72 | <div className="w-10 h-10 rounded-full bg-foreground/60 flex items-center justify-center group-hover:bg-foreground/80 transition-colors"> |
| 73 | <Play className="w-5 h-5 text-background fill-current ml-0.5" /> |
| 74 | </div> |
| 75 | </div> |
| 76 | )} |
| 77 | |
| 78 | {/* 视频时长标签 */} |
| 79 | {isVideo && duration && ( |
| 80 | <div className="absolute bottom-2 right-2 px-1.5 py-0.5 bg-foreground/75 rounded text-xs text-background"> |
| 81 | {formatSeconds(Math.floor(duration))} |
| 82 | </div> |
| 83 | )} |
| 84 | |
| 85 | {/* 悬浮遮罩 */} |
| 86 | <div className="absolute inset-0 bg-foreground/0 group-hover:bg-foreground/10 transition-colors" /> |
| 87 | </div> |
| 88 | ) : ( |
| 89 | // 无封面:固定 16:9 占位 |
| 90 | <div className="relative w-full" style={{ paddingBottom: '56.25%' }}> |
| 91 | <VideoPlaceholder /> |
| 92 | |
| 93 | {/* 视频播放图标 */} |
| 94 | {isVideo && ( |
| 95 | <div className="absolute inset-0 flex items-center justify-center"> |
| 96 | <div className="w-10 h-10 rounded-full bg-foreground/60 flex items-center justify-center group-hover:bg-foreground/80 transition-colors"> |
| 97 | <Play className="w-5 h-5 text-background fill-current ml-0.5" /> |
| 98 | </div> |
| 99 | </div> |
| 100 | )} |
| 101 | |
| 102 | {/* 悬浮遮罩 */} |
| 103 | <div className="absolute inset-0 bg-foreground/0 group-hover:bg-foreground/10 transition-colors" /> |
| 104 | </div> |
| 105 | )} |
| 106 | </button> |
| 107 | ) |
| 108 | }) |
| 109 | |
| 110 | AgentAssetCard.displayName = 'AgentAssetCard' |
| 111 |