这里写目录标题
- [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;
}