返回 AiToEarn
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
166 lines Plain Text