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
+166 -10
View File
@@ -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"
/>
<div class="absolute inset-0 w-full h-full" v-if="progress > 0">
<BorderProgressBar :progress="progress" />
</div>
<UploadCloudIcon
<!-- 上传状态图标 -->
<component
:is="statusIcon"
:class="[
'w-16 h-16 transition-colors duration-300',
isDarkMode
? 'text-gray-400 group-hover:text-indigo-400'
: 'text-gray-600 group-hover:text-indigo-600'
statusIconClass
]"
/>
<!-- 文件名或占位文本 -->
<p
:class="[
'mt-4 text-sm transition-colors duration-300 w-full text-center',
@@ -37,33 +42,75 @@
]"
>
<span class="block truncate">
{{ selectedFile ? selectedFile.name : placeholderText }}
{{ displayText }}
</span>
</p>
<p :class="['mt-2 text-xs', isDarkMode ? 'text-gray-500' : 'text-gray-400']">
{{ descriptionText }}
<!-- 状态描述或默认描述 -->
<p :class="['mt-2 text-xs', statusDescriptionClass]">
{{ statusDescription }}
</p>
<!-- 进度详情上传中显示 -->
<div v-if="isUploading && showProgressDetails" class="mt-3 w-full">
<div class="flex justify-between text-xs mb-1" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
<span>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span>
<span>{{ progress }}%</span>
</div>
</div>
<!-- 错误重试按钮 -->
<button
v-if="hasError && allowRetry"
@click.stop="handleRetry"
class="mt-3 px-4 py-2 text-sm rounded-lg transition-colors duration-200"
:class="[
isDarkMode
? 'bg-indigo-600 hover:bg-indigo-500 text-white'
: 'bg-indigo-500 hover:bg-indigo-600 text-white'
]"
>
{{ retryText }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
import { UploadCloudIcon } from 'lucide-vue-next'
import { UploadCloudIcon, CheckCircleIcon, XCircleIcon, LoaderIcon } from 'lucide-vue-next'
import BorderProgressBar from './BorderProgressBar.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
type UploadStatusType = 'idle' | 'uploading' | 'success' | 'error' | 'initializing' | 'confirming'
interface Props {
selectedFile?: File | null
progress?: number
placeholder?: string
description?: string
acceptedTypes?: string
/** 上传状态 */
uploadStatus?: UploadStatusType
/** 已上传字节数 */
uploadedBytes?: number
/** 总字节数 */
totalBytes?: number
/** 错误消息 */
errorMessage?: string
/** 是否允许重试 */
allowRetry?: boolean
/** 重试按钮文本 */
retryText?: string
/** 是否显示进度详情 */
showProgressDetails?: boolean
}
interface Emits {
fileSelected: [file: File]
fileDrop: [event: DragEvent]
retry: []
}
const props = withDefaults(defineProps<Props>(), {
@@ -71,7 +118,14 @@ const props = withDefaults(defineProps<Props>(), {
progress: 0,
placeholder: '',
description: '',
acceptedTypes: '*'
acceptedTypes: '*',
uploadStatus: 'idle',
uploadedBytes: 0,
totalBytes: 0,
errorMessage: '',
allowRetry: true,
retryText: '重试',
showProgressDetails: true
})
const emit = defineEmits<Emits>()
@@ -83,7 +137,103 @@ const placeholderText = computed(() => props.placeholder || t('send.uploadArea.p
const descriptionText = computed(() => props.description || t('send.uploadArea.description'))
const fileInput = ref<HTMLInputElement | null>(null)
const isUploading = computed(() => {
return ['uploading', 'initializing', 'confirming'].includes(props.uploadStatus)
})
const hasError = computed(() => props.uploadStatus === 'error')
const isSuccess = computed(() => props.uploadStatus === 'success')
const displayText = computed(() => {
if (props.selectedFile) {
return props.selectedFile.name
}
return placeholderText.value
})
const statusIcon = computed(() => {
if (isUploading.value) return LoaderIcon
if (isSuccess.value) return CheckCircleIcon
if (hasError.value) return XCircleIcon
return UploadCloudIcon
})
const statusIconClass = computed(() => {
if (isUploading.value) {
return isDarkMode
? 'text-indigo-400 animate-spin'
: 'text-indigo-600 animate-spin'
}
if (isSuccess.value) {
return isDarkMode
? 'text-green-400'
: 'text-green-600'
}
if (hasError.value) {
return isDarkMode
? 'text-red-400'
: 'text-red-600'
}
return isDarkMode
? 'text-gray-400 group-hover:text-indigo-400'
: 'text-gray-600 group-hover:text-indigo-600'
})
const statusClass = computed(() => {
if (hasError.value) {
return isDarkMode
? 'border-red-500/50'
: 'border-red-300'
}
if (isSuccess.value) {
return isDarkMode
? 'border-green-500/50'
: 'border-green-300'
}
return ''
})
const statusDescription = computed(() => {
if (hasError.value && props.errorMessage) {
return props.errorMessage
}
if (props.uploadStatus === 'initializing') {
return '正在初始化上传...'
}
if (props.uploadStatus === 'uploading') {
return '正在上传文件...'
}
if (props.uploadStatus === 'confirming') {
return '正在确认上传...'
}
if (isSuccess.value) {
return '上传成功!'
}
return descriptionText.value
})
const statusDescriptionClass = computed(() => {
if (hasError.value) {
return isDarkMode ? 'text-red-400' : 'text-red-500'
}
if (isSuccess.value) {
return isDarkMode ? 'text-green-400' : 'text-green-500'
}
return isDarkMode ? 'text-gray-500' : 'text-gray-400'
})
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const triggerFileUpload = () => {
// 上传中或成功状态下不允许重新选择文件
if (isUploading.value) return
fileInput.value?.click()
}
@@ -96,6 +246,12 @@ const handleFileUpload = (event: Event) => {
}
const handleFileDrop = (event: DragEvent) => {
// 上传中不允许拖放
if (isUploading.value) return
emit('fileDrop', event)
}
const handleRetry = () => {
emit('retry')
}
</script>