362 lines
8.6 KiB
TypeScript
362 lines
8.6 KiB
TypeScript
import { AxiosRequestConfig } from 'axios'
|
|
import { httpClient } from './request'
|
|
import { UploadConfig, ApiResponse } from '../types'
|
|
import { API_CONFIG, API_ENDPOINTS } from '../config'
|
|
import {
|
|
validateFileType,
|
|
formatFileSize,
|
|
compressImage,
|
|
readFileAsBase64,
|
|
generateId
|
|
} from '../utils'
|
|
|
|
/**
|
|
* 文件上传响应类型
|
|
*/
|
|
export interface UploadResponse {
|
|
fileId: string
|
|
filename: string
|
|
url: string
|
|
size: number
|
|
type: string
|
|
uploadTime: string
|
|
}
|
|
|
|
/**
|
|
* 批量上传响应类型
|
|
*/
|
|
export interface BatchUploadResponse {
|
|
success: UploadResponse[]
|
|
failed: Array<{
|
|
file: File
|
|
error: string
|
|
}>
|
|
total: number
|
|
successCount: number
|
|
failedCount: number
|
|
}
|
|
|
|
/**
|
|
* 文件上传类
|
|
*/
|
|
export class FileUploader {
|
|
private uploadQueue: Map<string, AbortController> = new Map()
|
|
|
|
/**
|
|
* 单文件上传
|
|
*/
|
|
public async uploadFile(config: UploadConfig): Promise<ApiResponse<UploadResponse>> {
|
|
const {
|
|
file,
|
|
filename = file instanceof File ? file.name : 'blob',
|
|
fieldName = 'file',
|
|
data = {},
|
|
onProgress
|
|
} = config
|
|
|
|
// 创建FormData
|
|
const formData = new FormData()
|
|
formData.append(fieldName, file, filename)
|
|
|
|
// 添加额外数据
|
|
Object.keys(data).forEach(key => {
|
|
formData.append(key, data[key])
|
|
})
|
|
|
|
// 生成上传ID
|
|
const uploadId = generateId()
|
|
const controller = new AbortController()
|
|
this.uploadQueue.set(uploadId, controller)
|
|
|
|
try {
|
|
const response = await httpClient.request<UploadResponse>({
|
|
url: API_ENDPOINTS.FILE.UPLOAD,
|
|
method: 'POST',
|
|
data: formData,
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
timeout: 0, // 上传不设置超时
|
|
responseType: 'json',
|
|
})
|
|
|
|
return response
|
|
} finally {
|
|
this.uploadQueue.delete(uploadId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 图片上传(带压缩)
|
|
*/
|
|
public async uploadImage(
|
|
file: File,
|
|
options: {
|
|
compress?: boolean
|
|
quality?: number
|
|
maxWidth?: number
|
|
maxHeight?: number
|
|
allowedTypes?: string[]
|
|
maxSize?: number
|
|
onProgress?: (progress: number) => void
|
|
} = {}
|
|
): Promise<ApiResponse<UploadResponse>> {
|
|
const {
|
|
compress = true,
|
|
quality = 0.8,
|
|
maxWidth = 1920,
|
|
maxHeight = 1080,
|
|
allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'],
|
|
maxSize = 10 * 1024 * 1024, // 10MB
|
|
onProgress
|
|
} = options
|
|
|
|
// 验证文件类型
|
|
if (!validateFileType(file, allowedTypes)) {
|
|
throw new Error(`不支持的文件类型,仅支持: ${allowedTypes.join(', ')}`)
|
|
}
|
|
|
|
// 验证文件大小
|
|
if (file.size > maxSize) {
|
|
throw new Error(`文件大小超出限制,最大支持: ${formatFileSize(maxSize)}`)
|
|
}
|
|
|
|
let uploadFile = file
|
|
|
|
// 压缩图片
|
|
if (compress && file.type.startsWith('image/')) {
|
|
try {
|
|
const compressedBlob = await compressImage(file, quality, maxWidth, maxHeight)
|
|
uploadFile = new File([compressedBlob], file.name, { type: file.type })
|
|
} catch (error) {
|
|
console.warn('图片压缩失败,使用原文件上传:', error)
|
|
}
|
|
}
|
|
|
|
return this.uploadFile({
|
|
file: uploadFile,
|
|
onProgress
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Base64上传
|
|
*/
|
|
public async uploadBase64(
|
|
file: File,
|
|
options: {
|
|
onProgress?: (progress: number) => void
|
|
} = {}
|
|
): Promise<ApiResponse<UploadResponse>> {
|
|
const { onProgress } = options
|
|
|
|
try {
|
|
const base64 = await readFileAsBase64(file)
|
|
|
|
const response = await httpClient.post<UploadResponse>(
|
|
API_ENDPOINTS.FILE.UPLOAD,
|
|
{
|
|
filename: file.name,
|
|
content: base64,
|
|
type: file.type,
|
|
size: file.size,
|
|
encoding: 'base64'
|
|
}
|
|
)
|
|
|
|
return response
|
|
} catch (error) {
|
|
throw new Error(`Base64上传失败: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 分片上传(大文件)
|
|
*/
|
|
public async uploadLargeFile(
|
|
file: File,
|
|
options: {
|
|
chunkSize?: number
|
|
onProgress?: (progress: number) => void
|
|
onChunkProgress?: (chunkIndex: number, chunkProgress: number) => void
|
|
} = {}
|
|
): Promise<ApiResponse<UploadResponse>> {
|
|
const {
|
|
chunkSize = 2 * 1024 * 1024, // 2MB per chunk
|
|
onProgress,
|
|
onChunkProgress
|
|
} = options
|
|
|
|
const totalChunks = Math.ceil(file.size / chunkSize)
|
|
const uploadId = generateId()
|
|
const uploadedChunks: string[] = []
|
|
|
|
try {
|
|
// 初始化分片上传
|
|
const initResponse = await httpClient.post('/file/upload/init', {
|
|
filename: file.name,
|
|
fileSize: file.size,
|
|
totalChunks,
|
|
uploadId
|
|
})
|
|
|
|
if (!initResponse.success) {
|
|
throw new Error(initResponse.message)
|
|
}
|
|
|
|
// 上传分片
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
const start = i * chunkSize
|
|
const end = Math.min(start + chunkSize, file.size)
|
|
const chunk = file.slice(start, end)
|
|
|
|
const formData = new FormData()
|
|
formData.append('chunk', chunk)
|
|
formData.append('chunkIndex', i.toString())
|
|
formData.append('uploadId', uploadId)
|
|
|
|
const chunkResponse = await httpClient.post('/file/upload/chunk', formData, {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
}
|
|
})
|
|
|
|
if (chunkResponse.success) {
|
|
uploadedChunks.push(chunkResponse.data.chunkId)
|
|
|
|
// 分片进度回调
|
|
onChunkProgress?.(i, 100)
|
|
|
|
// 总进度回调
|
|
const progress = ((i + 1) / totalChunks) * 100
|
|
onProgress?.(progress)
|
|
} else {
|
|
throw new Error(`分片 ${i} 上传失败: ${chunkResponse.message}`)
|
|
}
|
|
}
|
|
|
|
// 合并分片
|
|
const mergeResponse = await httpClient.post<UploadResponse>('/file/upload/merge', {
|
|
uploadId,
|
|
filename: file.name,
|
|
chunks: uploadedChunks
|
|
})
|
|
|
|
return mergeResponse
|
|
} catch (error) {
|
|
// 清理失败的上传
|
|
try {
|
|
await httpClient.post('/file/upload/cleanup', { uploadId })
|
|
} catch (cleanupError) {
|
|
console.warn('清理上传失败:', cleanupError)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 批量上传
|
|
*/
|
|
public async uploadBatch(
|
|
files: File[],
|
|
options: {
|
|
concurrent?: number
|
|
onProgress?: (progress: number) => void
|
|
onFileProgress?: (file: File, progress: number) => void
|
|
onFileComplete?: (file: File, result: UploadResponse | Error) => void
|
|
} = {}
|
|
): Promise<BatchUploadResponse> {
|
|
const {
|
|
concurrent = 3,
|
|
onProgress,
|
|
onFileProgress,
|
|
onFileComplete
|
|
} = options
|
|
|
|
const results: BatchUploadResponse = {
|
|
success: [],
|
|
failed: [],
|
|
total: files.length,
|
|
successCount: 0,
|
|
failedCount: 0
|
|
}
|
|
|
|
const uploadFile = async (file: File): Promise<void> => {
|
|
try {
|
|
const response = await this.uploadFile({
|
|
file,
|
|
onProgress: (progress) => onFileProgress?.(file, progress)
|
|
})
|
|
|
|
if (response.success) {
|
|
results.success.push(response.data)
|
|
results.successCount++
|
|
} else {
|
|
results.failed.push({
|
|
file,
|
|
error: response.message
|
|
})
|
|
results.failedCount++
|
|
}
|
|
|
|
onFileComplete?.(file, response.data)
|
|
} catch (error) {
|
|
results.failed.push({
|
|
file,
|
|
error: error instanceof Error ? error.message : '上传失败'
|
|
})
|
|
results.failedCount++
|
|
onFileComplete?.(file, error as Error)
|
|
}
|
|
|
|
// 更新总进度
|
|
const completed = results.successCount + results.failedCount
|
|
const progress = (completed / results.total) * 100
|
|
onProgress?.(progress)
|
|
}
|
|
|
|
// 并发上传
|
|
const chunks = []
|
|
for (let i = 0; i < files.length; i += concurrent) {
|
|
chunks.push(files.slice(i, i + concurrent))
|
|
}
|
|
|
|
for (const chunk of chunks) {
|
|
await Promise.all(chunk.map(uploadFile))
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
/**
|
|
* 取消上传
|
|
*/
|
|
public cancelUpload(uploadId: string): void {
|
|
const controller = this.uploadQueue.get(uploadId)
|
|
if (controller) {
|
|
controller.abort()
|
|
this.uploadQueue.delete(uploadId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 取消所有上传
|
|
*/
|
|
public cancelAllUploads(): void {
|
|
this.uploadQueue.forEach((controller) => {
|
|
controller.abort()
|
|
})
|
|
this.uploadQueue.clear()
|
|
}
|
|
|
|
/**
|
|
* 获取上传进度
|
|
*/
|
|
public getUploadProgress(uploadId: string): number {
|
|
// 这里可以实现获取具体上传进度的逻辑
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// 创建默认实例
|
|
export const fileUploader = new FileUploader()
|