Vue3+vite+Ts实现文件/文件夹拖拽上传图片,可预览(基于t-design二次封装,直接一键copy,无需动脑)

197 阅读4分钟

一、封装背景

常用的组件库element-ui和t-design都只有拖拽文件,没有实现文件夹拖拽。拖拽文件夹有个限制:正常情况下只能读取100个文件,下面代码通过递归遍历解决了该问题。

参考地址blog.csdn.net/m0_52009348…

二、效果图

image.png

image.png

三、实现

1.先配置好t-design组件库,参考官方文档Vue Next for Web | TDesign (tencent.com)

2.代码实现

<script setup lang="ts">
import { ref } from "vue";
import { BrowseIcon } from "tdesign-icons-vue-next";
import { MessagePlugin } from "tdesign-vue-next";
const fileArrPromiseResolve =
  ref<(value: File[] | PromiseLike<File[]>) => void>();
const fileArrPromise = ref<Promise<File[]>>();
const dropType = ref(false);
const pictureBoxLock = ref(false);
const fileList = ref<Array<File>>([]);
const isShow = ref(true);
const initFileArrPromise = () => {
  fileArrPromise.value = new Promise((resolve) => {
    fileArrPromiseResolve.value = resolve;
  });
};
// 文件拖拽dragenter进入
const handlePictureBoxDragenter = () => {
  // 这里传入一个10ms的延迟锁,让dragleave在enter延迟后触发
  pictureBoxLock.value = true;
  setTimeout(() => {
    pictureBoxLock.value = false;
  }, 10);
  dropType.value = true;
};

// 文件拖拽dragleave离开
const handlePictureBoxDragleave = () => {
  // 多张图片拖拽进入的延迟锁时返回
  if (pictureBoxLock.value) return;
  dropType.value = false;
};

// 文件拖拽drop落下
const handlePictureBoxDrop = async (e: DragEvent) => {
  dropType.value = false;
  // 判断file是文件夹还是文件
  const isDirectory = e.dataTransfer?.items[0].webkitGetAsEntry()?.isDirectory;
  // 这里的file给了一个初始值,如果是单张或多张图片拖拽可以直接获取其files属性
  let file: File[] = Array.from(e.dataTransfer?.files ?? []);
  if (isDirectory) {
    // 这里对promise进行一个初始化
    initFileArrPromise();
    // 这里获取到文件的一个file信息
    const fileItems: FileSystemDirectoryEntry =
      e.dataTransfer.items[0].webkitGetAsEntry() as FileSystemDirectoryEntry;
    // 这里是对fileItems处理,返回的是所有文件的数组
    file = (await getFileDirectory(fileItems)) as File[];
  }
  // 如果未能读取到想要的图片或者文件夹为空,都直接提示错误返回
  if (!file || !file.length) {
    MessagePlugin.error("图片上传错误");
    return;
  }
  fileList.value = fileList.value.concat(file);
  isShow.value = false;
};

// 这里对文件处理通过promise进行处理,为了确保文件夹中最后一个文件读取完成后再进行操作
const getFileDirectory = (fileItems: FileSystemDirectoryEntry) => {
  return new Promise((resolve) => {
    fileTypeLoop(fileItems, []);
    fileArrPromise.value?.then((files: File[]) => {
      resolve(files);
    });
  });
};
// 文件夹上传解析,进行了递归和类型划分,返回fileArr数组
const fileTypeLoop = (
  fileItem: FileSystemFileEntry | FileSystemDirectoryEntry,
  fileArr: File[],
  loopOver = false
) => {
  let dirReader: FileSystemDirectoryReader | null = null;
  // 如果fileItem是文件而不是文件夹进入
  if (fileItem.isFile) {
    (fileItem as FileSystemFileEntry).file((file: File) => {
      // fileTypeFilter方法是对你想要哪一类型的文件,我这里只想要图片格式
      const fileFilter =
        file.type &&
        "image/gif,image/jpeg,image/jpg,image/png,image/bmp".indexOf(
          file.type
        ) > -1;
      if (fileFilter) {
        // 重新创建file数据格式,加入type和path放入到fileArr中
        const newFile = new File([file], file.name, { type: file.type });
        fileArr.push(newFile);
      }
      // 如果文件夹读到最后一个时,将fileArr数组通过resolve返回出去
      if (loopOver) fileArrPromiseResolve.value!(fileArr);
    });
  } else if (fileItem.isDirectory) {
    // 如果fileItem是文件夹,读取文件夹内容再进行处理
    dirReader = (fileItem as FileSystemDirectoryEntry).createReader();
    dirReader.readEntries(onReadEntries);
  }
  // 通过递归解析出文件夹中所有文件,readEntries正常只能读出100个文件
  function onReadEntries(entries: FileSystemEntry[]) {
    for (let i = 0; i < entries.length; i++) {
      // 判断是否是最后一个文件,是的话就让loopOver为true
      if (
        i === entries.length - 1 &&
        !entries[i].isDirectory &&
        entries.length < 100
      )
        loopOver = true;
      // 进行递归处理
      fileTypeLoop(entries[i] as FileSystemDirectoryEntry, fileArr, loopOver);
    }
    // 如果entries.length则说明文件中可能不止100个文件,这个时候需要继续嵌套读取
    if (entries.length) dirReader?.readEntries(onReadEntries);
  }
};

//图片地址转化
function getImageAddress(file: File) {
  return window.URL.createObjectURL(file);
}

const delAllImages = () => {
  fileList.value = [];
  isShow.value = true;
};
</script>

<template>
  <div class="image-container">
    <div
      v-if="isShow"
      class="image-upload"
      :class="[{ 'drop-border': dropType }]"
      @dragenter.stop.prevent="handlePictureBoxDragenter()"
      @dragleave.stop.prevent="handlePictureBoxDragleave"
      @drop.stop.prevent="handlePictureBoxDrop($event)"
    >
      <SIcon :icon="'file-upload'" :size="50" />
      <p>将文件/文件夹拖拽到此处</p>
    </div>
    <!-- 图片显示预览 -->
    <div v-else class="image-list">
      <div
        v-for="img in fileList"
        :key="img.name"
        class="tdesign-demo-image-viewer__base"
      >
        <t-image-viewer :images="[getImageAddress(img)]">
          <template #trigger="{ open }">
            <div class="tdesign-demo-image-viewer__ui-image">
              <img
                alt="test"
                :src="getImageAddress(img)"
                class="tdesign-demo-image-viewer__ui-image--img"
              />
              <div
                class="tdesign-demo-image-viewer__ui-image--hover"
                @click="open"
              >
                <span><BrowseIcon size="1.4em" /> 预览</span>
              </div>
            </div>
          </template>
        </t-image-viewer>
      </div>
    </div>
    <div v-if="fileList.length" class="continue-upload">
      <div class="image-continue-upload">
        <div
          class="image-upload image-upload-little"
          :class="[{ 'drop-border': dropType }]"
          @dragenter.stop.prevent="handlePictureBoxDragenter()"
          @dragleave.stop.prevent="handlePictureBoxDragleave"
          @drop.stop.prevent="handlePictureBoxDrop($event)"
        >
          <p>继续添加文件,将文件/文件夹拖拽到此处</p>
        </div>
      </div>
      <div class="del-all-btn" @click="delAllImages()">
        <SIcon :icon="'del-icon'" :size="16" />
        <span>清空</span>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.image-container {
  width: 600px;
  height: 300px;
  position: relative;
  background-color: #f9f9f9;
  .continue-upload {
    width: 100%;
    height: 60px;
    background-color: #f2f2f2;
    position: absolute;
    bottom: 0;
    display: flex;
    .image-continue-upload {
      flex: 1;
      .image-upload-little {
        background-color: #f2f2f2;
      }
    }
    .del-all-btn {
      width: 60px;
      height: 100%;
      display: flex;
      justify-content: center;
      align-items: center;
      cursor: pointer;
    }
  }
}
.image-upload {
  width: 100%;
  height: 100%;
  background-color: #f9f9f9;
  border: 1px dashed #dcdcdc;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  &:hover {
    border-color: #0052d9;
  }
  p {
    color: #00000099;
    font-family: "微软雅黑";
  }
}
.drop-border {
  border-color: #0052d9;
}

.image-list {
  width: 100%;
  height: calc(100% - 60px);
  display: flex;
  flex-wrap: wrap;
  overflow-y: scroll;
}
.image-list::-webkit-scrollbar {
  width: 8px;
}

.image-list::-webkit-scrollbar-thumb {
  border-radius: 10px;
  box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.1);
  background: #c7ddfc;
}

.image-list::-webkit-scrollbar-track {
  box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.1);
  border-radius: 0;
  background: #c7ddfc;
}
.image-list::-webkit-scrollbar-track-piece {
  background: #eff3f8;
}

//图片预览样式
.tdesign-demo-image-viewer__ui-image {
  width: 100%;
  height: 100%;
  display: inline-flex;
  position: relative;
  justify-content: center;
  align-items: center;
  border-radius: var(--td-radius-small);
  overflow: hidden;
}
.tdesign-demo-image-viewer__ui-image {
  width: 100%;
  height: 100%;
  display: inline-flex;
  position: relative;
  justify-content: center;
  align-items: center;
  border-radius: var(--td-radius-small);
  overflow: hidden;
}

.tdesign-demo-image-viewer__ui-image--hover {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  position: absolute;
  left: 0;
  top: 0;
  opacity: 0;
  background-color: rgba(0, 0, 0, 0.6);
  color: var(--td-text-color-anti);
  line-height: 22px;
  transition: 0.2s;
}

.tdesign-demo-image-viewer__ui-image:hover
  .tdesign-demo-image-viewer__ui-image--hover {
  opacity: 1;
  cursor: pointer;
}

.tdesign-demo-image-viewer__ui-image--img {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  cursor: pointer;
  position: absolute;
}

.tdesign-demo-image-viewer__ui-image--footer {
  padding: 0 16px;
  height: 56px;
  width: 100%;
  line-height: 56px;
  font-size: 16px;
  position: absolute;
  bottom: 0;
  color: var(--td-text-color-anti);
  background-image: linear-gradient(
    0deg,
    rgba(0, 0, 0, 0.4) 0%,
    rgba(0, 0, 0, 0) 100%
  );
  display: flex;
  box-sizing: border-box;
}

.tdesign-demo-image-viewer__ui-image--title {
  flex: 1;
}

.tdesign-demo-popup__reference {
  margin-left: 16px;
}

.tdesign-demo-image-viewer__ui-image--icons .tdesign-demo-icon {
  cursor: pointer;
}

.tdesign-demo-image-viewer__base {
  width: 160px;
  height: 100px;
  margin: 10px;
  border: 4px solid var(--td-bg-color-secondarycontainer);
  border-radius: var(--td-radius-medium);
}
</style>