跳转到内容

文件拖入通用组件

示例

打开控制台查看输出结果

@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>

处理粘贴事件