Files
basic/src/ui/components/FaFileUpload/index.vue
T

232 lines
7.4 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.
<script setup lang="ts">
import axios from 'axios'
import { filesize } from 'filesize'
import { toast } from 'vue-sonner'
defineOptions({
name: 'FaFileUpload',
})
const props = withDefaults(defineProps<{
action: string
method?: string
headers?: Headers | Record<string, any>
data?: Record<string, any>
name?: string
afterUpload?: (response: any) => string | Promise<string>
multiple?: boolean
ext?: string[]
size?: number
max?: number
hideTips?: boolean
disabled?: boolean
}>(), {
method: 'post',
headers: () => ({}),
data: () => ({}),
name: 'file',
multiple: false,
ext: () => [],
size: 10 * 1024 * 1024,
max: 0,
hideTips: false,
disabled: false,
})
const emits = defineEmits<{
onSuccess: [response: any, file: File]
onClick: [fileItem: FileItem, index: number]
}>()
const fileList = defineModel<FileItem[]>('modelValue', { required: true })
export interface FileItem {
name: string
size: number
url?: string
status?: 'uploading' | 'success' | 'error'
progress?: number
file?: File
}
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const isDragging = ref(false)
function handleDragOver(e: DragEvent) {
e.preventDefault()
if (props.disabled) {
return
}
isDragging.value = true
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
isDragging.value = false
}
function handleDrop(e: DragEvent) {
e.preventDefault()
if (props.disabled) {
return
}
isDragging.value = false
if (e.dataTransfer?.files) {
onSelectFile(e.dataTransfer.files)
}
}
function onSelectFile(files: FileList | File[] | null) {
if (!files) {
return
}
const selectedFiles = Array.from(files)
// 数量限制
const remain = props.max > 0 ? props.max - fileList.value.length : selectedFiles.length
const filesToAdd: File[] = []
for (const file of selectedFiles.slice(0, remain)) {
// 类型校验
if (props.ext.length > 0) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (!props.ext.map(e => e.toLowerCase()).includes(ext || '')) {
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式`)
continue
}
}
// 大小校验
if (props.size > 0) {
if (file.size > props.size) {
toast.error(`上传文件大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`)
continue
}
}
filesToAdd.push(file)
}
fileInputRef.value!.value = ''
filesToAdd.forEach(file => uploadFile(file))
}
function uploadFile(file: File, index?: number) {
const formData = new FormData()
Object.entries(props.data).forEach(([key, value]) => {
formData.append(key, value)
})
formData.append(props.name, file)
let headersObj: Record<string, any> = {}
if (props.headers instanceof Headers) {
props.headers.forEach((value, key) => {
headersObj[key] = value
})
}
else {
headersObj = { ...props.headers }
}
if (index === undefined) {
fileList.value.push({
name: file.name,
size: file.size,
status: 'uploading',
progress: 0,
file,
})
}
const currentFileIndex = index ?? fileList.value.length - 1
axios({
url: props.action,
method: props.method,
headers: headersObj,
data: formData,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
fileList.value[currentFileIndex].progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
},
})
.then(async (response) => {
const url = await props.afterUpload?.(response.data)
if (url) {
fileList.value[currentFileIndex].url = url
}
emits('onSuccess', response.data, file)
fileList.value[currentFileIndex].status = 'success'
})
.catch(() => {
fileList.value[currentFileIndex].status = 'error'
})
}
function removeFile(idx: number) {
fileList.value.splice(idx, 1)
}
</script>
<template>
<div class="space-y-2">
<button
type="button"
class="h-40 w-full flex flex-col cursor-pointer items-center justify-center border border-2 rounded-lg border-dashed bg-transparent p-4 transition-all"
:class="{
'border-primary bg-primary/5': isDragging,
'opacity-50 cursor-not-allowed': props.disabled || (props.max > 0 && fileList.length >= props.max),
}"
:disabled="props.disabled || (props.max > 0 && fileList.length >= props.max)"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@click="fileInputRef?.click()"
>
<FaIcon name="i-icon-park-outline:upload" class="mb-2 text-2xl text-card-foreground/50" />
<div class="text-sm text-card-foreground/70">
将文件拖到此处,或<span class="cursor-pointer text-primary font-bold">点击上传</span>
</div>
<input ref="fileInputRef" type="file" :multiple="props.multiple" :disabled="props.disabled" class="hidden" @change="e => onSelectFile((e.target as HTMLInputElement).files)">
</button>
<div v-if="!props.hideTips && !props.disabled" class="flex flex-wrap gap-1 text-xs text-card-foreground/50 empty:hidden">
<div v-if="props.ext.length > 0" class="after:content-[';_'] last:after:content-empty">
{{ `支持 ${props.ext.join(' / ')} 格式` }}
</div>
<div v-if="props.size > 0" class="after:content-[';_'] last:after:content-empty">
{{ `大小不超过 ${filesize(props.size, { standard: 'jedec' })}` }}
</div>
<div v-if="props.max > 0" class="after:content-[';_'] last:after:content-empty">
{{ `数量不超过 ${props.max} 个` }}
</div>
</div>
<div v-if="fileList.length > 0" class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
<div
v-for="(item, index) in fileList"
:key="item.name + index"
class="group/file-upload-item relative flex items-center gap-2 border rounded-lg py-2 pe-2 ps-3"
:class="[
item.status === 'error' ? 'border-red-500 bg-red-500/10' : '',
]"
@click="emits('onClick', item, index)"
>
<div class="flex-1 truncate">
<div class="truncate text-sm font-medium">
{{ item.name }}
</div>
<div class="text-xs text-card-foreground/50">
{{ filesize(item.size, { standard: 'jedec' }) }}
</div>
</div>
<div v-if="item.status === 'uploading'" class="pointer-events-none absolute inset-0 z-0 bg-primary/5" :style="{ width: `${item.progress}%` }" />
<div v-else-if="item.status === 'success'" class="size-10 flex-center">
<FaIcon name="i-ix:upload-success" class="text-lg text-green-600" />
</div>
<div v-else-if="item.status === 'error'" class="size-10 flex-center">
<FaIcon name="i-ix:upload-fail" class="text-lg text-red-600" />
</div>
<FaButtonGroup v-if="!props.disabled" class="absolute inset-e-2 top-1/2 opacity-0 transition-opacity -translate-y-1/2 group-hover/file-upload-item:opacity-100">
<FaButton v-if="item.status === 'error'" variant="outline" size="icon" @click.stop="uploadFile(item.file!, index)">
<FaIcon name="i-icon-park-outline:upload" class="cursor-pointer text-lg" />
</FaButton>
<FaButton v-if="item.status !== 'uploading'" variant="outline" size="icon" @click.stop="removeFile(index)">
<FaIcon name="i-icon-park-outline:delete" class="cursor-pointer text-lg text-red-500" />
</FaButton>
</FaButtonGroup>
</div>
</div>
</div>
</template>