| 1 | /** |
| 2 | * useMediaUpload - 媒体文件上传 Hook |
| 3 | * 功能:封装媒体文件上传逻辑,支持进度显示、中断上传、移除媒体 |
| 4 | * 复用场景:HomeChat、ChatDetailPage 等需要上传媒体的组件 |
| 5 | */ |
| 6 | |
| 7 | import type { IUploadedMedia } from '@/components/Chat/MediaUpload' |
| 8 | import { useCallback, useRef, useState } from 'react' |
| 9 | import { uploadToOss } from '@/api/materials/material.api' |
| 10 | |
| 11 | export interface IUseMediaUploadOptions { |
| 12 | /** 上传失败时的回调 */ |
| 13 | onError?: (error: Error) => void |
| 14 | } |
| 15 | |
| 16 | export interface IUseMediaUploadReturn { |
| 17 | /** 已上传的媒体列表 */ |
| 18 | medias: IUploadedMedia[] |
| 19 | /** 设置媒体列表 */ |
| 20 | setMedias: React.Dispatch<React.SetStateAction<IUploadedMedia[]>> |
| 21 | /** 是否正在上传 */ |
| 22 | isUploading: boolean |
| 23 | /** 处理文件变更(上传) */ |
| 24 | handleMediasChange: (files: FileList) => Promise<void> |
| 25 | /** 移除媒体 */ |
| 26 | handleMediaRemove: (index: number) => void |
| 27 | /** 更新媒体(编辑后替换) */ |
| 28 | handleMediaUpdate: (index: number, newUrl: string) => void |
| 29 | /** 取消上传 */ |
| 30 | cancelUpload: () => void |
| 31 | /** 清空所有媒体 */ |
| 32 | clearMedias: () => void |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * useMediaUpload - 媒体文件上传 Hook |
| 37 | * @param options 配置选项 |
| 38 | * @returns 媒体上传相关的状态和方法 |
| 39 | */ |
| 40 | export function useMediaUpload(options?: IUseMediaUploadOptions): IUseMediaUploadReturn { |
| 41 | const { onError } = options ?? {} |
| 42 | |
| 43 | // 状态 |
| 44 | const [medias, setMedias] = useState<IUploadedMedia[]>([]) |
| 45 | const [isUploading, setIsUploading] = useState(false) |
| 46 | |
| 47 | // AbortController Map:按媒体 id 管理中断 |
| 48 | const uploadAbortRef = useRef<Map<string, AbortController> | null>(null) |
| 49 | |
| 50 | /** |
| 51 | * 处理媒体文件上传 |
| 52 | * @param files 文件列表 |
| 53 | */ |
| 54 | const handleMediasChange = useCallback( |
| 55 | async (files: FileList) => { |
| 56 | if (!files.length) |
| 57 | return |
| 58 | |
| 59 | setIsUploading(true) |
| 60 | if (!uploadAbortRef.current) { |
| 61 | uploadAbortRef.current = new Map() |
| 62 | } |
| 63 | |
| 64 | const fileArray = Array.from(files) |
| 65 | |
| 66 | try { |
| 67 | // 先批量添加占位媒体,记录每个媒体的 id |
| 68 | const tempMedias: IUploadedMedia[] = fileArray.map((file) => { |
| 69 | const id = `${Date.now()}-${Math.random().toString(16).slice(2)}` |
| 70 | const isVideo = file.type.startsWith('video/') |
| 71 | const isDocument = !file.type.startsWith('image/') && !file.type.startsWith('video/') |
| 72 | return { |
| 73 | id, |
| 74 | url: '', |
| 75 | type: isDocument ? 'document' : isVideo ? 'video' : 'image', |
| 76 | progress: 0, |
| 77 | file, |
| 78 | name: isDocument ? file.name : undefined, |
| 79 | } |
| 80 | }) |
| 81 | |
| 82 | setMedias(prev => [...prev, ...tempMedias]) |
| 83 | |
| 84 | // 并行上传所有文件 |
| 85 | await Promise.all( |
| 86 | fileArray.map(async (file, i) => { |
| 87 | const targetId = tempMedias[i]?.id |
| 88 | if (!targetId) |
| 89 | return |
| 90 | |
| 91 | const controller = new AbortController() |
| 92 | uploadAbortRef.current?.set(targetId, controller) |
| 93 | |
| 94 | const fullUrl = await uploadToOss(file, { |
| 95 | onProgress: (progress) => { |
| 96 | // 兼容 0-1 或 0-100 两种进度表示,统一成 0-100 |
| 97 | const percent = progress > 1 ? progress : progress * 100 |
| 98 | setMedias(prev => |
| 99 | prev.map((m, idx) => |
| 100 | m.id && m.id === targetId |
| 101 | ? { ...m, progress: Math.min(99, Math.max(0, percent)) } |
| 102 | : m, |
| 103 | ), |
| 104 | ) |
| 105 | }, |
| 106 | signal: controller.signal, |
| 107 | }) |
| 108 | |
| 109 | setMedias(prev => |
| 110 | prev.map((m, idx) => |
| 111 | m.id && m.id === targetId |
| 112 | ? { ...m, url: fullUrl as string, progress: undefined } |
| 113 | : m, |
| 114 | ), |
| 115 | ) |
| 116 | |
| 117 | uploadAbortRef.current?.delete(targetId) |
| 118 | }), |
| 119 | ) |
| 120 | } |
| 121 | catch (error: any) { |
| 122 | if (error.name !== 'AbortError') { |
| 123 | console.error('Upload failed:', error) |
| 124 | onError?.(error) |
| 125 | } |
| 126 | } |
| 127 | finally { |
| 128 | setIsUploading(false) |
| 129 | uploadAbortRef.current?.clear() |
| 130 | } |
| 131 | }, |
| 132 | [onError], |
| 133 | ) |
| 134 | |
| 135 | /** |
| 136 | * 移除指定索引的媒体 |
| 137 | * @param index 媒体索引 |
| 138 | */ |
| 139 | const handleMediaRemove = useCallback((index: number) => { |
| 140 | setMedias((prev) => { |
| 141 | const target = prev[index] |
| 142 | if (target?.id && uploadAbortRef.current?.has(target.id)) { |
| 143 | uploadAbortRef.current.get(target.id)?.abort() |
| 144 | uploadAbortRef.current.delete(target.id) |
| 145 | } |
| 146 | return prev.filter((_, i) => i !== index) |
| 147 | }) |
| 148 | }, []) |
| 149 | |
| 150 | /** |
| 151 | * 更新指定索引的媒体(编辑后替换 URL) |
| 152 | * @param index 媒体索引 |
| 153 | * @param newUrl 新的媒体 URL |
| 154 | */ |
| 155 | const handleMediaUpdate = useCallback((index: number, newUrl: string) => { |
| 156 | setMedias(prev => |
| 157 | prev.map((m, i) => (i === index ? { ...m, url: newUrl, file: undefined } : m)), |
| 158 | ) |
| 159 | }, []) |
| 160 | |
| 161 | /** |
| 162 | * 取消当前上传 |
| 163 | */ |
| 164 | const cancelUpload = useCallback(() => { |
| 165 | if (uploadAbortRef.current) { |
| 166 | uploadAbortRef.current.forEach(controller => controller.abort()) |
| 167 | uploadAbortRef.current.clear() |
| 168 | } |
| 169 | }, []) |
| 170 | |
| 171 | /** |
| 172 | * 清空所有媒体 |
| 173 | */ |
| 174 | const clearMedias = useCallback(() => { |
| 175 | setMedias([]) |
| 176 | }, []) |
| 177 | |
| 178 | return { |
| 179 | medias, |
| 180 | setMedias, |
| 181 | isUploading, |
| 182 | handleMediasChange, |
| 183 | handleMediaRemove, |
| 184 | handleMediaUpdate, |
| 185 | cancelUpload, |
| 186 | clearMedias, |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | export default useMediaUpload |
| 191 |