Skip to content

Commit 1c81115

Browse files
committed
feat: FaImageUpload / FaFileUpload 增加粘贴上传功能
1 parent e55e947 commit 1c81115

4 files changed

Lines changed: 329 additions & 115 deletions

File tree

‎packages/components/src/file-upload/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# FaFileUpload 文件上传组件
22

3-
支持拖拽和点击上传的文件上传组件,带进度显示和文件管理功能。
3+
支持拖拽、点击和粘贴上传的文件上传组件,带进度显示和文件管理功能。
44

55
## 使用场景
66

@@ -60,6 +60,10 @@ interface FileItem {
6060
| `onSuccess` | `response: any, file: File` | 单个文件上传成功时触发 |
6161
| `onClick` | `fileItem: FileItem, index: number` | 点击文件项时触发 |
6262

63+
## 粘贴上传
64+
65+
将鼠标移入当前组件,或通过 `Tab` 聚焦当前组件后,可直接按 `Ctrl+V` / `Cmd+V` 粘贴剪贴板中的文件。粘贴上传会复用组件现有的数量、格式和大小校验逻辑。
66+
6367
## 示例
6468

6569
### 基础用法

‎packages/components/src/file-upload/index.vue‎

100755100644
Lines changed: 142 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,83 @@ export interface FileItem {
4949
file?: File
5050
}
5151
52+
const containerRef = useTemplateRef<HTMLElement>('containerRef')
5253
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
5354
const isDragging = ref(false)
55+
const isHoveringContainer = ref(false)
56+
const isContainerFocused = ref(false)
57+
const isMaxReached = computed(() => props.max > 0 && fileList.value.length >= props.max)
58+
const canHandlePaste = computed(() =>
59+
!props.disabled
60+
&& !isMaxReached.value
61+
&& (isHoveringContainer.value || isContainerFocused.value),
62+
)
63+
const extAliasMap: Record<string, string[]> = {
64+
'jpeg': ['jpg', 'jpeg'],
65+
'jpg': ['jpg', 'jpeg'],
66+
'plain': ['txt'],
67+
'msword': ['doc'],
68+
'vnd.openxmlformats-officedocument.wordprocessingml.document': ['docx'],
69+
'vnd.ms-excel': ['xls'],
70+
'vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['xlsx'],
71+
'vnd.ms-powerpoint': ['ppt'],
72+
'vnd.openxmlformats-officedocument.presentationml.presentation': ['pptx'],
73+
'x-zip-compressed': ['zip'],
74+
'zip': ['zip'],
75+
'x-rar-compressed': ['rar'],
76+
'vnd.rar': ['rar'],
77+
'rar': ['rar'],
78+
'x-7z-compressed': ['7z'],
79+
}
80+
const acceptedExts = computed(() => new Set(props.ext.flatMap(ext => normalizeExtVariants(ext))))
81+
82+
function normalizeExt(ext?: string) {
83+
if (!ext) {
84+
return ''
85+
}
86+
return ext.toLowerCase().trim().replace(/^\./, '').split('+')[0] ?? ''
87+
}
88+
89+
function normalizeExtVariants(ext?: string) {
90+
const normalizedExt = normalizeExt(ext)
91+
if (!normalizedExt) {
92+
return []
93+
}
94+
return extAliasMap[normalizedExt] ?? [normalizedExt]
95+
}
5496
55-
function formatText(template: string, params: Record<string, string | number>) {
56-
return Object.entries(params).reduce((result, [key, value]) => {
57-
return result.replaceAll(`{${key}}`, String(value))
58-
}, template)
97+
function getFileExtVariants(file: File) {
98+
const extVariants = new Set<string>()
99+
const fileNameExt = file.name.split('.').pop()
100+
normalizeExtVariants(fileNameExt).forEach(ext => extVariants.add(ext))
101+
const mimeExt = file.type.split('/')[1]
102+
normalizeExtVariants(mimeExt).forEach(ext => extVariants.add(ext))
103+
return extVariants
104+
}
105+
106+
function validateFile(file: File) {
107+
if (acceptedExts.value.size > 0) {
108+
const fileExtVariants = getFileExtVariants(file)
109+
const isAccepted = [...fileExtVariants].some(ext => acceptedExts.value.has(ext))
110+
if (!isAccepted) {
111+
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式`, {
112+
description: file.name || file.type,
113+
})
114+
return false
115+
}
116+
}
117+
if (props.size > 0 && file.size > props.size) {
118+
toast.error(`上传文件大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`, {
119+
description: `${file.name || file.type} (${filesize(file.size, { standard: 'jedec' })})`,
120+
})
121+
return false
122+
}
123+
return true
59124
}
60125
61126
function handleDragOver(e: DragEvent) {
62127
e.preventDefault()
63-
if (props.disabled) {
128+
if (props.disabled || isMaxReached.value) {
64129
return
65130
}
66131
isDragging.value = true
@@ -73,43 +138,74 @@ function handleDragLeave(e: DragEvent) {
73138
74139
function handleDrop(e: DragEvent) {
75140
e.preventDefault()
76-
if (props.disabled) {
141+
if (props.disabled || isMaxReached.value) {
77142
return
78143
}
79144
isDragging.value = false
80145
if (e.dataTransfer?.files) {
81-
onSelectFile(e.dataTransfer.files)
146+
handleFiles(e.dataTransfer.files)
82147
}
83148
}
84149
85-
function onSelectFile(files: FileList | File[] | null) {
86-
if (!files) {
150+
function onPaste(e: ClipboardEvent) {
151+
if (e.defaultPrevented || !canHandlePaste.value) {
87152
return
88153
}
154+
const filesFromItems = [...(e.clipboardData?.items ?? [])]
155+
.filter(item => item.kind === 'file')
156+
.map(item => item.getAsFile())
157+
.filter((file): file is File => file !== null)
158+
const files = filesFromItems.length > 0
159+
? filesFromItems
160+
: [...(e.clipboardData?.files ?? [])]
161+
162+
if (files.length > 0 && handleFiles(files)) {
163+
e.preventDefault()
164+
}
165+
}
166+
167+
function onContainerFocusIn() {
168+
isContainerFocused.value = true
169+
}
170+
171+
function onContainerFocusOut(e: FocusEvent) {
172+
const nextFocusedElement = e.relatedTarget
173+
if (nextFocusedElement instanceof Node && containerRef.value?.contains(nextFocusedElement)) {
174+
return
175+
}
176+
isContainerFocused.value = false
177+
}
178+
179+
onMounted(() => {
180+
window.addEventListener('paste', onPaste)
181+
})
182+
183+
onUnmounted(() => {
184+
window.removeEventListener('paste', onPaste)
185+
})
186+
187+
function onSelectFile(files: FileList | File[] | null) {
188+
handleFiles(files)
189+
}
190+
191+
function handleFiles(files: FileList | File[] | null) {
192+
if (!files || props.disabled || isMaxReached.value) {
193+
return false
194+
}
89195
const selectedFiles = [...files]
90-
// 数量限制
91196
const remain = props.max > 0 ? props.max - fileList.value.length : selectedFiles.length
92-
const filesToAdd: File[] = []
93-
for (const file of selectedFiles.slice(0, remain)) {
94-
// 类型校验
95-
if (props.ext.length > 0) {
96-
const ext = file.name.split('.').pop()?.toLowerCase()
97-
if (!props.ext.map(e => e.toLowerCase()).includes(ext || '')) {
98-
toast.error(formatText('上传文件只支持 {ext} 格式', { ext: props.ext.join(' / ') }))
99-
continue
100-
}
101-
}
102-
// 大小校验
103-
if (props.size > 0) {
104-
if (file.size > props.size) {
105-
toast.error(formatText('上传文件大小不能超过 {size}', { size: filesize(props.size, { standard: 'jedec' }) }))
106-
continue
107-
}
108-
}
109-
filesToAdd.push(file)
197+
if (remain <= 0) {
198+
return false
199+
}
200+
if (fileInputRef.value) {
201+
fileInputRef.value.value = ''
202+
}
203+
const filesToAdd = selectedFiles.slice(0, remain).filter(validateFile)
204+
if (filesToAdd.length === 0) {
205+
return false
110206
}
111-
fileInputRef.value!.value = ''
112207
filesToAdd.forEach(file => uploadFile(file))
208+
return true
113209
}
114210
115211
function uploadFile(file: File, index?: number) {
@@ -167,15 +263,23 @@ function removeFile(idx: number) {
167263
</script>
168264

169265
<template>
170-
<div class="space-y-2">
266+
<div
267+
ref="containerRef"
268+
class="space-y-2"
269+
:tabindex="props.disabled ? undefined : 0"
270+
@mouseenter="isHoveringContainer = true"
271+
@mouseleave="isHoveringContainer = false"
272+
@focusin="onContainerFocusIn"
273+
@focusout="onContainerFocusOut"
274+
>
171275
<button
172276
type="button"
173277
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"
174278
:class="{
175279
'border-primary bg-primary/5': isDragging,
176-
'opacity-50 cursor-not-allowed': props.disabled || (props.max > 0 && fileList.length >= props.max),
280+
'opacity-50 cursor-not-allowed': props.disabled || isMaxReached,
177281
}"
178-
:disabled="props.disabled || (props.max > 0 && fileList.length >= props.max)"
282+
:disabled="props.disabled || isMaxReached"
179283
@dragover="handleDragOver"
180284
@dragleave="handleDragLeave"
181285
@drop="handleDrop"
@@ -191,13 +295,16 @@ function removeFile(idx: number) {
191295
</button>
192296
<div v-if="!props.hideTips && !props.disabled" class="text-xs text-card-foreground/50 flex flex-wrap gap-1 empty:hidden">
193297
<div v-if="props.ext.length > 0" class="after:content-[';_'] last:after:content-empty">
194-
{{ formatText('支持 {ext} 格式', { ext: props.ext.join(' / ') }) }}
298+
支持 {{ props.ext.join(' / ') }} 格式
299+
</div>
300+
<div class="after:content-[';_'] last:after:content-empty">
301+
支持粘贴上传
195302
</div>
196303
<div v-if="props.size > 0" class="after:content-[';_'] last:after:content-empty">
197-
{{ formatText('大小不超过 {size}', { size: filesize(props.size, { standard: 'jedec' }) }) }}
304+
大小不超过 {{ filesize(props.size, { standard: 'jedec' }) }}
198305
</div>
199306
<div v-if="props.max > 0" class="after:content-[';_'] last:after:content-empty">
200-
{{ formatText('数量不超过 {max} 个', { max: props.max }) }}
307+
数量不超过 {{ props.max }} 个
201308
</div>
202309
</div>
203310
<div v-if="fileList.length > 0" class="gap-2 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))]">

‎packages/components/src/image-upload/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# FaImageUpload 图片上传组件
22

3-
专门用于图片上传的组件,支持预览、排序和拖拽上传
3+
专门用于图片上传的组件,支持预览、排序和粘贴上传
44

55
## 使用场景
66

@@ -49,6 +49,10 @@
4949
|--------|------|------|
5050
| `onSuccess` | `response: any, file: File` | 单张图片上传成功时触发 |
5151

52+
## 粘贴上传
53+
54+
将鼠标移入当前组件,或通过 `Tab` 聚焦当前组件后,可直接按 `Ctrl+V` / `Cmd+V` 粘贴剪贴板中的图片。粘贴上传会复用组件现有的数量、格式和大小校验逻辑。
55+
5256
## 示例
5357

5458
### 基础用法

0 commit comments

Comments
 (0)