| 1 | /** |
| 2 | * MediaCard - 媒体卡片 |
| 3 | * 展示视频/图片缩略图,点击打开预览 |
| 4 | * 视频:缩略图 + 播放图标 + 时长 |
| 5 | * 图片:缩略图,保持原始比例 |
| 6 | * 支持批量模式:显示勾选指示器,点击切换选中 |
| 7 | */ |
| 8 | |
| 9 | 'use client' |
| 10 | |
| 11 | import type { ReactNode } from 'react' |
| 12 | import type { MediaItem } from '@/api/materials/material.types' |
| 13 | import { Check, Play } from 'lucide-react' |
| 14 | import { memo, useCallback } from 'react' |
| 15 | import { cn } from '@/utils/className' |
| 16 | import { getOssUrl } from '@/utils/oss' |
| 17 | import { LazyImage } from '../LazyImage' |
| 18 | import { PublishDialogDragSource } from '../PublishDialogDragSource' |
| 19 | |
| 20 | interface MediaCardProps { |
| 21 | /** 媒体数据 */ |
| 22 | media: MediaItem |
| 23 | /** 点击回调 */ |
| 24 | onClick: (media: MediaItem) => void |
| 25 | /** 是否为批量模式 */ |
| 26 | batchMode?: boolean |
| 27 | /** 是否选中 */ |
| 28 | selected?: boolean |
| 29 | /** 切换选中回调 */ |
| 30 | onToggleSelect?: () => void |
| 31 | /** 右上角操作区 */ |
| 32 | actions?: ReactNode |
| 33 | /** OSS/R2 图片是否使用云端缩略图 */ |
| 34 | useOssThumbnail?: boolean |
| 35 | /** 是否允许拖拽到发布弹框 */ |
| 36 | enablePublishDrag?: boolean |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * 格式化视频时长 |
| 41 | */ |
| 42 | function formatDuration(seconds?: number): string { |
| 43 | if (!seconds) |
| 44 | return '' |
| 45 | const mins = Math.floor(seconds / 60) |
| 46 | const secs = Math.floor(seconds % 60) |
| 47 | return `${mins}:${secs.toString().padStart(2, '0')}` |
| 48 | } |
| 49 | |
| 50 | export const MediaCard = memo(({ media, onClick, batchMode, selected, onToggleSelect, actions, useOssThumbnail, enablePublishDrag }: MediaCardProps) => { |
| 51 | const isVideo = media.type === 'video' |
| 52 | const thumbUrl = media.thumbUrl ? getOssUrl(media.thumbUrl) : media.url ? getOssUrl(media.url) : '/images/placeholder.png' |
| 53 | const duration = media.metadata?.duration |
| 54 | const canPublishDrag = !!enablePublishDrag && !batchMode |
| 55 | |
| 56 | const handleClick = useCallback(() => { |
| 57 | if (batchMode) { |
| 58 | onToggleSelect?.() |
| 59 | } |
| 60 | else { |
| 61 | onClick(media) |
| 62 | } |
| 63 | }, [media, onClick, batchMode, onToggleSelect]) |
| 64 | |
| 65 | const cardNode = ( |
| 66 | <div |
| 67 | className={cn( |
| 68 | 'mb-4 cursor-pointer group relative', |
| 69 | batchMode && selected && 'rounded-xl shadow-lg', |
| 70 | )} |
| 71 | onClick={handleClick} |
| 72 | > |
| 73 | {/* 批量模式圆形勾选指示器 */} |
| 74 | {batchMode && ( |
| 75 | <div |
| 76 | className={cn( |
| 77 | 'absolute top-2 right-2 z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all duration-200 shadow-sm', |
| 78 | selected |
| 79 | ? 'border-transparent bg-gradient-back scale-110' |
| 80 | : 'bg-background/90 border-muted-foreground/30 group-hover:border-primary group-hover:scale-105', |
| 81 | )} |
| 82 | onClick={(e) => { e.stopPropagation(); onToggleSelect?.() }} |
| 83 | > |
| 84 | {selected && <Check className="w-3.5 h-3.5 text-gradient-foreground" />} |
| 85 | </div> |
| 86 | )} |
| 87 | |
| 88 | {!batchMode && actions && ( |
| 89 | <div |
| 90 | className={cn( |
| 91 | 'absolute top-3 right-3 z-10 transition-all duration-200', |
| 92 | 'opacity-100 pointer-events-auto', |
| 93 | 'md:translate-y-1 md:opacity-0 md:pointer-events-none', |
| 94 | 'md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-hover:pointer-events-auto', |
| 95 | 'md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100 md:group-focus-within:pointer-events-auto', |
| 96 | )} |
| 97 | onClick={e => e.stopPropagation()} |
| 98 | > |
| 99 | {actions} |
| 100 | </div> |
| 101 | )} |
| 102 | |
| 103 | <div className="relative w-full overflow-hidden rounded-xl"> |
| 104 | <LazyImage |
| 105 | src={thumbUrl} |
| 106 | alt={media.title || media.desc || '媒体'} |
| 107 | width={400} |
| 108 | height={300} |
| 109 | className="w-full h-auto transition-transform duration-300 group-hover:scale-105" |
| 110 | skeletonClassName="rounded-xl" |
| 111 | placeholderHeight={150} |
| 112 | style={{ aspectRatio: 'auto' }} |
| 113 | unoptimized |
| 114 | useOssThumbnail={useOssThumbnail} |
| 115 | /> |
| 116 | |
| 117 | {/* 视频播放图标 */} |
| 118 | {isVideo && ( |
| 119 | <div className="absolute inset-0 flex items-center justify-center"> |
| 120 | <div className="w-10 h-10 rounded-full bg-black/50 flex items-center justify-center group-hover:bg-black/70 transition-colors"> |
| 121 | <Play className="w-5 h-5 text-white fill-white ml-0.5" /> |
| 122 | </div> |
| 123 | </div> |
| 124 | )} |
| 125 | |
| 126 | {/* 视频时长标签 */} |
| 127 | {isVideo && duration && ( |
| 128 | <div className="absolute bottom-2 right-2 px-1.5 py-0.5 bg-black/70 rounded text-xs text-white"> |
| 129 | {formatDuration(duration)} |
| 130 | </div> |
| 131 | )} |
| 132 | |
| 133 | {/* 悬浮遮罩 */} |
| 134 | {!batchMode && ( |
| 135 | <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors rounded-xl" /> |
| 136 | )} |
| 137 | |
| 138 | {/* 选中遮罩 */} |
| 139 | {batchMode && selected && ( |
| 140 | <div className="absolute inset-0 bg-primary/15 pointer-events-none rounded-xl" /> |
| 141 | )} |
| 142 | </div> |
| 143 | |
| 144 | {/* 标题 */} |
| 145 | {media.title && ( |
| 146 | <div className="pt-2 px-1"> |
| 147 | <p className="text-sm font-medium text-foreground line-clamp-2"> |
| 148 | {media.title} |
| 149 | </p> |
| 150 | </div> |
| 151 | )} |
| 152 | </div> |
| 153 | ) |
| 154 | |
| 155 | if (!canPublishDrag) |
| 156 | return cardNode |
| 157 | |
| 158 | return ( |
| 159 | <PublishDialogDragSource dragItem={{ kind: 'media', media }}> |
| 160 | {cardNode} |
| 161 | </PublishDialogDragSource> |
| 162 | ) |
| 163 | }) |
| 164 | |
| 165 | MediaCard.displayName = 'MediaCard' |
| 166 |