封装上传组件,提供各种校验、显示预览、排序等功能

这里写目录标题

  • [0 相关介绍](#0 相关介绍)
  • [1 实现文件选择器组件](#1 实现文件选择器组件)
    • [1.1 实现点击选择文件](#1.1 实现点击选择文件)
    • [1.2 实现文件拖拽选择](#1.2 实现文件拖拽选择)
    • [1.3 提供显示隐藏功能](#1.3 提供显示隐藏功能)
    • [1.4 组件完整代码](#1.4 组件完整代码)
  • [2 文件展示及文件信息处理组件](#2 文件展示及文件信息处理组件)
    • [2.1 文件展示](#2.1 文件展示)
    • [2.2 给每个文件添加功能icon](#2.2 给每个文件添加功能icon)
    • [2.3 引入配套模块](#2.3 引入配套模块)
    • [2.4 添加文件处理方法](#2.4 添加文件处理方法)
    • [2.5 暴露常用内部函数](#2.5 暴露常用内部函数)
    • [2.6 组件完整代码](#2.6 组件完整代码)
  • [3 文件预览组件](#3 文件预览组件)
    • [3.1 实现视频播放器](#3.1 实现视频播放器)
    • [3.2 实现图片和视频预览](#3.2 实现图片和视频预览)
  • [4 index组件](#4 index组件)
    • [4.1 接收props,整合数据](#4.1 接收props,整合数据)
    • [4.2 提供文件校验方法](#4.2 提供文件校验方法)
    • [4.3 完整代码](#4.3 完整代码)
  • [5 定义数据类型](#5 定义数据类型)
  • [6 工具方法](#6 工具方法)
  • [7 其他工具方法](#7 其他工具方法)
    • [7.1 上传方法](#7.1 上传方法)
    • [7.2 文件路径拼接方法](#7.2 文件路径拼接方法)

0 相关介绍

部分样式依赖于element-plus框架,视频播放器使用西瓜视频播放器组件

相关能力

  • 提供图片、音频、视频的预览功能
  • 提供是否为空、文件类型、文件大小、文件数量、图片宽高校验
  • 提供图片回显功能
  • 提供在达到数量限制和禁用时不显示文件选择器的能力
  • 提供点击上传和拖拽上传
  • 提供文件排序功能

效果图

效果图跟这篇文章中基本一致

相关文档

1 实现文件选择器组件

1.1 实现点击选择文件

由于html提供的input的文件选择器有点丑陋,并且样式改起来十分的复杂,所以通过其他的标签触发文件选择器

  • 隐藏input标签
  • 定义容器
  • 给容器绑定方法
  • 绑定方法主动触发input的click事件

1.2 实现文件拖拽选择

通过监听元素的drop事件实现对于多拽文件的读取功能,并实现和点击读取文件同样的效果

  • 监听dragover事件,并阻止事件的默认行为(避免文件在浏览器中打开),增加容器的获取焦点样式
  • 监听dragleave事件,删除容器的获取焦点样式
  • 监听drop事件,阻止事件的默认行为,对于获取到文件进行类型过滤,对多选进行判断

1.3 提供显示隐藏功能

在文件超出数量超出限制的时候,或者disabled生效的时候,需要隐藏文件选择组件的存在

1.4 组件完整代码

javascript 复制代码
<!-- 文件选择器组件 -->
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { UploadFile } from "./types";
const props = defineProps({
  accept: {
    type: String,
    default: "*"
  },
  multiple: {
    type: Boolean,
    default: false
  },
  disabled: {
    type: Boolean,
    default: false
  }
});
if (props.disabled) hide();
const fileInput = ref();

function clickButton() {
  fileInput.value.click();
}
const emit = defineEmits(["addFile"]);
function changeFile(e) {
  handleFile(e.srcElement.files);
}
// 格式化文件数据
function handleFile(files) {
  files.forEach(item => {
    const uploadFile: UploadFile = {
      name: item.name,
      percentage: 0,
      raw: item,
      size: item.size,
      status: "success",
      uid: Date.now(),
      url: URL.createObjectURL(item)
    };
    emit("addFile", uploadFile);
  });
}
const fileTarget = ref();
onMounted(() => {
  // 监听拖拽文件结束事件
  fileTarget.value.addEventListener("dragover", e => {
    e.preventDefault();
    if (props.disabled) return;
    fileTarget.value.classList.add("drop-hover");
  });
  // 监听拖拽文件离开事件
  fileTarget.value.addEventListener("dragleave", () => {
    if (props.disabled) return;
    fileTarget.value.classList.remove("drop-hover");
  });
  // 监听文件拖拽进目标事件
  fileTarget.value.addEventListener("drop", e => {
    e.preventDefault();
    if (props.disabled) return;
    // 判断拖拽进来的文件是否符合文件类型需求
    const files = Array.from(e.dataTransfer.files).filter((item: File) => {
      return props.accept.includes(item.name.split(".").pop());
    });
    if (files.length > 0) {
      if (!props.multiple) handleFile([files[0]]);
      else handleFile(files);
    }
    // 拖拽结束后,移除样式
    fileTarget.value.classList.remove("drop-hover");
  });
});

const isShow = ref(true);
function show() {
  isShow.value = true;
}
function hide() {
  isShow.value = false;
}
defineExpose({
  show,
  hide
});
</script>

<template>
  <div v-if="isShow">
    <div
      class="el-upload--picture-card mb-[25px]"
      @click="clickButton"
      ref="fileTarget"
    >
      <el-icon><Plus /></el-icon>
    </div>
    <input
      type="file"
      ref="fileInput"
      :accept="accept"
      :multiple="multiple"
      style="display: none"
      @change="changeFile"
    />
  </div>
</template>

<style lang="scss" scoped>
.drop-hover {
  border-color: var(--el-color-primary);
}
</style>

2 文件展示及文件信息处理组件

2.1 文件展示

根据不同的文件选择不同的展示方式

  • 图片:使用与element-plus中上传组件相同的样式直接展示图片
  • 视频:使用与图片相同的样式展示视频封面
  • 音频:展示音频名称,如果没有音频则展示默认音频图片
  • 文件下方显示文件的名称和顺序

2.2 给每个文件添加功能icon

  • 使用绝对定位,在文件展示区上放置一层功能区域容器
  • 在获取到焦点的时候通过改变透明度展示出来

2.3 引入配套模块

文件预览、文件选择与文件展示组件交互非常多,所以将两个组件作为文件展示组件的子组件引入

  • 引入文件选择组件,对接添加本地文件的方法
  • 引入文件预览组件,在点击预览按钮的时候调用其方法
  • 增加修改顺序弹窗和方法

2.4 添加文件处理方法

  • 提供截取视频第一帧作为封面图方法
  • 提供获取音频时长信息方法
  • 提供获取图片宽高信息方法

2.5 暴露常用内部函数

  • 暴露获取文件对象方法
  • 暴露清空文件列表方法
  • 暴露添加线上文件方法(主要功能通过url加载文件,并对其信息进行格式化处理)
  • 暴露文件预览功能方法
  • 暴露删除单个文件方法

2.6 组件完整代码

javascript 复制代码
<!-- 显示组件 -->
<script setup lang="ts">
import { getTypes } from "@/utils/preview";
import { UploadFilePlus, UploadFile } from "./types";
import { ref } from "vue";
import { message } from "@/utils/message";
import { Delete, ZoomIn, EditPen } from "@element-plus/icons-vue";
import previewCom from "./preview.vue";
import choiceFile from "./choiceFile.vue";
const choiceFileRef = ref();
const previewRef = ref();
const props = defineProps({
  disabled: {
    type: Boolean,
    default: false
  },
  // 数量限制
  limit: {
    type: Number,
    default: 1
  },
  accept: {
    type: String,
    default: "*"
  },
  multiple: {
    type: Boolean,
    default: false
  }
});

const dataList = ref([]);
const emit = defineEmits(["changeFileList", "verifyLength"]);
async function add(uploadFile: UploadFilePlus) {
  // 如果已经超出数量限制,则不再处理
  if (dataList.value.length >= props.limit) return;
  // 如果是远程文件不需要任何处理
  if (!uploadFile.name) return;
  // 如果被处理过则不再处理
  if (uploadFile.processed) return;
  try {
    const suffix = uploadFile.name.split(".").at(-1);
    const type = getTypes(suffix);
    uploadFile.type = type;
    if (type === "image") {
      uploadFile.viewUrl = uploadFile.url;
      const res = (await findImageDetail(uploadFile.url)) as {
        width: number;
        height: number;
      };
      uploadFile.width = res.width;
      uploadFile.height = res.height;
    } else if (type === "video") {
      const res = (await findvideodetail(uploadFile.url)) as {
        viewUrl: string;
        duration: number;
      };
      uploadFile.viewUrl = res.viewUrl;
      uploadFile.duration = res.duration;
    } else if (type === "audio") {
      const res = (await findAudioDetail(uploadFile.url)) as {
        duration: number;
      };
      uploadFile.viewUrl = new URL(
        "@/assets/goods/audio.svg",
        import.meta.url
      ).href;
      uploadFile.duration = res.duration;
    } else {
      // 其他类型直接上传
      uploadFile.type = "other";
      uploadFile.viewUrl = uploadFile.url;
    }

    uploadFile.needUpload = true;
    uploadFile.processed = true;
  } catch (e) {
    console.error(e);
    message(e, {
      type: "error"
    });
  }
  dataList.value.push(uploadFile);
  emit("changeFileList", uploadFile, "add");
  verifyLength();
}
//截取视频第一帧作为播放前默认图片
function findvideodetail(url) {
  const video = document.createElement("video"); // 也可以自己创建video
  video.src = url; // url地址 url跟 视频流是一样的
  const canvas = document.createElement("canvas"); // 获取 canvas 对象
  const ctx = canvas.getContext("2d"); // 绘制2d
  video.crossOrigin = "anonymous"; // 解决跨域问题,也就是提示污染资源无法转换视频
  video.currentTime = 1; // 第一帧
  return new Promise((resolve, reject) => {
    video.oncanplaythrough = () => {
      setTimeout(() => {
        canvas.width = video.clientWidth || video.videoWidth || 320; // 获取视频宽度
        canvas.height = video.clientHeight || video.videoHeight || 240; //获取视频高度
        // 利用canvas对象方法绘图
        ctx!.drawImage(video, 0, 0, canvas.width, canvas.height);
        // 转换成base64形式
        const viewUrl = canvas.toDataURL("image/png"); // 截取后的视频封面
        resolve({
          viewUrl: viewUrl,
          duration: Math.floor(video.duration)
        });
        video.remove();
        canvas.remove();
      }, 10);
    };
    video.onerror = e => {
      console.error(e);
      reject("文件异常,请检查文件格式,或联系管理员");
    };
  });
}
// 获取音频时长信息
function findAudioDetail(url) {
  const audio = document.createElement("audio"); // 也可以自己创建video
  audio.src = url; // url地址 url跟 视频流是一样的
  audio.crossOrigin = "anonymous"; // 解决跨域问题,也就是提示污染资源无法转换视频
  return new Promise((resolve, reject) => {
    audio.oncanplay = () => {
      resolve({
        duration: Math.floor(audio.duration)
      });
      audio.remove();
    };
    audio.onerror = e => {
      console.error(e);
      reject("文件异常,请检查文件格式,或联系管理员");
    };
  });
}
// 获取图片宽高信息
function findImageDetail(url) {
  const img = document.createElement("img"); // 也可以自己创建video
  img.src = url; // url地址 url跟 视频流是一样的
  return new Promise((resolve, reject) => {
    img.onload = () => {
      resolve({
        width: img.width,
        height: img.height
      });
      img.remove();
    };
    img.onerror = e => {
      console.error(e);
      reject("文件异常,请检查文件格式,或联系管理员");
    };
  });
}
// 预览文件
function preview(file: UploadFile) {
  // 调用预览组件
  previewRef.value.preview(file);
}
// 删除文件
function handleRemove(e: number | UploadFile) {
  let index = -1;
  if (typeof e == "number") {
    index = e;
    if (index >= 0) dataList.value.splice(index, 1);
  } else {
    index = dataList.value.findIndex((item: UploadFile) => item.uid == e.uid);
    if (index >= 0) dataList.value.splice(index, 1);
    else throw new Error("删除文件失败,请检查文件列表");
  }
  verifyLength();
  emit("changeFileList", dataList.value[index], "remove");
}
// 检验已选择的文件数量是否超过阈值,并做添加组件的显示/隐藏处理
function verifyLength() {
  if (dataList.value.length >= props.limit) {
    choiceFileRef.value.hide();
  } else {
    choiceFileRef.value.show();
  }
}
// 获取文件对象
function getFiles(): UploadFilePlus[] {
  return dataList.value;
}
// 清理文件
function clearFileList() {
  dataList.value = [];
}

// 添加远程图片
async function addFileList(url: string, name?: string) {
  const uploadFile: any = {
    url,
    name
  };
  const suffix = url.split(".").at(-1);
  const type = getTypes(suffix);
  const names = url.match(/(?<=\d{2}\/.*-)(.*)(?=-)/g);
  if (names && names.length > 0) uploadFile.name = names[0];
  uploadFile.type = type;
  if (type === "video") {
    const res = (await findvideodetail(url)) as {
      viewUrl: string;
    };
    uploadFile.viewUrl = res.viewUrl;
  } else if (type === "image") {
    uploadFile.viewUrl = uploadFile.url;
  } else if (type === "audio") {
    uploadFile.viewUrl = new URL(
      "@/assets/goods/audio.svg",
      import.meta.url
    ).href;
  }
  uploadFile.needUpload = false;
  dataList.value.push(uploadFile);
  verifyLength();
}
// 控制修改文件顺序弹窗
const changeNumberDialogVisible = ref(false);
// 控制修改文件顺序
const changeNumber = ref(-1);
// 要修改文件的位置
const changeNumberIndex = ref(-1);
// 唤起修改文件顺序弹窗
function openChangeNumber(index) {
  changeNumberDialogVisible.value = true;
  changeNumberIndex.value = index;
  changeNumber.value = index + 1;
}
// 修改文件顺序
function confirmChangeNumber() {
  const temp = dataList.value[changeNumberIndex.value];
  dataList.value.splice(changeNumberIndex.value, 1);
  dataList.value.splice(changeNumber.value - 1, 0, temp);
  changeNumberDialogVisible.value = false;
}

defineExpose({
  getFiles,
  clearFileList,
  addFileList,
  preview,
  handleRemove
});
</script>

<template>
  <div class="flex flex-wrap">
    <div v-for="(item, index) in dataList" :key="item.url" class="mr-3 img-box">
      <img
        class="showImg"
        :src="item.viewUrl"
        alt=""
        srcset=""
        v-if="item.type === 'image' || item.type === 'video'"
      />
      <div v-if="item.type === 'audio' && item.name" class="audio-box">
        《{{ item.name }}》
      </div>
      <!-- 增加对于旧版本音频没有名称的兼容 -->
      <img
        v-if="item.type === 'audio' && !item.name"
        src="/src/assets/goods/audio.svg"
        alt=""
        srcset=""
      />
      <div class="func-box">
        <span
          v-if="dataList.length > 1 && !disabled"
          @click="openChangeNumber(index)"
        >
          <el-icon size="25" color="#fff"><edit-pen /></el-icon>
        </span>
        <span @click="preview(item)">
          <el-icon size="25" color="#fff"><zoom-in /></el-icon>
        </span>
        <span @click="handleRemove(index)" v-if="!disabled">
          <el-icon size="25" color="#fff"><delete /></el-icon>
        </span>
      </div>
      <div class="file-name" v-if="props.limit > 1">
        {{ index + 1 }} {{ item.name }}
      </div>
    </div>
    <choiceFile
      ref="choiceFileRef"
      @addFile="add"
      :accept="accept"
      :multiple="multiple"
      :disabled="disabled"
    />
    <previewCom ref="previewRef" />
    <el-dialog v-model="changeNumberDialogVisible" style="width: 400px">
      <el-input-number
        v-model="changeNumber"
        :min="1"
        :max="dataList.length"
        :disabled="disabled"
        :step="1"
        :step-strictly="true"
        :controls="false"
        :placeholder="`请输入${1}~${dataList.length}之间的数字`"
        class="!w-[100%]"
      />
      <template #footer>
        <span>
          <el-button @click="changeNumberDialogVisible = false"
            >取 消</el-button
          >
          <el-button type="primary" @click="confirmChangeNumber">
            确 定
          </el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>

<style lang="scss" scoped>
.showImg {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.img-box {
  position: relative;
  background-color: var(--el-fill-color-lighter);
  border: 1px solid var(--el-border-color-darker);
  border-radius: 6px;
  width: 148px;
  height: 148px;
  // overflow: hidden;
  margin-bottom: 25px;
  .file-name {
    position: absolute;
    bottom: -26px;
    left: 0px;
    width: 148px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    font-size: 9px;
  }
}
.func-box {
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  justify-content: center;
  align-items: center;
  color: #fff;
  font-size: 12px;
  transition: all 0.2s ease-in-out;
  opacity: 0;
  > span {
    margin: 0 8px;
  }
}
.img-box:hover .func-box {
  opacity: 1;
}
.audio-box {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 14px;
  text-align: center;
  padding: 0 5px;
  overflow: hidden;
}
</style>

3 文件预览组件

3.1 实现视频播放器

视频播放器使用西瓜视频播放器组件

西瓜播放器相关文档

javascript 复制代码
<!-- 视频播放组件 -->
<script setup lang="ts">
import { deviceDetection } from "@pureadmin/utils";

import Player from "xgplayer";
import "xgplayer/dist/index.min.css";
let player;
// 对外暴露暂停事件
function pause() {
  player.pause();
}
// 对外暴露修改视频源事件
function changeUrl(url, poster) {
  player = new Player({
    id: "mse",
    lang: "zh",
    // 默认静音
    volume: 0,
    autoplay: false,
    screenShot: true,
    videoAttributes: {
      crossOrigin: "anonymous"
    },
    url: url,
    poster: poster,
    fluid: deviceDetection(),
    //传入倍速可选数组
    playbackRate: [0.5, 0.75, 1, 1.5, 2]
  });
}
defineExpose({ pause, changeUrl });
</script>

<template>
  <div id="mse" />
</template>

<style scoped>
#mse {
  flex: auto;
  margin: 0px auto;
}
</style>

3.2 实现图片和视频预览

  • 图片:使用el-image组件自带的功能实现图片的预览功能
  • 音频:使用audio标签实现音频播放
  • 视频:引入视频播放器组件实现视频播放
  • 对外暴露文件预览方法
javascript 复制代码
<!-- 文件预览组件 -->
<script setup lang="ts">
import { ref } from "vue";
import { UploadFile } from "./types";
import videoComponent from "./videoComponent.vue";
// 控制图片预览的路径
const previewFile = ref();
// 控制是否显示图片预览的弹窗
const dialogVisible = ref(false);
// 定义组件ref
const videoRef = ref(),
  audioRef = ref();
// 预览文件
function preview(file: UploadFile) {
  previewFile.value = file;
  dialogVisible.value = true;
}
// 关闭弹窗的时候停止音视频的播放
function close() {
  if (previewFile.value.type === "audio") {
    audioRef.value.pause();
  }
  if (previewFile.value.type === "video") {
    videoRef.value.pause();
  }
}
// 打开弹窗的时候修改视频路径和封面
function open() {
  if (previewFile.value.type === "video") {
    videoRef.value.changeUrl(previewFile.value.url, previewFile.value.viewUrl);
  }
}
defineExpose({ preview });
</script>

<template>
  <!-- 图片预览弹窗 -->
  <el-dialog
    v-model="dialogVisible"
    style="width: 800px"
    @close="close"
    @open="open"
  >
    <el-image
      v-if="previewFile.type === 'image'"
      style="width: 100%; height: 400px"
      :src="previewFile.url"
      fit="contain"
      alt="Preview Image"
      :zoom-rate="1.2"
      :max-scale="7"
      :min-scale="0.2"
      :preview-src-list="[previewFile.url]"
      :initial-index="0"
    />
    <videoComponent
      ref="videoRef"
      v-if="previewFile.type === 'video'"
      style="width: 100%; height: 400px"
    />
    <audio
      ref="audioRef"
      v-if="previewFile.type === 'audio'"
      :src="previewFile.url"
      controls
      style="width: 100%; height: 400px"
    />
  </el-dialog>
</template>
<style lang="scss" scoped></style>

4 index组件

4.1 接收props,整合数据

  • 定义props接收父组件传递过来的数据,并规定类型,必要的时候增加校验器
  • 根据文件类型、额外提示信息等外部参数,合并出组件所需要的参数

4.2 提供文件校验方法

  • 提供为空校验
  • 提供最大文件数校验
  • 提供最小文件数校验
  • 提供单个文件大小限制校验
  • 提供图片宽高超出限制校验
  • 提供文件类型校验

4.3 完整代码

javascript 复制代码
<script setup lang="ts">
import { IMAGE_TYPE, VIDEO_TYPE, AUDIO_TYPE } from "@/utils/preview";
import { ref } from "vue";
// 引入的文件格式"jpg|jpeg|png"不支持,需要格式为".jpg, .jpeg, .png", 所以需要进行格式化
const IMAGE_TYPE_FORMATED = IMAGE_TYPE.replace(/\|/g, ", ").replace(
  /((?<=\s?)\w{2,})/g,
  ".$1"
);
const AUDIO_TYPE_FORMATED = AUDIO_TYPE.replace(/\|/g, ", ").replace(
  /((?<=\s?)\w{2,})/g,
  ".$1"
);
const VIDEO_TYPE_FORMATED = VIDEO_TYPE.replace(/\|/g, ", ").replace(
  /((?<=\s?)\w{2,})/g,
  ".$1"
);
import showFile from "./showFile.vue";
const showFileRef = ref();
import { UploadFilePlus, UploadFile } from "./types";
const props = defineProps({
  // 数量限制
  limit: {
    type: Number,
    default: 1
  },
  // 最小数量限制
  minLimit: {
    type: Number,
    default: -1
  },
  // 多选限制
  multiple: {
    type: Boolean,
    default: false
  },
  // 文件种类
  fileType: {
    type: String,
    required: true,
    validator(value: string) {
      return ["audio", "image", "video"].includes(value);
    }
  },
  // 追加提示
  appendTip: {
    type: String,
    default: ""
  },
  // 图片、视频宽度限制(px)
  widthLimit: {
    type: Number,
    default: 0
  },
  // 图片、视频高度限制(px)
  heightLimit: {
    type: Number,
    default: 0
  },
  // 文件大小限制
  maxSize: {
    type: Number,
    default: 0
  },
  // 文件类型
  accept: {
    type: String,
    default: ""
  },
  // 禁用
  disabled: {
    type: Boolean,
    default: false
  },
  // 必须
  required: {
    type: Boolean,
    default: true
  }
});
// 可上传文件类型
const accept = ref(props.accept);
// 最大上传文件大小
const maxSize = ref(props.maxSize);
const tip = ref("");
// 根据类型设置默认值
if (props.fileType) {
  switch (props.fileType) {
    case "image":
      // 如果未固定文件种类,则使用默认支持的文件种类
      accept.value = accept.value || IMAGE_TYPE_FORMATED;
      // 图片默认最大20MB
      maxSize.value = maxSize.value || 20;
      break;
    case "audio":
      accept.value = accept.value || AUDIO_TYPE_FORMATED;
      maxSize.value = maxSize.value || 500;
      break;
    case "video":
      accept.value = accept.value || VIDEO_TYPE_FORMATED;
      maxSize.value = maxSize.value || 500;
      break;
    case "musiVideo":
      // 同时支持多种文件的需要合并一下
      accept.value =
        accept.value || `${VIDEO_TYPE_FORMATED}, ${AUDIO_TYPE_FORMATED}`;
      maxSize.value = maxSize.value || 500;
      break;
    case "imageVideo":
      accept.value =
        accept.value || `${VIDEO_TYPE_FORMATED}, ${IMAGE_TYPE_FORMATED}`;
      maxSize.value = maxSize.value || 500;
      break;
    default:
      throw new Error("类型错误");
  }
  // 合并提示信息
  tip.value = `请上传${accept.value}格式的文件,图片大小不能超过${maxSize.value}MB。${props.appendTip}`;
}
const emit = defineEmits(["changeFileList"]);

function changeFileList(file: UploadFilePlus, name: string) {
  emit("changeFileList", file, name);
}
// 获取文件对象
function getFiles(): UploadFilePlus[] {
  const fileList = showFileRef.value.getFiles();
  fileList.forEach(item => {
    if (item?.width) item.raw.width = item.width;
    if (item?.height) item.raw.height = item.height;
  });
  return fileList;
}
// 清理文件
function clearFileList() {
  showFileRef.value.clearFileList();
}
type validateReturnValue = {
  code: number;
  success: boolean;
  msg: string;
};
// 文件信息校验
function verification(): validateReturnValue {
  const fileList = showFileRef.value.getFiles();
  if (props.required && fileList.length <= 0) {
    return {
      code: 0,
      success: false,
      msg: "请选择上传文件"
    };
  }
  if (fileList.length > props.limit) {
    return {
      code: 0,
      success: false,
      msg: `文件数量超出限制,请上传${props.limit}个及以内的文件`
    };
  }
  if (props.minLimit > 0 && fileList.length < props.minLimit) {
    return {
      code: 0,
      success: false,
      msg: `文件数量低于最少限制,请上传${props.minLimit}个及以上的文件`
    };
  }
  for (let i = 0; i < fileList.length; i++) {
    const element = fileList[i];
    if (!element.needUpload) break;
    if (element.size / 1024 / 1024 > maxSize.value) {
      return {
        code: 0,
        success: false,
        msg: "文件大小超出限制"
      };
    }
    if (element.type === "image") {
      if (props.widthLimit && element.width != props.widthLimit) {
        return {
          code: 0,
          success: false,
          msg: `图片宽度不等于${props.widthLimit}像素`
        };
      }
      if (props.heightLimit && element.height != props.heightLimit) {
        return {
          code: 0,
          success: false,
          msg: `图片高度不等于${props.heightLimit}像素`
        };
      }
    }
    if (!accept.value.includes(element.name.split(".").at(-1).toLowerCase())) {
      return {
        code: 0,
        success: false,
        msg: `文件类型不正确,请上传${accept.value}类型的文件`
      };
    }
  }
  return {
    code: 200,
    success: true,
    msg: "格式正确"
  };
}
// 添加文件
function addFileList(url: string, name?: string) {
  showFileRef.value.addFileList(url, name);
}
function handlePictureCardPreview(file: UploadFile) {
  showFileRef.value.preview(file);
}
// 移除文件
function handleRemove(e: number | UploadFile) {
  showFileRef.value.handleRemove(e);
}
defineExpose({
  getFiles,
  verification,
  addFileList,
  clearFileList,
  handlePictureCardPreview,
  handleRemove
});
</script>

<template>
  <div>
    <div class="flex flex-wrap">
      <showFile
        ref="showFileRef"
        :disabled="disabled"
        :limit="limit"
        :multiple="multiple"
        :accept="accept"
        @changeFileList="changeFileList"
      />
    </div>
    <div class="el-upload__tip !mt-[-5px]">{{ tip }}</div>
  </div>
</template>

<style lang="scss" scoped></style>

5 定义数据类型

typescript 复制代码
export interface UploadFile {
  name: string; // 文件名称
  percentage: number; // 上传进度
  raw: File; // 原始 File 对象
  size: number; // 文件大小
  status: string; // 上传状态,可选值:'ready' | 'uploading' | 'success' | 'error'
  uid: number; // 文件唯一标识
  url: string; // 文件上传后的 URL(半路径)或临时的URL
}
export interface UploadFilePlus extends UploadFile {
  type: string;
  viewUrl: string | undefined; // 全路径
  needUpload: boolean; // 是否需要上传
  duration: number; // 视频/音频时长
  width: number; // 视频/图片宽度
  height: number; // 视频/图片高度
  processed: boolean; // 是否处理过
}

6 工具方法

由于使用自定义上传组件,所以数据的校验和上传往往比较复杂,所以提供工具函数来简化此操作,使得使用者只需要<10行的代码,完成几十行的功能

使用方法如下:

1 引入组件、工具函数、文件路径补全方法

javascript 复制代码
import FileUploadNew from "@/components/FileUploadNew/index.vue";
import { handleUseHook } from "@/components/FileUploadNew/utils";
// 非必须
import { preview } from "@/utils/preview";

2 实例化方法

javascript 复制代码
const { getFiles, verification, upload } = handleUseHook(formData, {
  path: pathRef, // 单选这样传就可以
  headDiagrams: { ref: headDiagramsRef, multiple: true }, // 多选这样传
  detailPics: { ref: detailPicsRef, multiple: true, callback: handle } // 每个文件上传回调这么传
});
  • formData为提交时需要上传的对象
  • path为后端需要的文件路径参数
  • pathRef为对应的组件ref

3 提交前处理

javascript 复制代码
async function sumbit(){
  // 获取文件信息,避免校验时无法正确校验
  await getFiles();
  // element-plus中form组件的校验,校验绑定的其他参数
  ruleFormRef.value.validate(async valid => {
    if (valid) {
  	  // 自行校验文件信息
      if (!(await verification())) return;
      submitLoading.value = true;
      try {
        const params = cloneDeep(formData.value);
        // 上传文件
        await upload(params);
        if (!formData.value.id) {
          const { success } = await addProduct(params);
          if (success) chores();
        } else {
          // 实际开发先调用编辑接口,再进行下面操作
          const { success } = await updateProduct(params);
          if (success) chores();
        }
      } finally {
        submitLoading.value = false;
      }
    }
  });
}

4 组件上增加参数

javascript 复制代码
<el-form-item
  label="头图:"
  prop="headDiagramsLoad"
  :error="formData.headDiagramsError"
  :rules="{
    required: true,
    message: '请上传头图',
    trigger: 'blur'
  }"
>
  <FileUploadNew
    ref="headDiagramsRef"
    :multiple="true"
    fileType="image"
    :required="true"
    :limit="5"
  />
</el-form-item>
  • 在实例化的时候会自动在formData上增加 "参数+Load" 和 "参数+Error"这两个新参数
  • 增加rules的作用是增加星号,让自带的校验做一个非空校验
    5 文件回显
javascript 复制代码
// 单选
if (videoUrlRef.value && res.data.videoUrl)
      videoUrlRef.value.addFileList(preview(res.data.videoUrl));
// 多选
if (headDiagramsRef.value && res.data.headDiagrams.length > 0)
  res.data.headDiagrams.forEach((item: any) => {
    headDiagramsRef.value.addFileList(preview(item));
  });

7 其他工具方法

7.1 上传方法

javascript 复制代码
import OSS from "ali-oss";
import dayjs from "dayjs"; // 引入时间组件
import cryptojs from "crypto-js";
import { buildUUID } from "@pureadmin/utils";
import { getStsToken, getFilePath, saveFilePath } from "@/api/common/oss";

// 获取不同文件类型bucket
const OSS_BUCKET_OBJECT = {
  image: "XXX-image",
  audio: "XXX-audio",
  video: "XXX-video1",
  application: "XXX-file",
  other: "XXX-upload"
};
// 分片的大小
let SHARD_SIZE = 10;
// 文件大小阈值,主要区分直接上传和分片上传
const FILE_SIZE_THRESHOLD = 10;
// 取MD5的字符串长度
const MD5_LENGTH = 10;
// 当文件大小限制的时候,将重新获取StsToken,避免Token过期
const REFRESH_FILE_MAX = 1000;
type configType = {
  accessKeyId: string;
  accessKeySecret: string;
  bucket: string;
  expiration: string;
  region: string;
  token: string;
  stsToken: string;
};
let config: configType = undefined;
const abortCheckpointMap = new Map();

// 对文件进行上传
export async function uploadFile(file, progressCallback?: any) {
  // console.log(file, Object.prototype.toString.call(file));
  // 实例化OSS对象
  if (!file || Object.prototype.toString.call(file) !== "[object File]") {
    throw new Error("参数不正确");
  }
  const fileSize = file.size / 1024 / 1024;
  // 根据约定和相关规则进行参数配置
  const ossConfig: configType = await getOssConfig(
    fileSize > REFRESH_FILE_MAX ? true : false
  );
  ossConfig.bucket = getBucket(file);
  ossConfig.stsToken = ossConfig.token;
  const client = new OSS(ossConfig);
  try {
    // progressCallback 三个参数 (进度, 断点信息, 返回值)
    // 校验传入的对象是否正确
    const md5 = await toMD5(file);
    const fileName = getFileName(file);
    // 进行秒传的判定
    const loadPath = await judgmentExistence(fileName + md5);
    if (loadPath) {
      return loadPath;
    }
    const environment = `/${import.meta.env.VITE_LOGOGRAM}`;
    const pathName = `${environment}/${dayjs().format("YYYY-MM-DD")}/`;
    const uploadFileName = pathName + buildUUID() + "-" + fileName;
    // 根据文件大小选择上传的方式
    if (fileSize > FILE_SIZE_THRESHOLD) {
      SHARD_SIZE = Math.ceil(fileSize / 500);
      console.log(
        abortCheckpointMap,
        abortCheckpointMap.has(md5),
        abortCheckpointMap.get(md5)
      );
      let option = {
        partSize: 1024 * 1024 * SHARD_SIZE,
        parallel: Math.ceil(fileSize / SHARD_SIZE),
        timeout: 1000 * 60 * 60,
        checkpoint: null,
        progress: (p, cpt, res) => {
          // 为中断点赋值。
          abortCheckpointMap.set(md5, {
            ...option,
            checkpoint: cpt
          });
          // 获取上传进度。
          console.log(p * 100, abortCheckpointMap);
          if (
            Object.prototype.toString.call(progressCallback) ===
            "[object Function]"
          )
            progressCallback(p, cpt, res);
        }
      };
      if (abortCheckpointMap.has(md5)) {
        option = abortCheckpointMap.get(md5);
      }
      console.log(option);
      // 文件上传
      const result = await client.multipartUpload(uploadFileName, file, option);
      // 保存MD5和文件路径
      await saveFilePath({
        fileMd5: fileName + md5,
        path: `/${result.name}`
      });
      abortCheckpointMap.delete(uploadFileName);
      return `/${result.name}`;
    } else {
      // 文件上传
      const result = await client.put(uploadFileName, file);
      // 保存MD5和文件路径
      await saveFilePath({
        fileMd5: md5,
        path: `/${result.name}`
      });
      return `/${result.name}`;
    }
  } catch (e) {
    console.error(e);
    client.cancel();
    return Promise.reject(e);
  }
}

// 获取后端返回的临时凭证,并根据时间判断凭证是否过期
export async function getOssConfig(isRefresh = false) {
  if (
    config?.expiration &&
    dayjs().isBefore(dayjs(config.expiration)) &&
    !isRefresh
  ) {
    return config;
  } else {
    const { data } = await getStsToken();
    config = data as configType;
    return data;
  }
}
// 获取文件名称
function getFileName(file) {
  const suffix = file.name.match(/(?<=\.)\w*$/g)[0];
  const oldFileName = file.name.match(/.*(?=\.\w+$)/g)[0];
  let other = "";
  if (file.type.split("/")[0] == "image" && file?.width) {
    other = `-${file.width}x${file.height}`;
  }
  const fileName = `${oldFileName}${other}.${suffix}`;
  return fileName;
}
// 根据文件类型获取不同的bucket
function getBucket(file) {
  const bucket = OSS_BUCKET_OBJECT[file.type.split("/")[0]];
  if (bucket) return bucket;
  else return OSS_BUCKET_OBJECT["other"];
}
// 校验是都可以秒传
async function judgmentExistence(md5) {
  try {
    const { data } = await getFilePath({ fileMd5: md5 });
    return data;
  } catch (e) {
    console.error(e);
    return "";
  }
}
// 获取文件md5
async function toMD5(file) {
  return new Promise((resolve, reject) => {
    try {
      const reader = new FileReader();
      let temp: any = null;
      // 文件太大会卡死
      if (file.size > 1024 * 1024 * MD5_LENGTH + 1) {
        temp = file.slice(0, 1024 * 1024 * MD5_LENGTH);
      }
      reader.readAsDataURL(temp || file);
      // 开始转base64
      reader.onload = async () => {
        const md5 = cryptojs.MD5(reader.result.toString()).toString();
        resolve(md5);
      };
    } catch (e) {
      console.error(e);
      reject(e);
    }
  });
}

7.2 文件路径拼接方法

javascript 复制代码
// 访问协议
const AGREEMENT = "https://";
// 获取不同文件类型的访问地址
const SERVER_ADDRESS = {
  image: "img.XXX.XXX.com",
  audio: "audio.XXX.XXX.com",
  video: "video.XXX.XXX.com",
  application: "file.XXX.XXX.com",
  other: "upload.XXX.XXX.com"
};
export const IMAGE_TYPE = "jpg|jpeg|png|gif|ico|webp|pcx|tif|raw|tga|bmp";
export const AUDIO_TYPE = "mp3|wav|flac|ogg|aac|wma";
export const VIDEO_TYPE =
  "avi|wmv|mpeg|mp4|m4v|mov|asf|flv|f4v|rmvb|rm|3gp|vob";
export const APPLICATION_TYPE = "doc|docx|xls|xlsx|ppt|pptx|pdf|txt|apk|zip";

export const imageRegex = RegExp(
  `${IMAGE_TYPE}|${IMAGE_TYPE.toLocaleUpperCase()}`
);
export const audioRegex = RegExp(
  `${AUDIO_TYPE}|${AUDIO_TYPE.toLocaleUpperCase()}`
);
export const videoRegex = RegExp(
  `${VIDEO_TYPE}|${VIDEO_TYPE.toLocaleUpperCase()}`
);
export const applicationRegex = RegExp(
  `${APPLICATION_TYPE}|${APPLICATION_TYPE.toLocaleUpperCase()}`
);

export function preview(pathName) {
  const type = getTypes(pathName);
  return `${AGREEMENT}${SERVER_ADDRESS[type]}${pathName}`;
}

export function getTypes(pathName) {
  const suffix = pathName.split(".").at(-1);
  let type = "other";
  if (imageRegex.test(suffix)) type = "image";
  if (audioRegex.test(suffix)) type = "audio";
  if (videoRegex.test(suffix)) type = "video";
  if (applicationRegex.test(suffix)) type = "application";
  return type;
}
相关推荐
fillwang22 分钟前
Python实现Excel行列转换
开发语言·python·excel
编程百晓君1 小时前
Harmony OS开发-ArkTS语言速成五
javascript·harmonyos·arkts
明月看潮生1 小时前
青少年编程与数学 02-005 移动Web编程基础 15课题、移动应用开发
前端·青少年编程·编程与数学·移动web
qq_424317181 小时前
html+css+js网页设计 美食 好厨艺西餐美食企业网站模板6个页面
javascript·css·html
JINGWHALE11 小时前
设计模式 结构型 外观模式(Facade Pattern)与 常见技术框架应用 解析
前端·人工智能·后端·设计模式·性能优化·系统架构·外观模式
北极糊的狐1 小时前
SQL中,# 和 $ 用于不同的占位符语法
java·开发语言
漫漫不慢.2 小时前
九进制转10进制
java·开发语言
西猫雷婶2 小时前
python学opencv|读取图像(二十五)使用cv2.putText()绘制文字进阶-垂直镜像文字
开发语言·python·opencv
别发呆了吧2 小时前
vue路由模式面试题
前端·javascript·vue.js·前端面试题
大小科圣2 小时前
windows配置jdk
java·开发语言