diff --git a/src/components/common/FileUploadArea.vue b/src/components/common/FileUploadArea.vue index 3071ec4..5e7acce 100644 --- a/src/components/common/FileUploadArea.vue +++ b/src/components/common/FileUploadArea.vue @@ -4,7 +4,8 @@ :class="[ isDarkMode ? 'bg-gray-800 bg-opacity-50 border-gray-600 hover:border-indigo-500' - : 'bg-gray-100 border-gray-300 hover:border-indigo-500' + : 'bg-gray-100 border-gray-300 hover:border-indigo-500', + statusClass ]" @click="triggerFileUpload" @dragover.prevent @@ -16,18 +17,22 @@ class="hidden" @change="handleFileUpload" :accept="acceptedTypes" + :disabled="isUploading" />
- + + +

- {{ selectedFile ? selectedFile.name : placeholderText }} + {{ displayText }}

-

- {{ descriptionText }} + + +

+ {{ statusDescription }}

+ + +
+
+ {{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }} + {{ progress }}% +
+
+ + + \ No newline at end of file diff --git a/src/composables/useFileUpload.ts b/src/composables/useFileUpload.ts index 7f1b673..6bb3131 100644 --- a/src/composables/useFileUpload.ts +++ b/src/composables/useFileUpload.ts @@ -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(UPLOAD_STATUS.IDLE) const uploadProgress = ref({ @@ -17,11 +35,34 @@ export function useFileUpload() { const uploadedCode = ref('') const currentFile = ref(null) + // 当前是否使用预签名上传 + const isUsingPresigned = ref(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 => { + /** + * 上传文件(支持预签名上传和传统上传) + */ + const uploadFile = async (file: File, uploadOptions?: FileUploadOptions): Promise => { + 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 => { 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 => { + // 构建预签名上传选项 + 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 => { 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 => { + 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 } } \ No newline at end of file diff --git a/src/composables/usePresignedUpload.ts b/src/composables/usePresignedUpload.ts new file mode 100644 index 0000000..b121a7e --- /dev/null +++ b/src/composables/usePresignedUpload.ts @@ -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(PRESIGN_UPLOAD_STATUS.IDLE) + const uploadSession = ref(null) + const uploadProgress = ref({ + loaded: 0, + total: 0, + percentage: 0 + }) + const uploadedCode = ref('') + const errorMessage = ref('') + + // 计算属性 + 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(() => 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 => { + 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 => { + 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 => { + 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 => { + // 重置状态 + 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 => { + 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 => { + 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 + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 536296c..774f02a 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,7 +1,8 @@ // API 服务层 import api from '@/utils/api' +import axios from 'axios' import type { ApiResponse, FileInfo, ConfigState, AdminUser, FileUploadResponse, TextSendResponse, DashboardData, FileListResponse, FileEditForm } from '@/types' -import type { UploadProgress } from '@/types' +import type { UploadProgress, PresignInitRequest, PresignInitResponse, PresignConfirmRequest, PresignUploadResult, PresignStatusResponse } from '@/types' // 系统配置服务 export class ConfigService { @@ -128,12 +129,105 @@ export class StatsService { } } +// 预签名上传服务 +export class PresignUploadService { + /** + * 初始化上传会话 + */ + static async initUpload(request: PresignInitRequest): Promise> { + return api.post('/presign/upload/init', request) + } + + /** + * 代理模式上传 + */ + static async proxyUpload( + uploadId: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise> { + const formData = new FormData() + formData.append('file', file) + + return api.put(`/presign/upload/proxy/${uploadId}`, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress: UploadProgress = { + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + } + onProgress(progress) + } + } + }) + } + + /** + * 确认直传上传 + */ + static async confirmUpload( + uploadId: string, + request?: PresignConfirmRequest + ): Promise> { + return api.post(`/presign/upload/confirm/${uploadId}`, request || {}) + } + + /** + * 查询上传状态 + */ + static async getUploadStatus(uploadId: string): Promise> { + return api.get(`/presign/upload/status/${uploadId}`) + } + + /** + * 取消上传 + */ + static async cancelUpload(uploadId: string): Promise> { + return api.delete(`/presign/upload/${uploadId}`) + } + + /** + * S3 直传(不经过后端) + */ + static async directUploadToS3( + uploadUrl: string, + file: File, + onProgress?: (progress: UploadProgress) => void + ): Promise { + try { + await axios.put(uploadUrl, file, { + headers: { + 'Content-Type': 'application/octet-stream' + }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress: UploadProgress = { + loaded: progressEvent.loaded, + total: progressEvent.total, + percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total) + } + onProgress(progress) + } + } + }) + return true + } catch { + return false + } + } +} + // 导出所有服务 export const services = { config: ConfigService, file: FileService, auth: AuthService, - stats: StatsService + stats: StatsService, + presignUpload: PresignUploadService } export default services \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 812f3fd..6dfd698 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -177,4 +177,68 @@ export interface RouteConfig { requiresAuth?: boolean title?: string } -} \ No newline at end of file +} + + +// ==================== 预签名上传相关类型 ==================== + +// 预签名上传模式 +export type PresignUploadMode = 'direct' | 'proxy' + +// 预签名上传状态 +export type PresignUploadStatus = + | 'idle' + | 'initializing' + | 'uploading' + | 'confirming' + | 'success' + | 'error' + +// 过期类型 +export type ExpireStyle = 'day' | 'hour' | 'minute' | 'forever' | 'count' + +// 初始化上传请求 +export interface PresignInitRequest { + file_name: string + file_size: number + expire_value?: number + expire_style?: ExpireStyle +} + +// 初始化上传响应 +export interface PresignInitResponse { + upload_id: string + upload_url: string + mode: PresignUploadMode + expires_in: number +} + +// 确认上传请求 +export interface PresignConfirmRequest { + expire_value?: number + expire_style?: ExpireStyle +} + +// 上传结果 +export interface PresignUploadResult { + code: string + name: string +} + +// 上传状态查询响应 +export interface PresignStatusResponse { + upload_id: string + file_name: string + file_size: number + mode: PresignUploadMode + created_at: string + expires_at: string + is_expired: boolean +} + +// 上传配置选项 +export interface PresignUploadOptions { + expireValue?: number + expireStyle?: ExpireStyle + onProgress?: (progress: UploadProgress) => void +} diff --git a/src/views/SendFileView.vue b/src/views/SendFileView.vue index b4e00af..1f08d87 100644 --- a/src/views/SendFileView.vue +++ b/src/views/SendFileView.vue @@ -511,6 +511,8 @@ import PageHeader from '@/components/common/PageHeader.vue' import SendTypeSelector from '@/components/common/SendTypeSelector.vue' import FileUploadArea from '@/components/common/FileUploadArea.vue' import TextInputArea from '@/components/common/TextInputArea.vue' +import { usePresignedUpload } from '@/composables/usePresignedUpload' +import type { ExpireStyle } from '@/types' interface Config { name: string @@ -552,6 +554,14 @@ const { t } = useI18n() const alertStore = useAlertStore() const sendRecords = computed(() => fileDataStore.shareData) +// 预签名上传 +const { + presignStatus, + uploadProgress: presignProgress, + uploadFile: presignUploadFile, + reset: resetPresignUpload +} = usePresignedUpload() + const fileHash = ref('') @@ -891,6 +901,28 @@ const handleDefaultFileUpload = async (file: File) => { const response: ApiResponse<{ code?: string; name?: string }> = await api.post('share/file/', formData, config) return response } + +// 预签名上传处理 +const handlePresignedUpload = async (file: File) => { + const code = await presignUploadFile(file, { + expireValue: expirationValue.value ? parseInt(expirationValue.value) : 1, + expireStyle: expirationMethod.value as ExpireStyle, + onProgress: (progress) => { + uploadProgress.value = progress.percentage + } + }) + + if (code) { + return { + code: 200, + detail: { + code: code, + name: file.name + } + } as ApiResponse<{ code?: string; name?: string }> + } + throw new Error(t('send.messages.uploadFailed')) +} const checkOpenUpload = () => { if (config.openUpload === 0 && localStorage.getItem('token') === null) { alertStore.showAlert(t('send.messages.guestUploadDisabled'), 'error') @@ -963,11 +995,16 @@ const handleSubmit = async () => { let response: ApiResponse if (sendType.value === 'file') { - // 使用切片上传替代原来的直接上传 - if (config.enableChunk) { - response = await handleChunkUpload(selectedFile.value!) - } else { - response = await handleDefaultFileUpload(selectedFile.value!) + // 优先使用预签名上传,如果失败则回退到其他方式 + try { + response = await handlePresignedUpload(selectedFile.value!) + } catch { + // 预签名上传失败,回退到切片上传或默认上传 + if (config.enableChunk) { + response = await handleChunkUpload(selectedFile.value!) + } else { + response = await handleDefaultFileUpload(selectedFile.value!) + } } } else { // 文本上传保持不变 @@ -1008,6 +1045,7 @@ const handleSubmit = async () => { selectedFile.value = null textContent.value = '' uploadProgress.value = 0 + resetPresignUpload() // 显示详情 selectedRecord.value = newRecord // 自动复制取件码链接