| 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 |