返回 AiToEarn
material.api.ts
根目录 / project / aitoearn-web / src / api / materials / material.api.ts
1 import type { ConfirmUploadData, CreateMaterialGroupParams, CreateMaterialGroupVo, CreateMaterialParams, GetMaterialGroupBySceneParams, MaterialFilterDeleteParams, MaterialGroupBySceneData, MaterialGroupListFilters, MaterialGroupListVo, MaterialGroupSceneVo, MaterialListFilters, MaterialListQueryParams, MaterialListVo, MaterialOpenApiResponse, MediaListFilters, MediaListResponse, OptimalMaterialVo, PromotionMaterial, ThumbnailVo, TransferMaterialParams, TransferMediaParams, TransferMediaResult, UpdateMaterialGroupParams, UpdateMaterialParams, UploadSignData, UploadToOssOptions } from './material.types'
2 import type { PlatType } from '@/app/config/platConfig'
3 import md5 from 'blueimp-md5'
4 import { useUserStore } from '@/store/user'
5 import { optimizeImageForUpload } from '@/utils/media'
6 import http, { request } from '@/utils/request'
7 import { AssetType } from './material.constants'
8
9 // Source: assets.ts
10 /**
11 * Get Video Thumbnail
12 * Get or extract thumbnail from a video by URL. If thumbnail already exists in metadata.cover, returns it directly. Otherwise extracts a new thumbnail.
13 */
14 export function getVideoThumbnail(url: string, timeInSeconds = 1) {
15 return http.get<ThumbnailVo>('assets/thumbnail', { url, timeInSeconds })
16 }
17
18 // Source: oss.ts
19 function getOriginalFileName(file: File | Blob) {
20 return 'name' in file && typeof file.name === 'string' && file.name
21 ? file.name
22 : `file_${Date.now()}`
23 }
24
25 function getUploadRequestPath(publicUploadId?: string) {
26 return publicUploadId
27 ? `assets/public/${encodeURIComponent(publicUploadId)}/uploadSign`
28 : 'assets/uploadSign'
29 }
30
31 function getConfirmRequestPath(assetId: string, publicUploadId?: string) {
32 return publicUploadId
33 ? `assets/public/${encodeURIComponent(publicUploadId)}/${assetId}/confirm`
34 : `assets/${assetId}/confirm`
35 }
36
37 function getAssetType(fileName: string, contentType: string) {
38 if (contentType.startsWith('image/') || contentType.startsWith('video/'))
39 return AssetType.UserMedia
40
41 if (contentType.includes('avatar') || fileName.includes('avatar'))
42 return AssetType.Avatar
43
44 return AssetType.UserFile
45 }
46
47 function getUploadFileName(file: File | Blob, publicUploadId?: string) {
48 const originalFileName = getOriginalFileName(file)
49
50 const hasChinese = /[\u4E00-\u9FA5]/.test(originalFileName)
51
52 const fileExtension = originalFileName.includes('.')
53 ? originalFileName.substring(originalFileName.lastIndexOf('.'))
54 : ''
55 const processedFileName = hasChinese
56 ? md5(originalFileName.replace(fileExtension, '')) + fileExtension
57 : originalFileName
58 const ownerId = publicUploadId || useUserStore.getState().userInfo?.id
59 const hashedPrefix = md5(new Date().getTime().toString())
60
61 return ownerId
62 ? `${ownerId}/${hashedPrefix}${processedFileName}`
63 : `${hashedPrefix}${processedFileName}`
64 }
65
66 // 获取 R2 presigned post 数据
67 async function getPresignedPostData(fileName: string, fileSize: number, contentType: string, publicUploadId?: string) {
68 const res = await request<UploadSignData>({
69 url: getUploadRequestPath(publicUploadId),
70 method: 'POST',
71 data: {
72 filename: fileName,
73 size: fileSize,
74 type: getAssetType(fileName, contentType),
75 },
76 })
77
78 if (!res || res.code !== 0)
79 throw new Error(res?.message || '获取上传签名失败')
80
81 return res.data
82 }
83
84 async function confirmUpload(assetId: string, fallbackUrl: string, publicUploadId?: string) {
85 const confirmResponse = await request<ConfirmUploadData>({
86 url: getConfirmRequestPath(assetId, publicUploadId),
87 method: 'POST',
88 data: {
89 id: assetId,
90 },
91 })
92
93 if (!confirmResponse || confirmResponse.code !== 0)
94 throw new Error(confirmResponse?.message || '上传确认失败')
95
96 return confirmResponse.data?.url || fallbackUrl
97 }
98
99 /**
100 * 上传文件到OSS (前端直传 AWS S3)
101 */
102 export async function uploadToOss(
103 file: File | Blob,
104 options?: UploadToOssOptions | ((prog: number) => void),
105 ): Promise<string> {
106 try {
107 const opts: UploadToOssOptions
108 = typeof options === 'function' ? { onProgress: options } : (options ?? {})
109
110 if (opts.signal?.aborted) {
111 throw new DOMException('上传已取消', 'AbortError')
112 }
113
114 const uploadFile = await optimizeImageForUpload(file, { signal: opts.signal })
115
116 const fileName = getUploadFileName(uploadFile, opts.publicUploadId)
117
118 const fileSize = uploadFile.size
119 const contentType = uploadFile.type || 'application/octet-stream'
120
121 // 获取 presigned post 数据
122 const presignedData = await getPresignedPostData(fileName, fileSize, contentType, opts.publicUploadId)
123
124 // R2 使用 PUT 请求直接上传到 uploadUrl,不需要 FormData
125 const uploadUrl = presignedData.uploadUrl
126
127 // 直传文件到 AWS S3 (支持进度回调)
128 if (opts.onProgress) {
129 return new Promise<string>((resolve, reject) => {
130 const xhr = new XMLHttpRequest()
131
132 // 监听上传进度
133 xhr.upload.addEventListener('progress', (event) => {
134 if (event.lengthComputable) {
135 const progress = Math.round((event.loaded / event.total) * 100)
136 opts.onProgress?.(progress)
137 }
138 })
139
140 const handleAbort = () => {
141 xhr.abort()
142 reject(new DOMException('上传失败: 用户取消', 'AbortError'))
143 }
144
145 opts.signal?.addEventListener('abort', handleAbort, { once: true })
146
147 // 监听上传完成
148 xhr.addEventListener('load', async () => {
149 opts.signal?.removeEventListener('abort', handleAbort)
150 if (xhr.status >= 200 && xhr.status < 300) {
151 try {
152 // 返回确认接口返回的最终访问URL
153 resolve(await confirmUpload(presignedData.id, presignedData.url, opts.publicUploadId))
154 }
155 catch (confirmError) {
156 console.error('确认上传失败:', confirmError)
157 reject(new Error('上传确认失败'))
158 }
159 }
160 else {
161 reject(new Error(`上传失败: ${xhr.statusText}`))
162 }
163 })
164
165 // 监听上传错误
166 xhr.addEventListener('error', () => {
167 opts.signal?.removeEventListener('abort', handleAbort)
168 reject(new Error('上传失败: 网络错误'))
169 })
170
171 // 开始上传 (R2 使用 PUT 请求)
172 xhr.open('PUT', uploadUrl)
173 xhr.setRequestHeader('Content-Type', contentType)
174 xhr.send(uploadFile)
175 })
176 }
177 else {
178 // 不使用进度回调的简单版本 (R2 使用 PUT 请求)
179
180 const uploadResponse = await fetch(uploadUrl, {
181 method: 'PUT',
182 body: uploadFile,
183 headers: {
184 'Content-Type': contentType,
185 },
186 signal: opts.signal,
187 })
188
189 if (!uploadResponse.ok) {
190 throw new Error(`上传失败: ${uploadResponse.statusText}`)
191 }
192
193 // 返回确认接口返回的最终访问URL
194 return confirmUpload(presignedData.id, presignedData.url, opts.publicUploadId)
195 }
196 }
197 catch (error) {
198 console.error('上传文件失败:', error)
199 throw error
200 }
201 }
202
203 /**
204 * 批量删除媒体资源
205 * 根据ID列表批量删除媒体资源。
206 */
207 export function batchDeleteMedia(ids: string[]) {
208 return http.delete('media/ids', { ids })
209 }
210
211 /**
212 * 媒体资源转移到其他分组
213 * 将媒体资源移动或复制到目标媒体分组。move 模式直接移动,copy 模式复制并重置使用次数。
214 */
215 export function transferMedia(data: TransferMediaParams) {
216 return http.post<TransferMediaResult>('media/transfer', data)
217 }
218
219 /**
220 * 获取媒体资源列表
221 * 分页获取媒体资源列表。
222 */
223 export function getMediaList(
224 filter: MediaListFilters,
225 pageNo: number,
226 pageSize: number,
227 type?: 'video' | 'img',
228 ) {
229 return http.get<MediaListResponse>(`media/list/${pageNo}/${pageSize}`, {
230 ...filter,
231 ...(type ? { type } : {}),
232 })
233 }
234
235 // Source: material.ts
236 /**
237 * 创建草稿分组
238 * 使用提供的元数据创建新的草稿分组。
239 */
240 export function apiCreateMaterialGroup(data: CreateMaterialGroupParams) {
241 return http.post<CreateMaterialGroupVo>('material/group', {
242 ...data,
243 type: 'video',
244 })
245 }
246
247 /**
248 * 删除草稿分组
249 * 根据ID删除草稿分组。
250 */
251 export function apiDeleteMaterialGroup(id: string) {
252 return http.delete(`material/group/${id}`)
253 }
254
255 /**
256 * 更新草稿分组信息
257 * 更新草稿分组的详情。
258 */
259 export function apiUpdateMaterialGroupInfo(
260 id: string,
261 data: UpdateMaterialGroupParams,
262 ) {
263 return http.post(`material/group/info/${id}`, data)
264 }
265
266 /**
267 * 获取草稿分组列表
268 * 分页获取草稿分组列表,包含关联的店铺信息。
269 */
270 export function apiGetMaterialGroupList(pageNo: number, pageSize: number, filters?: MaterialGroupListFilters) {
271 return http.get<MaterialGroupListVo>(`material/group/list/${pageNo}/${pageSize}`, filters)
272 }
273
274 /**
275 * 获取草稿分组详情
276 * 根据ID获取草稿分组详情。
277 */
278 export function apiGetMaterialInfo(id: string) {
279 return http.get(`material/group/info/${id}`)
280 }
281
282 /**
283 * 创建草稿
284 * 使用提供的媒体和元数据创建草稿。
285 */
286 export function apiCreateMaterial(
287 data: CreateMaterialParams,
288 silent?: boolean,
289 ) {
290 return http.post('material', data, silent)
291 }
292
293 /**
294 * 删除草稿
295 * 根据ID删除草稿。
296 */
297 export function apiDeleteMaterial(id: string) {
298 return http.delete(`material/${id}`)
299 }
300
301 /**
302 * 批量删除草稿
303 * 根据ID列表批量删除草稿。
304 */
305 export function apiBatchDeleteMaterials(ids: string[]) {
306 return http.delete('material/list', { ids })
307 }
308
309 /**
310 * 草稿转移到其他草稿箱
311 * 将草稿移动或复制到目标草稿箱。move 模式直接移动,copy 模式复制并重置使用次数。
312 */
313 export function apiTransferMaterials(data: TransferMaterialParams) {
314 return http.post<TransferMediaResult>('material/transfer', data)
315 }
316
317 /**
318 * 按条件删除草稿
319 * 删除符合筛选条件的草稿。
320 */
321 export function apiFilterDeleteMaterials(data: MaterialFilterDeleteParams) {
322 return http.delete('material/filter', data)
323 }
324
325 /**
326 * 获取草稿列表
327 * 分页获取草稿列表,支持筛选条件。
328 */
329 export async function apiGetMaterialList(groupId: string, pageNo: number, pageSize: number, filters?: MaterialListFilters) {
330 const params: MaterialListQueryParams = { groupId }
331 if (filters?.title)
332 params.title = filters.title
333 if (filters?.useCount !== undefined)
334 params.useCount = filters.useCount
335 const res = await http.get<MaterialListVo>(`material/list/${pageNo}/${pageSize}`, params)
336
337 const list = res?.data?.list
338 // 兼容代码,图文草稿补封面
339 if (list && list.length > 0) {
340 list.map((item) => {
341 if (item.mediaList[0].type === 'img') {
342 item.coverUrl = item.mediaList[0].url
343 }
344 })
345 }
346 return res
347 }
348
349 /**
350 * 获取草稿详情
351 * 根据ID获取草稿详情。
352 */
353 export function apiGetDraftInfo(id: string) {
354 return http.get<PromotionMaterial>(`material/info/${id}`)
355 }
356
357 /**
358 * 更新草稿信息
359 * 根据ID更新草稿详情。
360 */
361 export function apiUpdateMaterial(
362 id: string,
363 data: UpdateMaterialParams,
364 ) {
365 return http.put(`material/info/${id}`, data)
366 }
367
368 /**
369 * 通过素材组ID获取最优素材(公开接口,无需认证)
370 * @param groupId 素材组ID(即推广码)
371 * @param accountType 平台类型,用于筛选匹配的素材
372 */
373 export function apiGetOptimalMaterial(groupId: string, accountType: PlatType) {
374 return http.get<OptimalMaterialVo>('/material/optimal', { groupId, accountType })
375 }
376
377 /**
378 * 按使用场景查询素材组
379 * 公开接口,用于按使用场景与关联 ID 解析第一个素材组。
380 */
381 export async function apiGetMaterialGroupByScene(
382 params: GetMaterialGroupBySceneParams,
383 silent?: boolean,
384 ): Promise<MaterialOpenApiResponse<MaterialGroupSceneVo | null>> {
385 const res = await http.get<MaterialGroupBySceneData>('/material/group/by-scene', params, silent)
386 if (!res)
387 return res
388
389 return {
390 ...res,
391 data: Array.isArray(res.data) ? res.data[0] ?? null : res.data,
392 }
393 }
394
394 lines TYPESCRIPT