封装 el-upload 上传到阿里云 OSS,开箱即用。
ali-oss 配置
我们先安装 ali-oss
npm install ali-oss
因为我们 oss 配置是从接口获取的,所以要用单例获取配置,避免每次引入组件都调用接口。
// upload.js
import OSS from "ali-oss";
const ossClient = async () => {
const res = await fetch("/XXX");
const { result } = await res.clone().json();
const client = new OSS({
region: "oss-cn-shanghai",
accessKeyId: result.AccessKeyId,
accessKeySecret: result.AccessKeySecret,
stsToken: result.SecurityToken,
bucket: "XXX",
});
return client;
};
class SingleInstance {
instance;
static async getInstance() {
if (!this.instance) this.instance = await ossClient();
return this.instance;
}
}
let client = await SingleInstance.getInstance();
export default client;
在第一次引入的时候就生成了一次 ali-oss 实例,后续使用就都返回这个实例,不再调用接口了。
组件代码
Template
<template>
<el-upload
ref="upload"
:file-list="fileList"
:list-type="type === 'image' ? 'picture-card' : 'text'"
class="custom-uploader"
:class="fileList.length === limit ? 'hide' : ''"
action=""
:accept="allowAccept"
:limit="limit"
:multiple="multiple"
:show-file-list="showList"
:on-exceed="handleExceed"
:on-success="handleSuccess"
:on-remove="handleRemove"
:http-request="handleUpload"
:on-preview="handlePreview"
:before-upload="handleBeforeUpload"
>
<el-icon v-if="type === 'image'"><Plus /></el-icon>
<el-button v-else type="primary" v-show="type !== 'image'"
>{{ label }}</el-button
>
</el-upload>
<el-dialog v-model="dialogVisible" top="50px">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
</template>
type 根据不同类型文件显示不同的文件列表风格和按钮风格。dialog 用来预览图片。
Script 部分
只说明核心内容部分,完整代码在最后。
引入相关库
import OSS from "ali-oss";
import { v4 } from "uuid";
import Sortable from "sortablejs";
import client from "./upload";
Props
const props = defineProps({
modelValue: {
// 双向绑定更改数据
type: [String, Array], // 如果单文件可以使用字符串,上传多个时使用数组
default: "",
},
accept: {
// 上传文件类型
type: String,
default: () => "image",
},
limit: {
// 最大上传数量
type: Number,
default: () => 1,
},
multiple: {
// 是否多选
type: Boolean,
default: () => true,
},
label: {
// 非图片时按钮文本
type: String,
default: () => "上传文件",
},
showList: {
// 是否显示文件列表
type: Boolean,
default: () => false,
},
type: {
// 上传类型 控制ui风格
type: String,
default: () => "image",
},
originName: {
// 上传文件名是否使用原名,默认用uuid避免重名
type: Boolean,
default: () => false,
},
drag: {
// 是否可拖拽修改顺序
type: Boolean,
default: () => false,
},
});
避免直接修改数据源,我们内部定义变量接收
interface File {
uid: Number;
status: String;
name: String;
url: String;
}
const fileList = ref<File[]>([]);
watch(
() => props.modelValue,
(val: String[] | String) => {
let list: any[] = [];
if (typeof val === "string" && val) {
list = [val];
} else if (Array.isArray(val) && val.length > 0) {
list = val;
} else {
list = [];
}
const res = [...list];
if (res.length > 0) {
fileList.value = res.map((url: string) => {
const uid = new Date().getTime();
return {
uid,
status: "success",
name: window.decodeURI(url.slice(url.lastIndexOf("/") + 1)),
url: url,
};
});
}
},
{ immediate: true, deep: true }
);
el-upload 文件列表是数组类型,所以如果是单文件的字符串,我们需要包裹成数组,同时它们属于已经上传的,所以为他们初始化相应的属性。
自定义上传
const handleUpload = async (config: any) => {
try {
const {
file,
file: { uid },
} = config;
const point = file.name.lastIndexOf(".");
const ext = file.name.substr(point);
const fileName = file.name.substring(0, point);
const date = new Date();
const curDate = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
const path = `/${curDate}/${props.originName ? fileName : v4().replace(/-/g, "")}.${ext}`;
return await client.put(path, file);
} catch (error) {
ElMessage.error(typeof error === "string" ? error : JSON.stringify(error));
return false;
}
};
自定义文件路径和名称,再使用 ali-oss 上传,路径规则:年/月/日/uuid 文件名,返回 oss 的 url
上传成功处理
const uploadPool = ref<string[]>([]);
const selectFileLength = ref<number>(0);
const handleBeforeUpload = () => {
selectFileLength.value = selectFileLength.value + 1;
};
const handleSuccess = (file: any) => {
let { url } = file;
uploadPool.value.push(url);
if (upload.value.uploadFiles.find((file: any) => file.status !== "success")) {
return;
}
const value = props.multiple
? [...fileList.value.map(v => v.url), ...uploadPool.value]
: url;
emit("change", value);
emit("update:modelValue", value);
upload.value.clearFiles();
uploadPool.value = [];
selectFileLength.value = 0;
};
因为 el-upload 的事件都是以单个文件触发的,所以我们使用 uploadPool 临时存储已上传数据,在全部完成后一起更新。
element-plus 2.0.0 版本后 el-upload 实例不返回 uploadFiles 了,所以无法获取选择的文件数量,我们使用前置钩子 before-upload 来统计选择文件数量。
拖拽排序
watch(
() => props.drag,
val => {
if (val && props.limit > 1) rowDrag();
},
{ immediate: true }
);
const rowDrag = () => {
nextTick(() => {
const el = upload.value.$el.querySelector(".el-upload-list");
Sortable.create(el, {
onEnd: ({ newIndex, oldIndex }: any) => {
fileList.value.splice(
newIndex,
0,
fileList.value.splice(oldIndex, 1)[0]
);
const cp = [...fileList.value];
const value = cp.map(t => t.url);
emit("change", value);
emit("update:modelValue", value);
},
});
});
};
当最大上传数量大于 1 时,我们才能使用拖拽。拖拽使用的是 sortablejs 库,具体用法请看Sortable 文档
完整代码
<template>
<el-upload
ref="upload"
:file-list="fileList"
:list-type="type === 'image' ? 'picture-card' : 'text'"
class="custom-uploader"
:class="fileList.length === limit ? 'hide' : ''"
action=""
:accept="allowAccept"
:limit="limit"
:multiple="multiple"
:show-file-list="showList"
:on-exceed="handleExceed"
:on-success="handleSuccess"
:on-remove="handleRemove"
:http-request="handleUpload"
:on-preview="handlePreview"
:before-upload="handleBeforeUpload"
>
<el-icon v-if="type === 'image'"><Plus /></el-icon>
<el-button v-else type="primary" v-show="type !== 'image'">{{
label
}}</el-button>
</el-upload>
<el-dialog v-model="dialogVisible" top="50px">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
</template>
<script lang="ts" setup>
import OSS from "ali-oss";
import { v4 } from "uuid";
import { ref, watch, defineProps, defineEmits, computed, nextTick } from "vue";
import { ElMessage } from "element-plus";
import type { UploadInstance, UploadFile, UploadFiles } from "element-plus";
import Sortable from "sortablejs";
import client from "./upload";
const ACCEPT = {
image: "image/*",
excel: ".xlsx,.xls,.csv",
audio: "audio/*",
};
const emit = defineEmits(["change", "update:modelValue"]);
const props = defineProps({
modelValue: {
type: [String, Array],
default: "",
},
accept: {
type: String,
default: () => "image",
},
limit: {
type: Number,
default: () => 1,
},
multiple: {
type: Boolean,
},
label: {
type: String,
default: () => "上传文件",
},
showList: {
type: Boolean,
default: () => false,
},
type: {
type: String,
default: () => "image",
},
originName: {
type: Boolean,
default: () => false,
},
drag: {
type: Boolean,
default: () => false,
},
});
const allowAccept = computed(() => {
return props.accept in ACCEPT ? ACCEPT[props.accept] : props.accept || "";
});
const upload = ref<UploadInstance>();
const selectFileLength = ref<number>(0);
const uploadPool = ref<string[]>([]);
interface File {
uid: Number;
status: String;
name: String;
url: String;
}
const fileList = ref<File[]>([]);
watch(
() => props.modelValue,
(val: string[] | String) => {
let list = [];
if (typeof val === "string" && val) {
list = [val];
} else if (Array.isArray(val) && val.length > 0) {
list = val;
} else {
list = [];
}
const res = [...list];
if (res.length > 0) {
fileList.value = res.map((url: string) => {
const uid = new Date().getTime();
return {
uid,
status: "success",
name: window.decodeURI(url.slice(url.lastIndexOf("/") + 1)),
url: url,
};
});
}
},
{ immediate: true, deep: true }
);
const rowDrag = () => {
nextTick(() => {
const el = upload.value.$el.querySelector(".el-upload-list");
Sortable.create(el, {
onEnd: ({ newIndex, oldIndex }: any) => {
fileList.value.splice(
newIndex,
0,
fileList.value.splice(oldIndex, 1)[0]
);
const cp = [...fileList.value];
const value = cp.map(t => t.url);
emit("change", value);
emit("update:modelValue", value);
},
});
});
};
watch(
() => props.drag,
val => {
if (val && props.limit > 1) rowDrag();
},
{ immediate: true }
);
const handleBeforeUpload = () => {
selectFileLength.value = selectFileLength.value + 1;
};
const handleSuccess = (file: any) => {
let { url } = file;
uploadPool.value.push(url);
if (uploadPool.value.length < selectFileLength.value) return;
const value = props.multiple
? [...fileList.value.map(v => v.url), ...uploadPool.value]
: url;
emit("change", value);
emit("update:modelValue", value);
upload.value.clearFiles();
uploadPool.value = [];
selectFileLength.value = 0;
};
const handleRemove = (uploadFile: UploadFile, uploadFiles: UploadFiles) => {
const { url } = uploadFile || {};
let i = fileList.value.findIndex(item => item.url === url);
if (i >= 0) {
fileList.value.splice(i, 1);
const value = props.multiple ? [...fileList.value.map(v => v.url)] : "";
emit("change", value);
emit("update:modelValue", value);
}
};
const handleUpload = async (config: any) => {
try {
const {
file,
file: { uid },
} = config;
const point = file.name.lastIndexOf(".");
const ext = file.name.substr(point);
const fileName = file.name.substring(0, point);
const date = new Date();
const curDate = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
const path = `/${curDate}/${props.originName ? fileName : v4().replace(/-/g, "")}.${ext}`;
return await client.put(path, file);
} catch (error) {
ElMessage.error(typeof error === "string" ? error : JSON.stringify(error));
return false;
}
};
const dialogImageUrl = ref<string>("");
const dialogVisible = ref<boolean>(false);
const handlePreview = (uploadFile: any) => {
dialogImageUrl.value = uploadFile.url;
dialogVisible.value = true;
};
const handleExceed = (file: any) => {
ElMessage.warning("超过最大上传数量,请重新选择");
};
</script>
<style scoped>
.custom-uploader.hide /deep/ .el-upload--picture-card {
transition: 0s;
display: none;
}
.el-dialog img {
max-width: 100%;
}
.custom-uploader /deep/ .el-upload-list__item {
transition: none !important;
}
</style>
组件使用
import Upload from './Upload.vue';
<Upload accept="image" showList v-model="value" />
// 上传图片
<Upload accept="audio" showList originName type="audio" v-model="value" />
// 上传非图片