@@ -49,18 +49,83 @@ export interface FileItem {
4949 file? : File
5050}
5151
52+ const containerRef = useTemplateRef <HTMLElement >(' containerRef' )
5253const fileInputRef = useTemplateRef <HTMLInputElement >(' fileInputRef' )
5354const 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
61126function 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
74139function 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
115211function 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))]" >
0 commit comments