Files
schisandra-cloud-album-front/src/components/MyUI/Upload/Upload.vue
2025-01-13 19:30:29 +08:00

591 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import {computed, ref, watchEffect} from 'vue';
// import Spin from '../Spin/Spin.vue';
// import Message from '../Message/Message.vue';
// import Image from '../Image/Image.vue';
// import Space from '../Space/Space.vue';
interface FileType {
name?: string // 文件名
url: any // 文件地址
[propName: string]: any // 添加一个字符串索引签名,用于包含带有任意数量的其他属性
}
interface MessageType {
upload?: string // 上传成功的消息提示,没有设置该属性时即不显示上传消息提示
remove?: string // 删除成功的消息提示,没有设置该属性时即不显示删除消息提示
}
interface Props {
accept?: string // 接受上传的文件类型,与 <input type="file" /> 的 accept 属性一致,参考 https://developer.mozilla.org/zh-CN/docs/Web/HTML/Attributes/accept
multiple?: boolean // 是否支持多选文件,开启后可选择多个文件
maxCount?: number // 限制上传数量。当为 1 时,始终用最新上传的文件代替当前文件
tip?: string // 上传描述文字 string | slot
fit?: 'contain' | 'fill' | 'cover' | 'none' | 'scale-down' // 预览图片缩放规则,仅当上传文件为图片时生效
draggable?: boolean // 是否支持拖拽上传,开启后可拖拽文件到选择框上传
disabled?: boolean // 是否禁用,只能预览,不能删除和上传
spaceProps?: object // Space 组件属性配置,用于配置多个文件时的排列方式
spinProps?: object // Spin 组件属性配置,用于配置上传中样式
imageProps?: object // Image 组件属性配置,用于配置图片预览
messageProps?: object // Message 组件属性配置,用于配置操作消息提示
actionMessage?: MessageType // 操作完成的消息提示,传 {} 即可不显示任何消息提示
beforeUpload?: any // 上传文件之前的钩子,参数为上传的文件,返回 false 则停止上传,返回 true 开始上传;支持返回一个 Promise 对象如服务端校验等Promise 对象 reject 时停止上传resolve 时开始上传;通常用来校验用户上传的文件格式和大小
uploadMode?: 'base64' | 'custom' // 上传文件的方式,默认是 base64可选 'base64' | 'custom'
customRequest?: any // 自定义上传行为,只有 uploadMode: custom 时,才会使用 customRequest 自定义上传行为
fileList?: FileType[] // (v-model) 已上传的文件列表
}
const props = withDefaults(defineProps<Props>(), {
accept: '*', // 默认支持所有类型
multiple: false,
maxCount: undefined,
tip: 'ImageUpload',
fit: 'contain',
draggable: true,
disabled: false,
spaceProps: () => ({}),
spinProps: () => ({}),
imageProps: () => ({}),
messageProps: () => ({}),
actionMessage: () => ({upload: '上传成功', remove: '删除成功'}),
beforeUpload: () => true,
uploadMode: 'base64',
customRequest: () => {
},
fileList: () => []
});
const uploadedFiles = ref<FileType[]>([]); // 上传文件列表
const showUpload = ref(1); // 展示的上传框
const uploading = ref<boolean[]>([]); // 上传中
const uploadInputRef = ref(); // 上传文件控件引用
const imageRef = ref();
const messageRef = ref();
const emits = defineEmits(['update:fileList', 'drop', 'change', 'preview', 'remove']);
const maxFileCount = computed(() => {
if (props.maxCount === undefined) {
return Infinity;
}
return props.maxCount;
});
watchEffect(() => {
initUpload();
});
function initUpload() {
uploadedFiles.value = [...props.fileList];
if (uploadedFiles.value.length > maxFileCount.value) {
uploadedFiles.value.splice(maxFileCount.value);
}
if (props.disabled) {
// 禁用
showUpload.value = uploadedFiles.value.length;
} else {
if (uploadedFiles.value.length < maxFileCount.value) {
showUpload.value = props.fileList.length + 1;
} else {
showUpload.value = maxFileCount.value;
}
}
}
function isImage(url: string) {
// 检查 url 是否为图片
const imageUrlReg = /\.(jpg|jpeg|png|gif)$/i;
const base64Reg = /^data:image/;
return imageUrlReg.test(url) || base64Reg.test(url);
}
function isPDF(url: string) {
// 检查 url 是否为pdf
const pdfUrlReg = /\.pdf$/i;
const base64Reg = /^data:application\/pdf/;
return pdfUrlReg.test(url) || base64Reg.test(url);
}
function onDrop(e: DragEvent, index: number) {
// 拖拽上传
const files = e.dataTransfer?.files;
if (files?.length) {
const len = files.length;
for (let n = 0; n < len; n++) {
if (index + n <= maxFileCount.value) {
uploadFile(files[n], index + n);
} else {
break;
}
}
// input的change事件默认保存上一次input的value值同一value值(根据文件路径判断)在上传时不重新加载
uploadInputRef.value[index].value = '';
}
emits('drop', e);
}
function onClickFileInput(index: number) {
uploadInputRef.value[index].click();
}
function onUpload(e: any, index: number) {
// 点击上传
const files = e.target.files;
if (files?.length) {
const len = files.length;
for (let n = 0; n < len; n++) {
if (index + n < maxFileCount.value) {
uploadFile(files[n], index + n);
} else {
break;
}
}
// input的change事件默认保存上一次input的value值同一value值(根据文件路径判断)在上传时不重新加载
uploadInputRef.value[index].value = '';
}
}
const uploadFile = async (file: File, index: number) => {
// 统一上传文件方法
// console.log('开始上传 upload-event files:', file)
const promiseFunction = () => {
return new Promise((resolve, reject) => {
try {
// 尝试执行传入的函数,并获取返回值
const result = props.beforeUpload(file);
// 检查返回值是否是 Promise
if (result instanceof Promise) {
// 如果是 Promise则等待其 resolve 或 reject
result.then(resolve, reject);
} else {
// 检查返回值是否为布尔值
if (typeof result === 'boolean') {
// 如果是布尔值根据值resolve或reject
if (result) {
resolve(result);
} else {
reject(new Error('Function returned false'));
}
// result ? resolve(result) : reject(new Error('Function returned false'));
} else {
// 否则直接resolve返回值
resolve(result);
}
}
} catch (error) {
// 如果执行过程中抛出错误,则 reject 错误
reject(error);
}
});
};
promiseFunction()
.then(() => {
// 使用用户钩子进行上传前文件判断,例如大小、类型限制
if (maxFileCount.value > showUpload.value) {
showUpload.value++;
}
if (props.uploadMode === 'base64') {
// 以base64方式读取文件
uploading.value[index] = true;
base64Upload(file, index);
}
if (props.uploadMode === 'custom') {
// 自定义上传行为,需配合 customRequest 属性
uploading.value[index] = true;
customUpload(file, index);
}
})
.catch((error: any) => {
console.log('beforeUpload error:', error);
});
};
function base64Upload(file: File, index: number) {
var reader = new FileReader();
reader.readAsDataURL(file); // 以base64方式读取文件
reader.onloadstart = function (_e) {
// 当读取操作开始时触发
// reader.abort() // 取消上传
// console.log('开始读取 onloadstart:', e)
};
reader.onabort = function (_e) {
// 当读取操作被中断时触发
// console.log('读取中止 onabort:', e)
};
reader.onerror = function (_e) {
// 当读取操作发生错误时触发
// console.log('读取错误 onerror:', e)
};
reader.onprogress = function (e) {
// 在读取Blob时触发读取上传进度50ms左右调用一次
// console.log('读取中 onprogress:', e)
// console.log('已读取:', Math.ceil(e.loaded / e.total * 100))
if (e.loaded === e.total) {
// 上传完成
uploading.value[index] = false; // 隐藏loading状态
}
};
reader.onload = function (e) {
// 当读取操作成功完成时调用
// console.log('读取成功 onload:', e)
// 该文件的base64数据如果是图片则前端可直接用来展示图片
uploadedFiles.value.push({
name: file.name,
url: e.target?.result
});
if (props.actionMessage.upload) {
messageRef.value.success(props.actionMessage.upload);
}
emits('update:fileList', uploadedFiles.value);
emits('change', uploadedFiles.value);
};
reader.onloadend = function (_e) {
// 当读取操作结束时触发(要么成功,要么失败)触发
// console.log('读取结束 onloadend:', e)
};
}
function customUpload(file: File, index: number) {
props
.customRequest(file)
.then((res: any) => {
uploadedFiles.value.push(res);
if (props.actionMessage.upload) {
messageRef.value.success(props.actionMessage.upload);
}
emits('update:fileList', uploadedFiles.value);
emits('change', uploadedFiles.value);
})
.catch((err: any) => {
if (maxFileCount.value > 1) {
showUpload.value = uploadedFiles.value.length + 1;
}
messageRef.value.error(err);
})
.finally(() => {
uploading.value[index] = false;
});
}
function onPreview(index: number, url: string) {
if (isImage(url)) {
const files = uploadedFiles.value.slice(0, index).filter((file) => !isImage(file.url));
imageRef.value[index - files.length].preview(0);
} else {
window.open(url);
}
emits('preview', uploadedFiles.value[index]);
}
function onRemove(index: number) {
if (uploadedFiles.value.length < maxFileCount.value) {
showUpload.value--;
}
const removeFile = uploadedFiles.value.splice(index, 1);
if (props.actionMessage.remove) {
messageRef.value.success(props.actionMessage.remove);
}
emits('remove', removeFile[0]);
emits('update:fileList', uploadedFiles.value);
emits('change', uploadedFiles.value);
}
function onInfo(content: string) {
messageRef.value.info(content);
}
function onSuccess(content: string) {
messageRef.value.success(content);
}
function onError(content: string) {
messageRef.value.error(content);
}
function onWarning(content: string) {
messageRef.value.warning(content);
}
function onLoading(content: string) {
messageRef.value.loading(content);
}
defineExpose({
info: onInfo,
success: onSuccess,
error: onError,
warning: onWarning,
loading: onLoading
});
</script>
<template>
<div class="m-upload-wrap">
<Space gap="small" v-bind="spaceProps">
<div class="m-upload-item" v-for="n of showUpload" :key="n">
<div class="m-upload">
<div
v-show="!uploading[n - 1] && !uploadedFiles[n - 1]"
class="upload-item"
:class="{ 'upload-disabled': disabled }"
@dragenter.stop.prevent
@dragover.stop.prevent
@drop.stop.prevent="draggable && !disabled ? onDrop($event, n - 1) : () => false"
@click="!disabled ? onClickFileInput(n - 1) : () => false"
>
<input
ref="uploadInputRef"
type="file"
@click.stop
:accept="accept"
:multiple="multiple"
@change="onUpload($event, n - 1)"
style="display: none"
/>
<div>
<svg
focusable="false"
class="plus-svg"
data-icon="plus"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<defs></defs>
<path d="M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z"></path>
<path d="M176 474h672q8 0 8 8v60q0 8-8 8H176q-8 0-8-8v-60q0-8 8-8z"></path>
</svg>
<p class="upload-tip">
<slot>{{ tip }}</slot>
</p>
</div>
</div>
<div v-show="uploading[n - 1]" class="file-uploading">
<Spin class="spin-uploading" tip="uploading" size="small" indicator="spin-line" v-bind="spinProps"/>
</div>
<div v-if="uploadedFiles[n - 1]" class="file-preview">
<Image
v-if="isImage(uploadedFiles[n - 1].url)"
ref="imageRef"
:bordered="false"
:width="82"
:height="82"
:fit="fit"
:src="uploadedFiles[n - 1].url"
:name="uploadedFiles[n - 1].name"
v-bind="imageProps"
/>
<svg
v-else-if="isPDF(uploadedFiles[n - 1].url)"
class="file-svg"
focusable="false"
data-icon="file-pdf"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M531.3 574.4l.3-1.4c5.8-23.9 13.1-53.7 7.4-80.7-3.8-21.3-19.5-29.6-32.9-30.2-15.8-.7-29.9 8.3-33.4 21.4-6.6 24-.7 56.8 10.1 98.6-13.6 32.4-35.3 79.5-51.2 107.5-29.6 15.3-69.3 38.9-75.2 68.7-1.2 5.5.2 12.5 3.5 18.8 3.7 7 9.6 12.4 16.5 15 3 1.1 6.6 2 10.8 2 17.6 0 46.1-14.2 84.1-79.4 5.8-1.9 11.8-3.9 17.6-5.9 27.2-9.2 55.4-18.8 80.9-23.1 28.2 15.1 60.3 24.8 82.1 24.8 21.6 0 30.1-12.8 33.3-20.5 5.6-13.5 2.9-30.5-6.2-39.6-13.2-13-45.3-16.4-95.3-10.2-24.6-15-40.7-35.4-52.4-65.8zM421.6 726.3c-13.9 20.2-24.4 30.3-30.1 34.7 6.7-12.3 19.8-25.3 30.1-34.7zm87.6-235.5c5.2 8.9 4.5 35.8.5 49.4-4.9-19.9-5.6-48.1-2.7-51.4.8.1 1.5.7 2.2 2zm-1.6 120.5c10.7 18.5 24.2 34.4 39.1 46.2-21.6 4.9-41.3 13-58.9 20.2-4.2 1.7-8.3 3.4-12.3 5 13.3-24.1 24.4-51.4 32.1-71.4zm155.6 65.5c.1.2.2.5-.4.9h-.2l-.2.3c-.8.5-9 5.3-44.3-8.6 40.6-1.9 45 7.3 45.1 7.4zm191.4-388.2L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494z"
></path>
</svg>
<svg
v-else
class="file-svg"
focusable="false"
data-icon="file"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M854.6 288.6L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494z"
></path>
</svg>
<div class="file-mask">
<a class="file-icon" title="预览" @click="onPreview(n - 1, uploadedFiles[n - 1].url)">
<svg
class="icon-svg"
focusable="false"
data-icon="eye"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z"
></path>
</svg>
</a>
<a v-show="!disabled" class="file-icon" title="删除" @click.prevent.stop="onRemove(n - 1)">
<svg
class="icon-svg"
focusable="false"
data-icon="delete"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z"
></path>
</svg>
</a>
</div>
</div>
</div>
</div>
</Space>
<Message ref="messageRef" v-bind="messageProps"/>
</div>
</template>
<style lang="less" scoped>
.m-upload-wrap {
display: inline-block;
.m-upload-item {
display: inline-block;
}
.mr8 {
margin-right: 8px;
}
}
.m-upload {
position: relative;
display: inline-block;
width: 100px;
height: 100px;
.upload-item {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
width: 100px;
height: 100px;
border-radius: 8px;
border: 1px dashed #d9d9d9;
background-color: rgba(0, 0, 0, 0.02);
cursor: pointer;
transition: border-color 0.3s;
&:hover {
border-color: #40a9ff;
}
.plus-svg {
display: inline-block;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
fill: currentColor;
}
.upload-tip {
margin-top: 8px;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
}
}
.upload-disabled {
cursor: not-allowed;
&:hover {
border-color: #d9d9d9;
}
}
.file-uploading {
width: 100px;
height: 100px;
padding: 8px;
border-radius: 8px;
border: 1px dashed #d9d9d9;
background-color: rgba(0, 0, 0, 0.02);
display: flex;
align-items: center;
text-align: center;
.spin-uploading {
display: inline-block;
:deep(.spin-tip) {
max-width: 82px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.file-preview {
position: relative;
padding: 8px;
width: 100px;
height: 100px;
border-radius: 8px;
border: 1px solid #d9d9d9;
display: flex;
align-items: center;
text-align: center;
.file-svg {
display: inline-block;
width: 100%;
height: 60px;
color: rgba(0, 0, 0, 0.88);
fill: currentColor;
}
.file-mask {
// top right bottom left 简写为 inset: 0
// insert 无论元素的书写模式、行内方向和文本朝向如何,其所定义的都不是逻辑偏移而是实体偏移
position: absolute;
inset: 0;
margin: 8px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
.file-icon {
display: inline-block;
height: 16px;
margin: 0 4px;
cursor: pointer;
.icon-svg {
display: inline-block;
font-size: 16px;
color: rgba(255, 255, 255, 0.65);
fill: currentColor;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: rgba(255, 255, 255, 1);
}
}
}
}
&:hover {
.file-mask {
opacity: 1;
pointer-events: auto;
}
}
}
}
</style>