文件拖入通用组件
示例
打开控制台查看输出结果
@ep_src_vue_generic_component_file_drop_Example1(./Example1.vue)
vue
<script setup lang="ts">
import { ref } from 'vue'
import FileDrop from './FileDrop/index.vue'
import FileDropDefaultContent from './FileDrop/DefaultContent.vue'
const active = ref(true)
</script>
<template>
<label>
<input type="checkbox" v-model="active" />
启用文件拖入
</label>
<FileDrop
mode="file"
:active="active"
@drop="(e) => console.log('drop', e)"
>
<FileDropDefaultContent />
</FileDrop>
</template>
@ep_src_vue_generic_component_file_drop_Example2(./Example2.vue)
vue
<script setup lang="ts">
import FileDrop from './FileDrop/index.vue'
import FileDropDefaultContent from './FileDrop/DefaultContent.vue'
</script>
<template>
<FileDrop mode="structure" @drop="(e) => console.log('drop', e)">
<FileDropDefaultContent tips="读取文件结构" />
</FileDrop>
</template>
@ep_src_vue_generic_component_file_drop_Example3(./Example3.vue)
vue
<script setup lang="ts">
import FileDrop from './FileDrop/index.vue'
import FileDropDefaultContent from './FileDrop/DefaultContent.vue'
</script>
<template>
<FileDrop
mode="structure"
@drop="(e) => console.log('drop', e)"
show-overlay
>
<FileDropDefaultContent
tips="显示遮罩层"
width="300px"
height="200px"
/>
</FileDrop>
</template>
@ep_src_vue_generic_component_file_drop_Example4(./Example4.vue)
vue
<script setup lang="ts">
import { ref } from 'vue'
import FileDrop from './FileDrop/index.vue'
const isDragging = ref(false)
</script>
<template>
<FileDrop
mode="structure"
@drop="(e) => console.log('drop', e)"
v-model:dragging="isDragging"
>
<div class="content">
自定义内容{{ isDragging ? '(文件拖入)' : '' }}
</div>
</FileDrop>
</template>
<style lang="css" scoped>
.content {
width: 300px;
height: 200px;
background-color: pink;
color: black;
display: flex;
align-items: center;
justify-content: center;
}
</style>
全局文件拖入
@ep_src_vue_generic_component_file_drop_Example5(./Example5.vue)
vue
<script setup lang="ts">
import { ref } from 'vue'
import GlobalFileDrop from './FileDrop/Global.vue'
const dropActive = ref(false)
</script>
<template>
<label>
<input type="checkbox" v-model="dropActive" />
启用全局文件拖入
</label>
<GlobalFileDrop
mode="structure"
@drop="(e) => console.log('drop', e)"
:active="dropActive"
/>
</template>
代码
./FileDrop/index.vue
vue
<script setup lang="ts" generic="T extends Mode">
import { computed, provide, ref, watchEffect } from 'vue'
import {
DropError,
getEntries,
getFiles,
parseStructure,
FileStructure,
filterFilesByAccept,
} from './utils'
import type { Mode, DropResult } from './utils'
interface Props {
/**
* 文件拖拽模式
* - `file`: 文件模式
* - `structure`: 文件结构模式
*/
mode: T
/**
* 是否撑满父元素
*/
full?: boolean
/**
* 是否显示遮罩层
*/
showOverlay?: boolean
/**
* 遮罩层标题
*/
overlayTitle?: string
/**
* 遮罩层描述
*/
overlayDesc?: string
/**
* 遮罩层 z-index
* @default 3000
*/
overlayZIndex?: number
/**
* v-model:dragging
*/
dragging?: boolean
/**
* 是否激活拖拽
*/
active?: boolean
/**
* 允许的文件类型(仅在文件模式下生效)
*
* 规范: 逗号分隔,以点开头为匹配文件后缀,否则为匹配 MIME 类型
*
* @example "image/*,.pdf" // 匹配所有图片或pdf
*/
accept?: string
}
const props = withDefaults(defineProps<Props>(), {
full: false,
showOverlay: false,
overlayTitle: '拖拽文件到此区域',
overlayDesc: '支持单个或批量文件',
overlayZIndex: 3000,
active: true,
})
const emit = defineEmits<{
(e: 'drop', payload: DropResult<T>): void
(e: 'error', error: DropError): void
(e: 'update:dragging', dragging: boolean): void
(e: 'update:parsing', parsing: boolean): void
}>()
const isDragging = ref(false)
const isParsing = ref(false)
// 提供给子组件
provide('mode', props.mode)
provide('isDragging', isDragging)
provide('isParsing', isParsing)
// 提供给父组件 v-model:dragging
watchEffect(() => emit('update:dragging', isDragging.value))
// 提供给父组件 v-model:parsing
watchEffect(() => emit('update:parsing', isParsing.value))
const wrapperClassNames = computed(() => ({
'full-size': props.full,
dragging: isDragging.value,
}))
const handleDrop = async (e: DragEvent) => {
if (!props.active) return
e.preventDefault()
isDragging.value = false
const files = getFiles(e)
const entries = getEntries(e)
const payload: DropResult<T> = {
result: [],
structureNotSupported: false,
hasDirectories: false,
filtered: [],
}
type Result = DropResult<T>['result']
if (props.mode === 'structure') {
if (entries.length === 0) return
isParsing.value = true
try {
const structures = await parseStructure(entries)
payload.result = structures as Result
payload.hasDirectories = structures.some((file) =>
file.isDirectory(),
)
} catch (_) {
if (files.length === 0) return
// 出现错误说明不支持 webkitGetAsEntry API,直接返回没有层级的文件结构列表
payload.structureNotSupported = true
payload.result = files.map(
(file) => new FileStructure(file),
) as unknown as Result
}
isParsing.value = false
} else if (props.mode === 'file' && props.accept) {
// 文件模式且配置了 accept prop
const { allowed, invalid } = filterFilesByAccept(files, props.accept)
payload.result = allowed as Result
payload.filtered = invalid
} else {
if (files.length === 0) return
payload.result = files as Result
}
emit('drop', payload)
}
const handleDragOver = (e: DragEvent) => {
if (!props.active) return
e.preventDefault()
isDragging.value = true
}
const handleDragLeave = (e: DragEvent) => {
if (!props.active) return
const dropZone = e.currentTarget as HTMLElement
const relatedTarget = e.relatedTarget as HTMLElement | null
// 判断是否仍在拖拽区域内,避免移动到子元素时误触发 dragleave
if (!relatedTarget || !dropZone.contains(relatedTarget)) {
isDragging.value = false
}
}
</script>
<template>
<div
:class="['drop-zone', wrapperClassNames]"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="handleDrop"
>
<slot :is-dragging="isDragging" :is-parsing="isParsing"></slot>
<div
class="overlay"
:class="{ show: showOverlay && isDragging }"
:style="{
zIndex: overlayZIndex,
}"
>
<slot
name="overlay"
:is-dragging="isDragging"
:is-parsing="isParsing"
></slot>
<div class="default-overlay">
<div class="overlay-title">{{ overlayTitle }}</div>
<div class="overlay-desc">{{ overlayDesc }}</div>
</div>
</div>
</div>
</template>
<style lang="css" scoped>
.drop-zone {
position: relative;
width: fit-content;
height: fit-content;
overflow: hidden;
}
.drop-zone.full-size {
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--overlay-bg, rgba(0, 0, 0, 0.8));
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 9;
transition: opacity var(--overlay-transition-duration, 0.2s) ease-in-out;
opacity: 0;
pointer-events: none;
}
.overlay.show {
opacity: 1;
pointer-events: auto;
}
.default-overlay {
text-align: center;
}
.overlay-title {
color: var(--overlay-title-color, #f5f5f5);
font-size: var(--overlay-title-font-size, 16px);
}
.overlay-desc {
color: var(--overlay-desc-color, #999);
font-size: var(--overlay-desc-font-size, 12px);
}
</style>
./FileDrop/Global.vue
vue
<script setup lang="ts" generic="T extends Mode">
import { onBeforeUnmount, onMounted, provide, ref, watchEffect } from 'vue'
import {
DropError,
getEntries,
getFiles,
parseStructure,
FileStructure,
filterFilesByAccept,
} from './utils'
import type { Mode, DropResult } from './utils'
interface Props {
/**
* 文件拖拽模式
* - `file`: 文件模式
* - `structure`: 文件结构模式
*/
mode: T
/**
* 是否显示遮罩层
*/
showOverlay?: boolean
/**
* 遮罩层标题
*/
overlayTitle?: string
/**
* 遮罩层描述
*/
overlayDesc?: string
/**
* 遮罩层 z-index
* @default 3000
*/
overlayZIndex?: number
/**
* v-model:dragging
*/
dragging?: boolean
/**
* 是否激活拖拽
*/
active?: boolean
/**
* 允许的文件类型(仅在文件模式下生效)
*
* 规范: 逗号分隔,以点开头为匹配文件后缀,否则为匹配 MIME 类型
*
* @example "image/*,.pdf" // 匹配所有图片或pdf
*/
accept?: string
}
const props = withDefaults(defineProps<Props>(), {
showOverlay: true,
overlayTitle: '拖拽文件到此区域',
overlayDesc: '支持单个或批量文件',
overlayZIndex: 3000,
active: true,
})
const emit = defineEmits<{
(e: 'drop', payload: DropResult<T>): void
(e: 'error', error: DropError): void
(e: 'update:dragging', dragging: boolean): void
(e: 'update:parsing', parsing: boolean): void
}>()
const isDragging = ref(false)
const isParsing = ref(false)
// 提供给子组件
provide('mode', props.mode)
provide('isDragging', isDragging)
provide('isParsing', isParsing)
// 提供给父组件 v-model:dragging
watchEffect(() => emit('update:dragging', isDragging.value))
// 提供给父组件 v-model:parsing
watchEffect(() => emit('update:parsing', isParsing.value))
const handleDrop = async (e: DragEvent) => {
if (!props.active) return
e.preventDefault()
isDragging.value = false
const files = getFiles(e)
const entries = getEntries(e)
const payload: DropResult<T> = {
result: [],
structureNotSupported: false,
hasDirectories: false,
filtered: [],
}
type Result = DropResult<T>['result']
if (props.mode === 'structure') {
if (entries.length === 0) return
isParsing.value = true
try {
const structures = await parseStructure(entries)
payload.result = structures as Result
payload.hasDirectories = structures.some((file) =>
file.isDirectory(),
)
} catch (_) {
if (files.length === 0) return
// 出现错误说明不支持 webkitGetAsEntry API,直接返回没有层级的文件结构列表
payload.structureNotSupported = true
payload.result = files.map(
(file) => new FileStructure(file),
) as unknown as Result
}
isParsing.value = false
} else if (props.mode === 'file' && props.accept) {
// 文件模式且配置了 accept prop
const { allowed, invalid } = filterFilesByAccept(files, props.accept)
payload.result = allowed as Result
payload.filtered = invalid
} else {
if (files.length === 0) return
payload.result = files as Result
}
emit('drop', payload)
}
const handleDragOver = (e: DragEvent) => {
if (!props.active) return
e.preventDefault()
isDragging.value = true
}
const handleDragLeave = (e: DragEvent) => {
if (!props.active) return
const dropZone = e.currentTarget as HTMLElement
const relatedTarget = e.relatedTarget as HTMLElement | null
// 判断是否仍在拖拽区域内,避免移动到子元素时误触发 dragleave
if (!relatedTarget || !dropZone.contains(relatedTarget)) {
isDragging.value = false
}
}
onMounted(() => {
document.addEventListener('dragover', handleDragOver)
document.addEventListener('dragleave', handleDragLeave)
document.addEventListener('drop', handleDrop)
})
onBeforeUnmount(() => {
document.removeEventListener('dragover', handleDragOver)
document.removeEventListener('dragleave', handleDragLeave)
document.removeEventListener('drop', handleDrop)
})
</script>
<template>
<Teleport to="body">
<div
class="drop-zone-global-overlay"
:class="{ show: showOverlay && isDragging }"
:style="{
zIndex: overlayZIndex,
}"
>
<slot :is-dragging="isDragging" :is-parsing="isParsing"></slot>
<div class="default-overlay">
<div class="overlay-title">{{ overlayTitle }}</div>
<div class="overlay-desc">{{ overlayDesc }}</div>
</div>
</div>
</Teleport>
</template>
<style lang="css" scoped>
.drop-zone-global-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--overlay-bg, rgba(0, 0, 0, 0.8));
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 9;
transition: opacity var(--overlay-transition-duration, 0.2s) ease-in-out;
opacity: 0;
pointer-events: none;
}
.drop-zone-global-overlay.show {
opacity: 1;
pointer-events: auto;
}
.default-overlay {
text-align: center;
}
.overlay-title {
color: var(--overlay-title-color, #f5f5f5);
font-size: var(--overlay-title-font-size, 16px);
}
.overlay-desc {
color: var(--overlay-desc-color, #999);
font-size: var(--overlay-desc-font-size, 12px);
}
</style>
./FileDrop/utils.ts
ts
// utils.ts
import { inject, ref, type Ref } from 'vue'
export type Mode = 'file' | 'structure'
export class DropError extends Error {
readonly code: string
constructor(message: string, code: string) {
super(message)
this.name = 'DropError'
this.code = code
}
}
export class Structure {
readonly name: string
constructor(name: string) {
this.name = name
}
isFile(): this is FileStructure {
return this instanceof FileStructure
}
isDirectory(): this is DirectoryStructure {
return this instanceof DirectoryStructure
}
}
export class FileStructure extends Structure {
readonly file: File
constructor(file: File) {
super(file.name)
this.file = file
}
}
export class DirectoryStructure extends Structure {
readonly children: Structure[]
constructor(name: string, children: Structure[]) {
super(name)
this.children = children
}
}
export interface DropResult<T extends Mode> {
/**
* 拖拽的文件或文件结构
*/
result: T extends 'structure' ? Structure[] : File[]
/**
* 在文件结构模式中,是否支持读取文件结构
*/
structureNotSupported: boolean
/**
* 在文件结构模式中,是否包含文件夹
*/
hasDirectories: boolean
/**
* 过滤后的文件列表(在文件模式下,通过配置 `accept` 参数,过滤掉的文件)
*/
filtered: File[]
}
/**
* 拖拽处理函数类型
*/
export type HandleDrop<T extends Mode> = (payload: DropResult<T>) => void
export const getFiles = (e: DragEvent): File[] => {
return Array.from(e.dataTransfer?.files || [])
}
export const getEntries = (e: DragEvent): FileSystemEntry[] => {
return Array.from(e.dataTransfer?.items || [])
.map((item) => item.webkitGetAsEntry())
.filter(Boolean) as FileSystemEntry[]
}
/**
* 解析文件结构
*/
export const parseStructure = async (
entries: FileSystemEntry[],
): Promise<Structure[]> => {
const result: Structure[] = []
for (const entry of entries) {
if (!entry) continue
if (entry.isDirectory) {
const directory = entry as FileSystemDirectoryEntry
const directoryReader = directory.createReader()
const children = await new Promise<Structure[]>((resolve) => {
directoryReader.readEntries((entries) =>
resolve(parseStructure(entries)),
)
})
result.push(new DirectoryStructure(directory.name, children))
} else {
const file = entry as FileSystemFileEntry
const fileStructure = await new Promise<FileStructure>(
(resolve, reject) => {
file.file((file) => resolve(new FileStructure(file)), reject)
},
)
result.push(fileStructure)
}
}
return result
}
/**
* 从父组件注入 `isDragging` ref。
*/
export const injectDragging = () => {
return inject<Ref<boolean>>('isDragging') || ref(false)
}
export const filterFilesByAccept = (
files: File[],
accept?: string,
): {
allowed: File[]
invalid: File[]
} => {
if (!accept) return { allowed: files, invalid: [] }
const allowedTypes = accept.split(',').map((t) => t.trim())
const allowed: File[] = [] // 符合的
const invalid: File[] = [] // 不符合的
for (const file of files) {
const matched = allowedTypes.some((type) => {
// 处理扩展名匹配 (如 .pdf)
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type.toLowerCase())
}
// 处理 MIME 类型匹配 (如 image/*)
const [category, subtype] = type.split('/')
const regex = new RegExp(
`${category}/${subtype === '*' ? '.*' : subtype}$`,
)
return regex.test(file.type)
})
if (matched) {
allowed.push(file)
} else {
invalid.push(file)
}
}
return { allowed, invalid }
}
./FileDrop/DefaultContent.vue
vue
<!-- DefaultContent.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { injectDragging } from './utils'
const props = withDefaults(
defineProps<{
size?: number | string
width?: number | string
height?: number | string
tips?: string
}>(),
{
size: '128px',
tips: '拖拽文件到此区域',
},
)
const isDragging = injectDragging()
const addUnit = (val: number | string): string => {
if (typeof val === 'number') {
return `${val}px`
}
return val
}
const style = computed(() => {
return {
width: addUnit(props.width || props.size),
height: addUnit(props.height || props.size),
}
})
</script>
<template>
<div class="file-drop__default-content" :style="style" :class="{ dragging: isDragging }">
<span v-if="tips">{{ tips }}</span>
<slot></slot>
</div>
</template>
<style lang="css" scoped>
.file-drop__default-content {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #ccc;
border-radius: 4px;
color: #999;
font-size: 14px;
user-select: none;
}
.file-drop__default-content.dragging {
border: 1px dashed #409eff;
}
</style>