feat: 新增 S3预签名上传

This commit is contained in:
Lan
2025-12-31 03:22:55 +08:00
parent 6ddf664b56
commit 88c15c7506
6 changed files with 864 additions and 28 deletions
+137 -10
View File
@@ -1,12 +1,30 @@
import { ref, computed, readonly } from 'vue'
import { FileService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import type { UploadProgress, UploadStatus } from '@/types'
import { usePresignedUpload } from '@/composables/usePresignedUpload'
import type { UploadProgress, UploadStatus, PresignUploadOptions, ExpireStyle } from '@/types'
import { UPLOAD_STATUS, FILE_SIZE_LIMITS } from '@/constants'
export function useFileUpload() {
export interface FileUploadOptions {
/** 是否使用预签名上传,默认 false 保持向后兼容 */
usePresigned?: boolean
/** 过期时间值 */
expireValue?: number
/** 过期时间类型 */
expireStyle?: ExpireStyle
/** 进度回调 */
onProgress?: (progress: UploadProgress) => void
}
export function useFileUpload(options?: { defaultUsePresigned?: boolean }) {
const alertStore = useAlertStore()
// 预签名上传 composable
const presignedUpload = usePresignedUpload()
// 是否默认使用预签名上传
const defaultUsePresigned = options?.defaultUsePresigned ?? false
// 状态管理
const uploadStatus = ref<UploadStatus>(UPLOAD_STATUS.IDLE)
const uploadProgress = ref<UploadProgress>({
@@ -17,11 +35,34 @@ export function useFileUpload() {
const uploadedCode = ref<string>('')
const currentFile = ref<File | null>(null)
// 当前是否使用预签名上传
const isUsingPresigned = ref<boolean>(false)
// 计算属性
const isUploading = computed(() => uploadStatus.value === UPLOAD_STATUS.UPLOADING)
const isSuccess = computed(() => uploadStatus.value === UPLOAD_STATUS.SUCCESS)
const isError = computed(() => uploadStatus.value === UPLOAD_STATUS.ERROR)
const isIdle = computed(() => uploadStatus.value === UPLOAD_STATUS.IDLE)
const isUploading = computed(() => {
if (isUsingPresigned.value) {
return presignedUpload.isUploading.value || presignedUpload.isInitializing.value || presignedUpload.isConfirming.value
}
return uploadStatus.value === UPLOAD_STATUS.UPLOADING
})
const isSuccess = computed(() => {
if (isUsingPresigned.value) {
return presignedUpload.isSuccess.value
}
return uploadStatus.value === UPLOAD_STATUS.SUCCESS
})
const isError = computed(() => {
if (isUsingPresigned.value) {
return presignedUpload.isError.value
}
return uploadStatus.value === UPLOAD_STATUS.ERROR
})
const isIdle = computed(() => {
if (isUsingPresigned.value) {
return presignedUpload.presignStatus.value === 'idle'
}
return uploadStatus.value === UPLOAD_STATUS.IDLE
})
// 文件验证
const validateFile = (file: File): boolean => {
@@ -35,19 +76,43 @@ export function useFileUpload() {
return true
}
// 上传文件
const uploadFile = async (file: File): Promise<string | null> => {
/**
* 上传文件(支持预签名上传和传统上传)
*/
const uploadFile = async (file: File, uploadOptions?: FileUploadOptions): Promise<string | null> => {
const shouldUsePresigned = uploadOptions?.usePresigned ?? defaultUsePresigned
if (!validateFile(file)) {
return null
}
// 记录当前使用的上传方式
isUsingPresigned.value = shouldUsePresigned
currentFile.value = file
if (shouldUsePresigned) {
// 使用预签名上传
return await uploadFileWithPresigned(file, uploadOptions)
} else {
// 使用传统上传方式
return await uploadFileTraditional(file, uploadOptions?.onProgress)
}
}
/**
* 传统上传方式
*/
const uploadFileTraditional = async (
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<string | null> => {
try {
uploadStatus.value = UPLOAD_STATUS.UPLOADING
currentFile.value = file
uploadedCode.value = ''
const response = await FileService.uploadFile(file, (progress) => {
uploadProgress.value = progress
onProgress?.(progress)
})
if (response.code === 200 && response.detail?.code) {
@@ -66,6 +131,37 @@ export function useFileUpload() {
}
}
/**
* 预签名上传方式
*/
const uploadFileWithPresigned = async (
file: File,
uploadOptions?: FileUploadOptions
): Promise<string | null> => {
// 构建预签名上传选项
const presignOptions: PresignUploadOptions = {
expireValue: uploadOptions?.expireValue,
expireStyle: uploadOptions?.expireStyle,
onProgress: (progress) => {
// 同步进度到本 composable 的状态
uploadProgress.value = progress
uploadOptions?.onProgress?.(progress)
}
}
const result = await presignedUpload.uploadFile(file, presignOptions)
if (result) {
// 同步预签名上传的结果到本 composable 的状态
uploadedCode.value = result
uploadStatus.value = UPLOAD_STATUS.SUCCESS
} else {
uploadStatus.value = UPLOAD_STATUS.ERROR
}
return result
}
// 上传文本
const uploadText = async (text: string): Promise<string | null> => {
if (!text.trim()) {
@@ -105,6 +201,22 @@ export function useFileUpload() {
}
uploadedCode.value = ''
currentFile.value = null
// 如果使用预签名上传,也重置预签名状态
if (isUsingPresigned.value) {
presignedUpload.reset()
}
isUsingPresigned.value = false
}
/**
* 取消上传
*/
const cancelUpload = async (): Promise<void> => {
if (isUsingPresigned.value) {
await presignedUpload.cancelUpload()
}
resetUpload()
}
// 格式化文件大小
@@ -122,6 +234,13 @@ export function useFileUpload() {
uploadProgress: readonly(uploadProgress),
uploadedCode: readonly(uploadedCode),
currentFile: readonly(currentFile),
isUsingPresigned: readonly(isUsingPresigned),
// 预签名上传相关状态(透传)
presignStatus: presignedUpload.presignStatus,
uploadSession: presignedUpload.uploadSession,
currentMode: presignedUpload.currentMode,
presignErrorMessage: presignedUpload.errorMessage,
// 计算属性
isUploading,
@@ -129,11 +248,19 @@ export function useFileUpload() {
isError,
isIdle,
// 预签名上传计算属性(透传)
isInitializing: presignedUpload.isInitializing,
isConfirming: presignedUpload.isConfirming,
// 方法
uploadFile,
uploadText,
resetUpload,
cancelUpload,
validateFile,
formatFileSize
formatFileSize,
// 预签名上传方法(透传)
getPresignStatus: presignedUpload.getStatus
}
}
+357
View File
@@ -0,0 +1,357 @@
import { ref, computed, readonly } from 'vue'
import { PresignUploadService } from '@/services'
import { useAlertStore } from '@/stores/alertStore'
import { FILE_SIZE_LIMITS } from '@/constants'
import type {
PresignUploadStatus,
PresignUploadMode,
PresignInitResponse,
PresignUploadOptions,
PresignStatusResponse,
UploadProgress,
ExpireStyle
} from '@/types'
import axios from 'axios'
// 预签名上传状态常量
export const PRESIGN_UPLOAD_STATUS = {
IDLE: 'idle',
INITIALIZING: 'initializing',
UPLOADING: 'uploading',
CONFIRMING: 'confirming',
SUCCESS: 'success',
ERROR: 'error'
} as const
// 默认过期配置
const DEFAULT_EXPIRE_VALUE = 1
const DEFAULT_EXPIRE_STYLE: ExpireStyle = 'day'
/**
* 预签名上传 Composable
* 支持 S3 直传模式和服务器代理模式
*/
export function usePresignedUpload() {
const alertStore = useAlertStore()
// 状态管理
const presignStatus = ref<PresignUploadStatus>(PRESIGN_UPLOAD_STATUS.IDLE)
const uploadSession = ref<PresignInitResponse | null>(null)
const uploadProgress = ref<UploadProgress>({
loaded: 0,
total: 0,
percentage: 0
})
const uploadedCode = ref<string>('')
const errorMessage = ref<string>('')
// 计算属性
const isInitializing = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.INITIALIZING)
const isUploading = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.UPLOADING)
const isConfirming = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.CONFIRMING)
const isSuccess = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.SUCCESS)
const isError = computed(() => presignStatus.value === PRESIGN_UPLOAD_STATUS.ERROR)
const currentMode = computed<PresignUploadMode | null>(() => uploadSession.value?.mode ?? null)
/**
* 文件大小验证
*/
const validateFileSize = (file: File): boolean => {
if (file.size > FILE_SIZE_LIMITS.MAX_FILE_SIZE) {
const maxSizeMB = Math.round(FILE_SIZE_LIMITS.MAX_FILE_SIZE / 1024 / 1024)
errorMessage.value = `文件大小不能超过 ${maxSizeMB}MB`
alertStore.showAlert(errorMessage.value, 'error')
return false
}
return true
}
/**
* 进度追踪回调
*/
const createProgressHandler = (
externalHandler?: (progress: UploadProgress) => void
): ((progress: UploadProgress) => void) => {
return (progress: UploadProgress) => {
// 确保进度值在有效范围内
uploadProgress.value = {
loaded: Math.max(0, progress.loaded),
total: Math.max(0, progress.total),
percentage: Math.min(100, Math.max(0, progress.percentage))
}
// 调用外部进度回调
if (externalHandler) {
externalHandler(uploadProgress.value)
}
}
}
/**
* 错误处理
*/
const handleUploadError = (error: unknown): void => {
presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR
if (axios.isAxiosError(error)) {
const status = error.response?.status
const detail = error.response?.data?.detail
switch (status) {
case 400:
errorMessage.value = detail || '请求参数错误'
break
case 403:
errorMessage.value = detail || '操作被禁止'
break
case 404:
errorMessage.value = '上传会话不存在或已过期'
break
case 500:
errorMessage.value = '服务器错误,请稍后重试'
break
default:
errorMessage.value = detail || '上传失败,请重试'
}
} else if (error instanceof Error) {
errorMessage.value = error.message
} else {
errorMessage.value = '未知错误'
}
alertStore.showAlert(errorMessage.value, 'error')
}
/**
* 初始化上传会话
*/
const initUploadSession = async (
file: File,
options?: PresignUploadOptions
): Promise<PresignInitResponse | null> => {
try {
presignStatus.value = PRESIGN_UPLOAD_STATUS.INITIALIZING
const response = await PresignUploadService.initUpload({
file_name: file.name,
file_size: file.size,
expire_value: options?.expireValue ?? DEFAULT_EXPIRE_VALUE,
expire_style: options?.expireStyle ?? DEFAULT_EXPIRE_STYLE
})
if (response.code === 200 && response.detail) {
uploadSession.value = response.detail
return response.detail
} else {
throw new Error(response.message || '初始化上传失败')
}
} catch (error) {
handleUploadError(error)
return null
}
}
/**
* 直传模式上传到 S3
*/
const uploadDirect = async (
file: File,
session: PresignInitResponse,
options?: PresignUploadOptions
): Promise<string | null> => {
try {
presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING
// 上传到 S3
const progressHandler = createProgressHandler(options?.onProgress)
const uploadSuccess = await PresignUploadService.directUploadToS3(
session.upload_url,
file,
progressHandler
)
if (!uploadSuccess) {
throw new Error('S3 上传失败')
}
// 确认上传
presignStatus.value = PRESIGN_UPLOAD_STATUS.CONFIRMING
const confirmResponse = await PresignUploadService.confirmUpload(session.upload_id, {
expire_value: options?.expireValue ?? DEFAULT_EXPIRE_VALUE,
expire_style: options?.expireStyle ?? DEFAULT_EXPIRE_STYLE
})
if (confirmResponse.code === 200 && confirmResponse.detail?.code) {
presignStatus.value = PRESIGN_UPLOAD_STATUS.SUCCESS
uploadedCode.value = String(confirmResponse.detail.code)
// 确保进度为 100%
uploadProgress.value = {
loaded: file.size,
total: file.size,
percentage: 100
}
alertStore.showAlert('文件上传成功!', 'success')
return uploadedCode.value
} else {
throw new Error(confirmResponse.message || '确认上传失败')
}
} catch (error) {
handleUploadError(error)
return null
}
}
/**
* 代理模式上传
*/
const uploadProxy = async (
file: File,
session: PresignInitResponse,
options?: PresignUploadOptions
): Promise<string | null> => {
try {
presignStatus.value = PRESIGN_UPLOAD_STATUS.UPLOADING
const progressHandler = createProgressHandler(options?.onProgress)
const response = await PresignUploadService.proxyUpload(
session.upload_id,
file,
progressHandler
)
if (response.code === 200 && response.detail?.code) {
presignStatus.value = PRESIGN_UPLOAD_STATUS.SUCCESS
uploadedCode.value = String(response.detail.code)
// 确保进度为 100%
uploadProgress.value = {
loaded: file.size,
total: file.size,
percentage: 100
}
alertStore.showAlert('文件上传成功!', 'success')
return uploadedCode.value
} else {
throw new Error(response.message || '代理上传失败')
}
} catch (error) {
handleUploadError(error)
return null
}
}
/**
* 上传文件(主入口方法)
* 根据 mode 自动选择直传或代理上传
*/
const uploadFile = async (
file: File,
options?: PresignUploadOptions
): Promise<string | null> => {
// 重置状态
reset()
// 验证文件大小
if (!validateFileSize(file)) {
presignStatus.value = PRESIGN_UPLOAD_STATUS.ERROR
return null
}
// 初始化上传会话
const session = await initUploadSession(file, options)
if (!session) {
return null
}
// 根据模式选择上传方式
if (session.mode === 'direct') {
return await uploadDirect(file, session, options)
} else {
return await uploadProxy(file, session, options)
}
}
/**
* 取消上传
*/
const cancelUpload = async (): Promise<void> => {
if (!uploadSession.value?.upload_id) {
return
}
try {
await PresignUploadService.cancelUpload(uploadSession.value.upload_id)
alertStore.showAlert('上传已取消', 'info')
} catch (error) {
// 取消失败时静默处理,因为会话可能已过期
console.warn('取消上传失败:', error)
} finally {
reset()
}
}
/**
* 获取上传状态
*/
const getStatus = async (): Promise<PresignStatusResponse | null> => {
if (!uploadSession.value?.upload_id) {
return null
}
try {
const response = await PresignUploadService.getUploadStatus(uploadSession.value.upload_id)
if (response.code === 200 && response.detail) {
// 检查会话是否过期
if (response.detail.is_expired) {
errorMessage.value = '上传会话已过期'
alertStore.showAlert(errorMessage.value, 'warning')
}
return response.detail
}
return null
} catch (error) {
handleUploadError(error)
return null
}
}
/**
* 重置状态
*/
const reset = (): void => {
presignStatus.value = PRESIGN_UPLOAD_STATUS.IDLE
uploadSession.value = null
uploadProgress.value = {
loaded: 0,
total: 0,
percentage: 0
}
uploadedCode.value = ''
errorMessage.value = ''
}
return {
// 状态 (只读)
presignStatus: readonly(presignStatus),
uploadSession: readonly(uploadSession),
uploadProgress: readonly(uploadProgress),
uploadedCode: readonly(uploadedCode),
errorMessage: readonly(errorMessage),
// 计算属性
isInitializing,
isUploading,
isConfirming,
isSuccess,
isError,
currentMode,
// 方法
uploadFile,
cancelUpload,
getStatus,
reset
}
}