Files
FileCodeBoxFronted2026/src/components/common/FileUploadArea.vue
T
2026-06-03 02:22:24 +08:00

333 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
class="rounded-2xl p-8 flex flex-col items-center justify-center border-2 border-dashed transition-all duration-300 group cursor-pointer relative min-h-72 overflow-hidden"
:class="[
isDarkMode
? 'bg-gray-800/60 border-gray-600 hover:border-indigo-400'
: 'bg-white/80 border-gray-300 hover:border-indigo-500',
isDragActive
? isDarkMode
? 'border-indigo-400 bg-indigo-500/10 shadow-lg shadow-indigo-900/30'
: 'border-indigo-500 bg-indigo-50 shadow-lg shadow-indigo-100'
: '',
statusClass
]"
role="button"
tabindex="0"
:aria-busy="isUploading"
@click="triggerFileUpload"
@keydown.enter.prevent="triggerFileUpload"
@keydown.space.prevent="triggerFileUpload"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleFileDrop"
>
<input
ref="fileInput"
type="file"
class="hidden"
@change="handleFileUpload"
:accept="acceptedTypes"
:disabled="isUploading"
multiple
/>
<div
class="absolute inset-0 opacity-0 transition-opacity duration-300"
:class="[isDragActive ? 'opacity-100' : '']"
>
<div
class="absolute inset-3 rounded-2xl border"
:class="[
isDarkMode ? 'border-indigo-300/30 bg-indigo-400/5' : 'border-indigo-300 bg-indigo-50/80'
]"
></div>
</div>
<div class="absolute inset-0 w-full h-full" v-if="progress > 0">
<BorderProgressBar :progress="progress" />
</div>
<!-- 上传状态图标 -->
<component
:is="statusIcon"
:class="['relative z-10 w-16 h-16 transition-colors duration-300', statusIconClass]"
/>
<!-- 文件名或占位文本 -->
<p
:class="[
'relative z-10 mt-4 text-sm transition-colors duration-300 w-full text-center',
isDarkMode
? 'text-gray-400 group-hover:text-indigo-400'
: 'text-gray-600 group-hover:text-indigo-600'
]"
>
<span v-if="selectedFiles && selectedFiles.length > 1" class="block">
<span v-for="(f, i) in selectedFiles" :key="i" class="block truncate">{{ f.name }}</span>
</span>
<span v-else class="block truncate">
{{ displayText }}
</span>
</p>
<!-- 状态描述或默认描述 -->
<p :class="['relative z-10 mt-2 text-xs text-center leading-5', statusDescriptionClass]">
{{ statusDescription }}
</p>
<div
v-if="selectedFiles.length > 1"
class="relative z-10 mt-4 flex flex-wrap items-center justify-center gap-2"
>
<span
v-for="file in selectedFiles.slice(0, 3)"
:key="`${file.name}-${file.size}`"
class="max-w-40 truncate rounded-full px-3 py-1 text-xs"
:class="[isDarkMode ? 'bg-gray-700/80 text-gray-200' : 'bg-gray-100 text-gray-700']"
>
{{ file.name }}
</span>
<span
v-if="selectedFiles.length > 3"
class="rounded-full px-3 py-1 text-xs"
:class="[isDarkMode ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-100 text-indigo-700']"
>
+{{ selectedFiles.length - 3 }}
</span>
</div>
<!-- 进度详情上传中显示 -->
<div v-if="isUploading && showProgressDetails" class="relative z-10 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, computed } from 'vue'
import { UploadCloudIcon, CheckCircleIcon, XCircleIcon, LoaderIcon } from 'lucide-vue-next'
import BorderProgressBar from './BorderProgressBar.vue'
import { useI18n } from 'vue-i18n'
import { useInjectedDarkMode } from '@/composables'
const { t } = useI18n()
type UploadStatusType = 'idle' | 'uploading' | 'success' | 'error' | 'initializing' | 'confirming'
interface Props {
selectedFile?: File | null
selectedFiles?: File[]
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]
filesSelected: [files: File[]]
fileDrop: [event: DragEvent]
retry: []
}
const props = withDefaults(defineProps<Props>(), {
selectedFile: null,
selectedFiles: () => [],
progress: 0,
placeholder: '',
description: '',
acceptedTypes: '*',
uploadStatus: 'idle',
uploadedBytes: 0,
totalBytes: 0,
errorMessage: '',
allowRetry: true,
retryText: '重试',
showProgressDetails: true
})
const emit = defineEmits<Emits>()
const isDarkMode = useInjectedDarkMode()
// 使用computed属性处理多语言文本
const placeholderText = computed(() => props.placeholder || t('send.uploadArea.placeholder'))
const descriptionText = computed(() => props.description || t('send.uploadArea.description'))
const fileInput = ref<HTMLInputElement | null>(null)
const isDragActive = ref(false)
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.selectedFiles && props.selectedFiles.length === 1) {
return props.selectedFiles[0].name
}
if (props.selectedFiles && props.selectedFiles.length > 1) {
return `已选择 ${props.selectedFiles.length} 个文件`
}
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.value ? 'text-indigo-400 animate-spin' : 'text-indigo-600 animate-spin'
}
if (isSuccess.value) {
return isDarkMode.value ? 'text-green-400' : 'text-green-600'
}
if (hasError.value) {
return isDarkMode.value ? 'text-red-400' : 'text-red-600'
}
return isDarkMode.value
? 'text-gray-400 group-hover:text-indigo-400'
: 'text-gray-600 group-hover:text-indigo-600'
})
const statusClass = computed(() => {
if (hasError.value) {
return isDarkMode.value ? 'border-red-500/50' : 'border-red-300'
}
if (isSuccess.value) {
return isDarkMode.value ? '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.value ? 'text-red-400' : 'text-red-500'
}
if (isSuccess.value) {
return isDarkMode.value ? 'text-green-400' : 'text-green-500'
}
return isDarkMode.value ? '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()
}
const handleDragEnter = () => {
if (isUploading.value) return
isDragActive.value = true
}
const handleDragOver = () => {
if (isUploading.value) return
isDragActive.value = true
}
const handleDragLeave = (event: DragEvent) => {
const target = event.currentTarget as HTMLElement
const relatedTarget = event.relatedTarget as Node | null
if (!relatedTarget || !target.contains(relatedTarget)) {
isDragActive.value = false
}
}
const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement
const files = target.files
if (files && files.length > 0) {
if (files.length === 1) {
emit('fileSelected', files[0])
} else {
emit('filesSelected', Array.from(files))
}
}
// 重置 input 值,允许选择同名文件
target.value = ''
}
const handleFileDrop = (event: DragEvent) => {
// 上传中不允许拖放
isDragActive.value = false
if (isUploading.value) return
emit('fileDrop', event)
}
const handleRetry = () => {
emit('retry')
}
</script>