返回 AiToEarn
useMediaUpload.ts
根目录 / project / aitoearn-web / src / hooks / useMediaUpload.ts
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
191 lines TYPESCRIPT