feat: FaImageUpload / FaFileUpload 增加粘贴上传功能

This commit is contained in:
Hooray
2026-04-18 14:04:12 +08:00
parent e55e94743f
commit 1c81115605
4 changed files with 329 additions and 115 deletions
@@ -1,6 +1,6 @@
# FaFileUpload 文件上传组件
支持拖拽和点击上传的文件上传组件,带进度显示和文件管理功能。
支持拖拽、点击和粘贴上传的文件上传组件,带进度显示和文件管理功能。
## 使用场景
@@ -60,6 +60,10 @@ interface FileItem {
| `onSuccess` | `response: any, file: File` | 单个文件上传成功时触发 |
| `onClick` | `fileItem: FileItem, index: number` | 点击文件项时触发 |
## 粘贴上传
将鼠标移入当前组件,或通过 `Tab` 聚焦当前组件后,可直接按 `Ctrl+V` / `Cmd+V` 粘贴剪贴板中的文件。粘贴上传会复用组件现有的数量、格式和大小校验逻辑。
## 示例
### 基础用法
+140 -33
View File
@@ -49,18 +49,83 @@ export interface FileItem {
file?: File
}
const containerRef = useTemplateRef<HTMLElement>('containerRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const isDragging = ref(false)
const isHoveringContainer = ref(false)
const isContainerFocused = ref(false)
const isMaxReached = computed(() => props.max > 0 && fileList.value.length >= props.max)
const canHandlePaste = computed(() =>
!props.disabled
&& !isMaxReached.value
&& (isHoveringContainer.value || isContainerFocused.value),
)
const extAliasMap: Record<string, string[]> = {
'jpeg': ['jpg', 'jpeg'],
'jpg': ['jpg', 'jpeg'],
'plain': ['txt'],
'msword': ['doc'],
'vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
'vnd.ms-excel': ['xls'],
'vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
'vnd.ms-powerpoint': ['ppt'],
'vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
'x-zip-compressed': ['zip'],
'zip': ['zip'],
'x-rar-compressed': ['rar'],
'vnd.rar': ['rar'],
'rar': ['rar'],
'x-7z-compressed': ['7z'],
}
const acceptedExts = computed(() => new Set(props.ext.flatMap(ext => normalizeExtVariants(ext))))
function formatText(template: string, params: Record<string, string | number>) {
return Object.entries(params).reduce((result, [key, value]) => {
return result.replaceAll(`{${key}}`, String(value))
}, template)
function normalizeExt(ext?: string) {
if (!ext) {
return ''
}
return ext.toLowerCase().trim().replace(/^\./, '').split('+')[0] ?? ''
}
function normalizeExtVariants(ext?: string) {
const normalizedExt = normalizeExt(ext)
if (!normalizedExt) {
return []
}
return extAliasMap[normalizedExt] ?? [normalizedExt]
}
function getFileExtVariants(file: File) {
const extVariants = new Set<string>()
const fileNameExt = file.name.split('.').pop()
normalizeExtVariants(fileNameExt).forEach(ext => extVariants.add(ext))
const mimeExt = file.type.split('/')[1]
normalizeExtVariants(mimeExt).forEach(ext => extVariants.add(ext))
return extVariants
}
function validateFile(file: File) {
if (acceptedExts.value.size > 0) {
const fileExtVariants = getFileExtVariants(file)
const isAccepted = [...fileExtVariants].some(ext => acceptedExts.value.has(ext))
if (!isAccepted) {
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式`, {
description: file.name || file.type,
})
return false
}
}
if (props.size > 0 && file.size > props.size) {
toast.error(`上传文件大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`, {
description: `${file.name || file.type} (${filesize(file.size, { standard: 'jedec' })})`,
})
return false
}
return true
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
if (props.disabled) {
if (props.disabled || isMaxReached.value) {
return
}
isDragging.value = true
@@ -73,43 +138,74 @@ function handleDragLeave(e: DragEvent) {
function handleDrop(e: DragEvent) {
e.preventDefault()
if (props.disabled) {
if (props.disabled || isMaxReached.value) {
return
}
isDragging.value = false
if (e.dataTransfer?.files) {
onSelectFile(e.dataTransfer.files)
handleFiles(e.dataTransfer.files)
}
}
function onSelectFile(files: FileList | File[] | null) {
if (!files) {
function onPaste(e: ClipboardEvent) {
if (e.defaultPrevented || !canHandlePaste.value) {
return
}
const filesFromItems = [...(e.clipboardData?.items ?? [])]
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
const files = filesFromItems.length > 0
? filesFromItems
: [...(e.clipboardData?.files ?? [])]
if (files.length > 0 && handleFiles(files)) {
e.preventDefault()
}
}
function onContainerFocusIn() {
isContainerFocused.value = true
}
function onContainerFocusOut(e: FocusEvent) {
const nextFocusedElement = e.relatedTarget
if (nextFocusedElement instanceof Node && containerRef.value?.contains(nextFocusedElement)) {
return
}
isContainerFocused.value = false
}
onMounted(() => {
window.addEventListener('paste', onPaste)
})
onUnmounted(() => {
window.removeEventListener('paste', onPaste)
})
function onSelectFile(files: FileList | File[] | null) {
handleFiles(files)
}
function handleFiles(files: FileList | File[] | null) {
if (!files || props.disabled || isMaxReached.value) {
return false
}
const selectedFiles = [...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(formatText('上传文件只支持 {ext} 格式', { ext: props.ext.join(' / ') }))
continue
if (remain <= 0) {
return false
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
// 大小校验
if (props.size > 0) {
if (file.size > props.size) {
toast.error(formatText('上传文件大小不能超过 {size}', { size: filesize(props.size, { standard: 'jedec' }) }))
continue
const filesToAdd = selectedFiles.slice(0, remain).filter(validateFile)
if (filesToAdd.length === 0) {
return false
}
}
filesToAdd.push(file)
}
fileInputRef.value!.value = ''
filesToAdd.forEach(file => uploadFile(file))
return true
}
function uploadFile(file: File, index?: number) {
@@ -167,15 +263,23 @@ function removeFile(idx: number) {
</script>
<template>
<div class="space-y-2">
<div
ref="containerRef"
class="space-y-2"
:tabindex="props.disabled ? undefined : 0"
@mouseenter="isHoveringContainer = true"
@mouseleave="isHoveringContainer = false"
@focusin="onContainerFocusIn"
@focusout="onContainerFocusOut"
>
<button
type="button"
class="p-4 border border-2 rounded-lg border-dashed bg-transparent flex flex-col h-40 w-full cursor-pointer transition-all items-center justify-center"
:class="{
'border-primary bg-primary/5': isDragging,
'opacity-50 cursor-not-allowed': props.disabled || (props.max > 0 && fileList.length >= props.max),
'opacity-50 cursor-not-allowed': props.disabled || isMaxReached,
}"
:disabled="props.disabled || (props.max > 0 && fileList.length >= props.max)"
:disabled="props.disabled || isMaxReached"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@@ -191,13 +295,16 @@ function removeFile(idx: number) {
</button>
<div v-if="!props.hideTips && !props.disabled" class="text-xs text-card-foreground/50 flex flex-wrap gap-1 empty:hidden">
<div v-if="props.ext.length > 0" class="after:content-[';_'] last:after:content-empty">
{{ formatText('支持 {ext} 格式', { ext: props.ext.join(' / ') }) }}
支持 {{ props.ext.join(' / ') }} 格式
</div>
<div class="after:content-[';_'] last:after:content-empty">
支持粘贴上传
</div>
<div v-if="props.size > 0" class="after:content-[';_'] last:after:content-empty">
{{ formatText('大小不超过 {size}', { size: filesize(props.size, { standard: 'jedec' }) }) }}
大小不超过 {{ filesize(props.size, { standard: 'jedec' }) }}
</div>
<div v-if="props.max > 0" class="after:content-[';_'] last:after:content-empty">
{{ formatText('数量不超过 {max} 个', { max: props.max }) }}
数量不超过 {{ props.max }} 个
</div>
</div>
<div v-if="fileList.length > 0" class="gap-2 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))]">
@@ -1,6 +1,6 @@
# FaImageUpload 图片上传组件
专门用于图片上传的组件,支持预览、排序和拖拽上传。
专门用于图片上传的组件,支持预览、排序和粘贴上传。
## 使用场景
@@ -49,6 +49,10 @@
|--------|------|------|
| `onSuccess` | `response: any, file: File` | 单张图片上传成功时触发 |
## 粘贴上传
将鼠标移入当前组件,或通过 `Tab` 聚焦当前组件后,可直接按 `Ctrl+V` / `Cmd+V` 粘贴剪贴板中的图片。粘贴上传会复用组件现有的数量、格式和大小校验逻辑。
## 示例
### 基础用法
+142 -43
View File
@@ -49,58 +49,75 @@ const emits = defineEmits<{
const images = defineModel<string[]>('modelValue', { required: true })
const isUploading = ref(false)
const activeUploadCount = ref(0)
const uploadProgress = ref(0)
const containerRef = useTemplateRef<HTMLElement>('containerRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const isHoveringContainer = ref(false)
const isContainerFocused = ref(false)
const isUploading = computed(() => activeUploadCount.value > 0)
const acceptedExts = computed(() => new Set(props.ext.flatMap(ext => normalizeExtVariants(ext))))
const canHandlePaste = computed(() =>
!props.disabled
&& !isUploading.value
&& (isHoveringContainer.value || isContainerFocused.value),
)
function formatText(template: string, params: Record<string, string | number>) {
return Object.entries(params).reduce((result, [key, value]) => {
return result.replaceAll(`{${key}}`, String(value))
}, template)
function normalizeExt(ext?: string) {
if (!ext) {
return ''
}
return ext.toLowerCase().trim().replace(/^\./, '').split('+')[0] ?? ''
}
function onSelectFile(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) {
return
function normalizeExtVariants(ext?: string) {
const normalizedExt = normalizeExt(ext)
if (!normalizedExt) {
return []
}
const selectedFiles = [...input.files]
const remain = props.max === 0 ? selectedFiles.length : props.max - images.value.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(formatText('上传图片只支持 {ext} 格式', { ext: props.ext.join(' / ') }), {
description: file.name,
if (normalizedExt === 'jpg' || normalizedExt === 'jpeg') {
return ['jpg', 'jpeg']
}
return [normalizedExt]
}
function getFileExtVariants(file: File) {
const extVariants = new Set<string>()
const fileNameExt = file.name.split('.').pop()
normalizeExtVariants(fileNameExt).forEach(ext => extVariants.add(ext))
const mimeExt = file.type.split('/')[1]
normalizeExtVariants(mimeExt).forEach(ext => extVariants.add(ext))
return extVariants
}
function validateFile(file: File) {
if (acceptedExts.value.size > 0) {
const fileExtVariants = getFileExtVariants(file)
const isAccepted = [...fileExtVariants].some(ext => acceptedExts.value.has(ext))
if (!isAccepted) {
toast.error(`上传图片只支持 ${props.ext.join(' / ')} 格式`, {
description: file.name || file.type,
})
continue
return false
}
}
// 大小校验
if (props.size > 0) {
if (file.size > props.size) {
toast.error(formatText('上传图片大小不能超过 {size}', { size: filesize(props.size, { standard: 'jedec' }) }), {
description: `${file.name} (${filesize(file.size, { standard: 'jedec' })})`,
if (props.size > 0 && file.size > props.size) {
toast.error(`上传图片大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`, {
description: `${file.name || file.type} (${filesize(file.size, { standard: 'jedec' })})`,
})
continue
return false
}
return true
}
filesToAdd.push(file)
}
fileInputRef.value!.value = ''
// 上传每个文件
filesToAdd.forEach((file) => {
isUploading.value = true
function uploadFile(file: File) {
activeUploadCount.value += 1
uploadProgress.value = 0
const formData = new FormData()
// 附加额外参数
Object.entries(props.data).forEach(([key, value]) => {
formData.append(key, value)
})
formData.append(props.name, file)
// 处理 headers,确保为普通对象
let headersObj: Record<string, any> = {}
if (props.headers instanceof Headers) {
props.headers.forEach((value, key) => {
@@ -127,10 +144,75 @@ function onSelectFile(e: Event) {
}
emits('onSuccess', response.data, file)
}).finally(() => {
isUploading.value = false
activeUploadCount.value -= 1
if (!isUploading.value) {
uploadProgress.value = 0
}
})
}
function onSelectFile(e: Event) {
handleFiles((e.target as HTMLInputElement).files)
}
function onPaste(e: ClipboardEvent) {
if (e.defaultPrevented || !canHandlePaste.value) {
return
}
const clipboardItems = [...(e.clipboardData?.items ?? [])]
const filesFromItems = clipboardItems
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
const files = filesFromItems.length > 0
? filesFromItems
: [...(e.clipboardData?.files ?? [])].filter(file => file.type.startsWith('image/'))
if (files.length > 0 && handleFiles(files)) {
e.preventDefault()
}
}
function onContainerFocusIn() {
isContainerFocused.value = true
}
function onContainerFocusOut(e: FocusEvent) {
const nextFocusedElement = e.relatedTarget
if (nextFocusedElement instanceof Node && containerRef.value?.contains(nextFocusedElement)) {
return
}
isContainerFocused.value = false
}
onMounted(() => {
window.addEventListener('paste', onPaste)
})
onUnmounted(() => {
window.removeEventListener('paste', onPaste)
})
function handleFiles(files: FileList | File[] | null) {
if (!files || props.disabled || isUploading.value) {
return false
}
const selectedFiles = [...files]
const remain = props.max === 0 ? selectedFiles.length : props.max - images.value.length
if (remain <= 0) {
return false
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
const filesToAdd = selectedFiles.slice(0, remain).filter(validateFile)
if (filesToAdd.length === 0) {
return false
}
filesToAdd.forEach((file) => {
void uploadFile(file)
})
return true
}
function onRemove(idx: number) {
@@ -152,7 +234,15 @@ function onMove(index: number, direction: 'forward' | 'backward') {
</script>
<template>
<div class="space-y-2">
<div
ref="containerRef"
class="space-y-2"
:tabindex="props.disabled ? undefined : 0"
@mouseenter="isHoveringContainer = true"
@mouseleave="isHoveringContainer = false"
@focusin="onContainerFocusIn"
@focusout="onContainerFocusOut"
>
<div class="flex flex-wrap gap-2">
<div
v-for="(img, index) in images" :key="img" class="group/image-upload border rounded-lg flex items-center justify-center relative overflow-hidden" :style="{
@@ -187,12 +277,18 @@ function onMove(index: number, direction: 'forward' | 'backward') {
</div>
</div>
<button
v-if="images.length < props.max || props.max === 0" type="button" class="bg-transparent flex-center relative overflow-hidden" :class="{
v-if="images.length < props.max || props.max === 0"
type="button"
class="bg-transparent flex-center relative overflow-hidden"
:class="{
'cursor-not-allowed': props.disabled || isUploading,
}" :style="{
}"
:style="{
width: `${props.width}px`,
height: `${props.height}px`,
}" :disabled="props.disabled || isUploading" @click="fileInputRef?.click()"
}"
:disabled="props.disabled || isUploading"
@click="fileInputRef?.click()"
>
<slot>
<div class="text-primary p-0 border border-2 rounded-lg border-dashed flex-center size-full">
@@ -207,16 +303,19 @@ function onMove(index: number, direction: 'forward' | 'backward') {
</div>
<div v-if="!props.hideTips" class="text-xs text-card-foreground/50 flex flex-wrap gap-1 empty:hidden">
<div v-if="props.dimension" class="after:content-[';_'] last:after:content-empty">
{{ formatText('建议尺寸为 {width}*{height}', { width: props.dimension.width, height: props.dimension.height }) }}
建议尺寸为 {{ props.dimension.width }}*{{ props.dimension.height }}
</div>
<div v-if="props.ext.length > 0" class="after:content-[';_'] last:after:content-empty">
{{ formatText('支持 {ext} 格式', { ext: props.ext.join(' / ') }) }}
支持 {{ props.ext.join(' / ') }} 格式
</div>
<div v-if="!props.disabled" class="after:content-[';_'] last:after:content-empty">
支持粘贴上传
</div>
<div v-if="props.size > 0" class="after:content-[';_'] last:after:content-empty">
{{ formatText('大小不超过 {size}', { size: filesize(props.size, { standard: 'jedec' }) }) }}
大小不超过 {{ filesize(props.size, { standard: 'jedec' }) }}
</div>
<div v-if="props.max > 1" class="after:content-[';_'] last:after:content-empty">
{{ formatText('数量不超过 {max} 个', { max: props.max }) }}
数量不超过 {{ props.max }} 个
</div>
</div>
</div>