返回 AiToEarn
index.tsx
1 /**
2 * MediaUpload - 媒体上传预览组件
3 * 功能:图片/视频上传预览,支持上传进度显示,支持图片编辑
4 */
5
6 'use client'
7
8 import { FileText, Loader2, Music, Pencil, Plus, X } from 'lucide-react'
9 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
10 import { useTransClient } from '@/app/i18n/client'
11 import { MediaPreview } from '@/components/common/MediaPreview'
12 import { BrushEditor } from '@/components/common/MediaPreview/BrushEditor'
13 import { cn } from '@/utils/className'
14 import { getOssUrl } from '@/utils/oss'
15
16 /** 上传的媒体文件类型 */
17 export interface IUploadedMedia {
18 /** 唯一标识(用于进度、删除等场景的精确匹配) */
19 id?: string
20 url: string
21 type: 'image' | 'video' | 'audio' | 'document'
22 /** 上传进度 0-100,undefined 表示上传完成 */
23 progress?: number
24 /** 本地文件对象,用于预览 */
25 file?: File
26 /** 文件名称(document/audio 类型使用) */
27 name?: string
28 /** 缓存控制(可选) */
29 cache_control?: {
30 type: 'ephemeral'
31 }
32 }
33
34 function formatFileSize(bytes?: number) {
35 if (!bytes || bytes <= 0)
36 return ''
37 if (bytes < 1024)
38 return `${bytes} B`
39 if (bytes < 1024 * 1024)
40 return `${(bytes / 1024).toFixed(1)} KB`
41 if (bytes < 1024 * 1024 * 1024)
42 return `${(bytes / 1024 / 1024).toFixed(2)} MB`
43 return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
44 }
45
46 export interface IMediaUploadProps {
47 /** 已上传的媒体列表 */
48 medias: IUploadedMedia[]
49 /** 是否正在上传 */
50 isUploading?: boolean
51 /** 是否禁用 */
52 disabled?: boolean
53 /** 文件变更回调 */
54 onFilesChange?: (files: FileList) => void
55 /** 移除媒体回调 */
56 onRemove?: (index: number) => void
57 /** 最大上传数量 */
58 maxCount?: number
59 /** 是否显示媒体列表(只显示上传按钮时可关闭) */
60 showList?: boolean
61 /** 是否显示上传按钮(只做预览时可关闭) */
62 showUploadButton?: boolean
63 /** 上传按钮样式:网格方块(默认)或小圆形图标按钮 */
64 buttonVariant?: 'grid' | 'icon'
65 /** 自定义类名 */
66 className?: string
67 /** 是否启用编辑功能 */
68 editable?: boolean
69 /** 媒体更新回调(编辑后替换) */
70 onMediaUpdate?: (index: number, newUrl: string) => void
71 /** 文件选择过滤 */
72 accept?: string
73 }
74
75 /**
76 * MediaUpload - 媒体上传预览组件
77 */
78 export function MediaUpload({
79 medias,
80 isUploading = false,
81 disabled = false,
82 onFilesChange,
83 onRemove,
84 maxCount = 5,
85 showList = true,
86 showUploadButton = true,
87 buttonVariant = 'grid',
88 className,
89 editable = true,
90 onMediaUpdate,
91 accept = '*/*',
92 }: IMediaUploadProps) {
93 const { t } = useTransClient('chat')
94 const fileInputRef = useRef<HTMLInputElement>(null)
95 const [previewIndex, setPreviewIndex] = useState<number | null>(null)
96 const [editingIndex, setEditingIndex] = useState<number | null>(null)
97
98 /** 缓存的 ObjectURL 集合 */
99 const previewUrls = useMemo(() => {
100 return medias.map((media) => {
101 if (media.file) {
102 return URL.createObjectURL(media.file)
103 }
104 return getOssUrl(media.url)
105 })
106 }, [medias])
107
108 /** 清理 ObjectURL,防止内存泄漏 */
109 useEffect(() => {
110 return () => {
111 previewUrls.forEach((url) => {
112 if (url.startsWith('blob:')) {
113 URL.revokeObjectURL(url)
114 }
115 })
116 }
117 }, [previewUrls])
118
119 /** 处理文件选择 */
120 const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
121 const files = e.target.files
122 if (files && files.length > 0) {
123 const remaining = Math.max(0, maxCount - medias.length)
124 if (remaining <= 0) {
125 // 已达到最大数量,直接忽略本次选择
126 }
127 else if (files.length > remaining) {
128 // 只取前 remaining 个文件
129 const dt = new DataTransfer()
130 for (let i = 0; i < remaining; i++) {
131 dt.items.add(files[i])
132 }
133 onFilesChange?.(dt.files)
134 }
135 else {
136 onFilesChange?.(files)
137 }
138 }
139 // 重置 input,允许重复选择同一文件
140 if (fileInputRef.current) {
141 fileInputRef.current.value = ''
142 }
143 }
144
145 /** 触发文件选择 */
146 const handleUploadClick = () => {
147 if (!disabled && !isUploading) {
148 fileInputRef.current?.click()
149 }
150 }
151
152 const canUploadMore = medias.length < maxCount
153
154 /** 打开预览 */
155 const handlePreview = (index: number) => {
156 const media = medias[index]
157 if (!media)
158 return
159 if (media.type === 'document' || media.type === 'audio')
160 return
161 setPreviewIndex(index)
162 }
163
164 const handleClosePreview = () => {
165 setPreviewIndex(null)
166 }
167
168 /** 打开编辑器(从缩略图) */
169 const handleOpenEditor = useCallback(
170 (index: number) => {
171 const media = medias[index]
172 if (!media || media.type !== 'image')
173 return
174 setEditingIndex(index)
175 },
176 [medias],
177 )
178
179 /** 编辑保存回调 */
180 const handleEditorSave = useCallback(
181 (newUrl: string) => {
182 if (editingIndex !== null) {
183 onMediaUpdate?.(editingIndex, newUrl)
184 }
185 setEditingIndex(null)
186 },
187 [editingIndex, onMediaUpdate],
188 )
189
190 /** MediaPreview 中编辑保存回调 */
191 const handlePreviewEdit = useCallback(
192 (index: number, newUrl: string) => {
193 onMediaUpdate?.(index, newUrl)
194 },
195 [onMediaUpdate],
196 )
197
198 return (
199 <div
200 className={cn('flex items-center gap-2', buttonVariant === 'grid' && 'flex-wrap', className)}
201 >
202 {/* 已上传的媒体列表 */}
203 {showList
204 && medias.map((media, index) => (
205 <div
206 key={`${media.url}-${index}`}
207 className={cn(
208 'relative group rounded-lg overflow-hidden border border-border bg-muted',
209 media.type === 'document' || media.type === 'video' || media.type === 'audio'
210 ? 'min-w-[220px] h-14'
211 : 'w-14 h-14',
212 )}
213 >
214 {/* 媒体预览 */}
215 {media.type === 'document' || media.type === 'audio' ? (
216 <div className="flex items-center gap-3 h-full px-3 bg-background">
217 <div className="flex items-center justify-center w-7 h-7 rounded-md bg-primary/10 text-primary">
218 {media.type === 'audio' ? <Music className="w-4 h-4" /> : <FileText className="w-4 h-4" />}
219 </div>
220 <div className="flex flex-col justify-center min-w-0">
221 <span className="text-xs font-medium text-foreground truncate max-w-[160px]">
222 {media.name || media.file?.name || (media.type === 'audio' ? t('media.audio') : t('media.document'))}
223 </span>
224 {media.file && (
225 <span className="text-[11px] text-muted-foreground mt-0.5">
226 {formatFileSize(media.file.size)}
227 </span>
228 )}
229 </div>
230 </div>
231 ) : media.type === 'video' ? (
232 <button
233 type="button"
234 onClick={() => handlePreview(index)}
235 className="flex items-center gap-3 h-full w-full px-3 bg-background text-left cursor-pointer"
236 >
237 <div className="flex items-center justify-center w-7 h-7 rounded-md bg-blue-500/10 text-blue-500">
238 <span className="text-[11px] font-semibold">VID</span>
239 </div>
240 <div className="flex flex-col justify-center min-w-0">
241 <span className="text-xs font-medium text-foreground truncate max-w-[160px]">
242 {media.name || media.file?.name || 'video'}
243 </span>
244 {media.file && (
245 <span className="text-[11px] text-muted-foreground mt-0.5">
246 {formatFileSize(media.file.size)}
247 </span>
248 )}
249 </div>
250 </button>
251 ) : (
252 <img
253 src={previewUrls[index]}
254 alt={`media-${index}`}
255 className="w-full h-full object-cover cursor-pointer"
256 onClick={() => handlePreview(index)}
257 />
258 )}
259
260 {/* 编辑按钮(仅图片类型且启用编辑时显示) */}
261 {!disabled
262 && editable
263 && onMediaUpdate
264 && media.type === 'image'
265 && media.progress === undefined && (
266 <button
267 onClick={(e) => {
268 e.stopPropagation()
269 handleOpenEditor(index)
270 }}
271 className={cn(
272 'absolute bottom-1 right-1 z-10 w-4 h-4 rounded-full flex items-center justify-center cursor-pointer',
273 'bg-background/95 text-muted-foreground shadow-sm border border-border/70',
274 'hover:border-transparent hover:bg-gradient-back hover:text-gradient-foreground hover:shadow-primary/20 transition-colors',
275 )}
276 aria-label={t('media.edit')}
277 type="button"
278 >
279 <Pencil className="w-2.5 h-2.5" />
280 </button>
281 )}
282
283 {/* 上传进度遮罩:仅在进度未完成时显示,由 Hook 负责在完成时清理 progress */}
284 {media.progress !== undefined && media.progress < 100 && (
285 <div className="absolute inset-0 flex items-center justify-center bg-black/45">
286 {media.type === 'image' ? (
287 <Loader2 className="w-6 h-6 text-white animate-spin" />
288 ) : (
289 <div className="relative w-8 h-8">
290 <svg className="w-8 h-8 -rotate-90">
291 <circle
292 cx="16"
293 cy="16"
294 r="12"
295 stroke="rgba(255,255,255,0.3)"
296 strokeWidth="3"
297 fill="none"
298 />
299 <circle
300 cx="16"
301 cy="16"
302 r="12"
303 stroke="white"
304 strokeWidth="3"
305 fill="none"
306 strokeDasharray={`${(media.progress || 0) * 0.75} 75`}
307 strokeLinecap="round"
308 />
309 </svg>
310 <span className="absolute inset-0 flex items-center justify-center text-white text-[10px] font-medium">
311 {media.progress}
312 %
313 </span>
314 </div>
315 )}
316 </div>
317 )}
318
319 {/* 删除按钮(上传中和上传完成都可关闭) */}
320 {!disabled && (
321 <button
322 onClick={() => onRemove?.(index)}
323 className={cn(
324 'absolute top-1 right-1 z-10 w-4 h-4 rounded-full flex items-center justify-center cursor-pointer',
325 'bg-background/95 text-muted-foreground shadow-sm border border-border/70',
326 'hover:bg-destructive hover:text-destructive-foreground hover:border-destructive transition-colors',
327 )}
328 aria-label={t('media.remove')}
329 type="button"
330 >
331 <X className="w-3 h-3" />
332 </button>
333 )}
334 </div>
335 ))}
336
337 {/* 上传按钮 */}
338 {showUploadButton && canUploadMore && (
339 <>
340 <input
341 ref={fileInputRef}
342 type="file"
343 accept={accept}
344 multiple
345 onChange={handleFileChange}
346 className="hidden"
347 />
348 <button
349 onClick={handleUploadClick}
350 disabled={buttonVariant === 'grid' ? disabled || isUploading : disabled}
351 className={cn(
352 buttonVariant === 'grid'
353 ? cn(
354 'w-14 h-14 rounded-lg border-2 border-dashed border-border flex items-center justify-center transition-all cursor-pointer',
355 'hover:border-primary hover:bg-primary/10',
356 'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:bg-transparent',
357 )
358 : cn(
359 // 小圆形图标按钮:更精致的聊天操作样式
360 'w-8 h-8 rounded-full flex items-center justify-center text-muted-foreground transition-all cursor-pointer',
361 'bg-background border border-border/80 hover:bg-primary/5 hover:border-primary/60',
362 'shadow-sm hover:shadow disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-background disabled:hover:border-border/80',
363 ),
364 )}
365 aria-label="Upload media"
366 type="button"
367 >
368 {buttonVariant === 'grid' && isUploading ? (
369 <Loader2 className="w-5 h-5 text-muted-foreground animate-spin" />
370 ) : (
371 <Plus className="w-5 h-5 text-muted-foreground" />
372 )}
373 </button>
374 </>
375 )}
376
377 {/* 预览大图 / 视频 - 使用全局 MediaPreview 组件 */}
378 <MediaPreview
379 open={previewIndex !== null}
380 items={previewUrls.map((src, idx) => ({
381 type: medias[idx]?.type === 'video' ? 'video' : 'image',
382 src,
383 title: medias[idx]?.name || medias[idx]?.file?.name,
384 }))}
385 initialIndex={previewIndex ?? 0}
386 onClose={handleClosePreview}
387 editable={editable}
388 onEdit={handlePreviewEdit}
389 />
390
391 {/* 缩略图编辑器 */}
392 {editable && editingIndex !== null && medias[editingIndex] && (
393 <BrushEditor
394 open={true}
395 imageUrl={previewUrls[editingIndex]}
396 onClose={() => setEditingIndex(null)}
397 onSave={handleEditorSave}
398 />
399 )}
400 </div>
401 )
402 }
403
404 export default MediaUpload
405
405 lines Plain Text