<script lang="ts" setup>
|
import { computed, nextTick, onMounted, reactive, ref, unref, watch } from 'vue';
|
import VueSimpleUploader from 'vue-simple-uploader';
|
import 'vue-simple-uploader/dist/style.css';
|
|
import { useGlobSetting } from '@jnpf/hooks';
|
import { buildBitUUID } from '@jnpf/utils';
|
|
import { $t } from '@vben/locales';
|
import { useAccessStore } from '@vben/stores';
|
|
import { globalShareState } from '@vben-core/shared/global-state';
|
|
import { message } from 'ant-design-vue';
|
import SparkMD5 from 'spark-md5';
|
|
import { units, uploadFileProps } from '../props';
|
import FileItem from './FileItem.vue';
|
|
const props = defineProps(uploadFileProps);
|
|
const emit = defineEmits(['fileSuccess']);
|
|
const { chunkMerge } = globalShareState.getApi();
|
|
const Uploader = VueSimpleUploader.Uploader;
|
const UploaderBtn = VueSimpleUploader.UploaderBtn;
|
const UploaderUnsupport = VueSimpleUploader.UploaderUnsupport;
|
const UploaderList = VueSimpleUploader.UploaderList;
|
const accessStore = useAccessStore();
|
const globSetting = useGlobSetting();
|
const uploaderRef = ref<any>(null);
|
const uploaderBtnRef = ref<any>(null);
|
const options = reactive({
|
// 服务器分片校验函数,秒传及断点续传基础
|
checkChunkUploadedByResponse(chunk, message) {
|
const objMessage = JSON.parse(message);
|
if (objMessage.code === 200) {
|
if (objMessage.data.uploaded) {
|
return true;
|
}
|
const chunkNumbers = objMessage.data.chunkNumbers;
|
return (chunkNumbers || []).includes(chunk.offset + 1);
|
} else {
|
return true;
|
}
|
},
|
chunkSize: 1024 * 1024 * 5,
|
headers: {
|
Authorization: accessStore.accessToken,
|
},
|
maxChunkRetries: 5,
|
query: {
|
extension: '',
|
fileType: '',
|
},
|
singleFile: props.limit === 1,
|
target: `${globSetting.apiURL}/api/file/chunk`,
|
testChunks: true, // 是否开启服务器分片校验
|
});
|
const messageKey = 'upload';
|
const attrs = ref({ accept: props.accept || '*' });
|
const statusText = {
|
error: $t('component.upload.uploadError'),
|
paused: $t('component.upload.paused'),
|
success: $t('component.upload.uploadSuccess'),
|
uploading: $t('component.upload.uploading'),
|
waiting: $t('component.upload.waiting'),
|
};
|
|
const getAcceptText = computed(() => {
|
let txt = '';
|
if (props.accept.includes('image/*')) txt += `、${$t('component.upload.image')}`;
|
if (props.accept.includes('video/*')) txt += `、${$t('component.upload.video')}`;
|
if (props.accept.includes('audio/*')) txt += `、${$t('component.upload.audio')}`;
|
if (props.accept.includes('.xls,.xlsx')) txt += '、excel';
|
if (props.accept.includes('.doc,.docx')) txt += '、word';
|
if (props.accept.includes('.pdf')) txt += '、pdf';
|
if (props.accept.includes('.txt')) txt += '、txt';
|
return txt.slice(1);
|
});
|
|
watch(
|
() => props.accept,
|
(val) => {
|
attrs.value.accept = val || '*';
|
},
|
);
|
|
defineExpose({ openUploader });
|
|
function openUploader() {
|
uploaderBtnRef.value.$el.click();
|
}
|
// 上传前校验
|
function beforeUpload(file) {
|
const isTopLimit = props.limit ? props.value.length >= props.limit : false;
|
if (isTopLimit) {
|
message.error({ content: $t('component.upload.fileMaxNumber', [props.limit]), key: messageKey });
|
return false;
|
}
|
const isRightSize = true;
|
if (props.fileSize) {
|
const unitNum = units[props.sizeUnit];
|
const isRightSize = file.size / unitNum < props.fileSize;
|
if (!isRightSize) {
|
message.error({ content: $t('component.upload.fileMaxSize', { size: props.fileSize, unit: props.sizeUnit }), key: messageKey });
|
return isRightSize;
|
}
|
}
|
const isAccept = checkAccept(file);
|
if (!isAccept) {
|
message.error({ content: $t('component.upload.fileTypeCheck', [unref(getAcceptText)]), key: messageKey });
|
return isAccept;
|
}
|
return isRightSize && isAccept;
|
}
|
// 校验格式
|
function checkAccept(file) {
|
if (!props.accept || props.accept === '*') return true;
|
const extension = file.getExtension();
|
const fileType = file.fileType;
|
if (props.accept.includes(extension)) return true;
|
if (props.accept.includes('image/*') && /image\/*/.test(fileType)) return true;
|
if (props.accept.includes('video/*') && /video\/*/.test(fileType)) return true;
|
if (props.accept.includes('audio/*') && /audio\/*/.test(fileType)) return true;
|
return false;
|
}
|
function onFileAdded(file) {
|
if (beforeUpload && typeof beforeUpload === 'function' && !beforeUpload(file)) {
|
file.cancel();
|
return false;
|
}
|
// 自定义状态
|
file.customStatus = 'check';
|
options.query.fileType = file.fileType;
|
options.query.extension = file.getExtension();
|
computeMD5(file);
|
}
|
/**
|
* 计算md5,实现断点续传及秒传
|
* @param file
|
*/
|
function computeMD5(file) {
|
const fileReader = new FileReader();
|
const blobSlice = File.prototype.slice || (File.prototype as any).mozSlice || (File.prototype as any).webkitSlice;
|
let currentChunk = 0;
|
const chunkSize = 10 * 1024 * 1000;
|
const chunks = Math.ceil(file.size / chunkSize);
|
const spark = new SparkMD5.ArrayBuffer();
|
|
file.pause();
|
loadNext();
|
|
fileReader.addEventListener('load', (e) => {
|
spark.append(e.target?.result);
|
if (currentChunk < chunks) {
|
currentChunk++;
|
loadNext();
|
} else {
|
const md5 = spark.end();
|
computeMD5Success(md5, file);
|
}
|
});
|
// eslint-disable-next-line unicorn/prefer-add-event-listener
|
fileReader.onerror = function () {
|
message.error($t('component.upload.fileTypeCheck', [file.name]));
|
file.cancel();
|
};
|
|
function loadNext() {
|
const start = currentChunk * chunkSize;
|
const end = Math.min(start + chunkSize, file.size);
|
// eslint-disable-next-line unicorn/prefer-blob-reading-methods
|
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
|
}
|
}
|
function computeMD5Success(md5, file) {
|
file.uniqueIdentifier = md5 + buildBitUUID(); // 把md5值+随机码作为文件的识别码
|
file.customStatus = 'uploading';
|
file.resume(); // 开始上传
|
}
|
function onFileSuccess(_rootFile, file, response, _chunk) {
|
const res = JSON.parse(response);
|
if (res.code != 200) {
|
message.error(res.msg);
|
file.cancel();
|
return;
|
}
|
setTimeout(() => {
|
// 秒传 直接展示
|
if (res.data.uploaded) {
|
// 秒传结果
|
} else if (res.data.merge) {
|
// 需要合并
|
handleSuccess(file);
|
} else {
|
// 上传错误
|
file.cancel();
|
message.error($t('component.upload.uploadError'));
|
}
|
}, 300);
|
}
|
function onFileProgress(_rootFile, _file, _chunk) {
|
// console.log(`上传中 ${_file.name},chunk:${_chunk.startByte / 1024 / 1024} ~ ${_chunk.endByte / 1024 / 1024}`);
|
}
|
function onFileError(_rootFile, file, _response, _chunk) {
|
file.cancel();
|
message.error($t('component.upload.uploadError'));
|
}
|
function handleSuccess(file) {
|
const query = {
|
extension: file.getExtension(),
|
fileName: file.name.replaceAll('#', ''),
|
fileSize: file.size,
|
fileType: file.getType(),
|
folder: props.folder,
|
identifier: file.uniqueIdentifier,
|
pathType: props.pathType,
|
sortRule: (props.sortRule || []).join(','),
|
timeFormat: props.timeFormat,
|
type: props.type,
|
};
|
chunkMerge(query)
|
.then((res) => {
|
// 自定义完成状态
|
file.customCompleted = true;
|
const data = {
|
fileExtension: res.data.fileExtension,
|
fileId: res.data.name,
|
fileSize: res.data.fileSize,
|
fileVersionId: res.data.fileVersionId,
|
name: file.name.replaceAll('#', ''),
|
url: res.data.url,
|
};
|
emit('fileSuccess', data);
|
file.cancel();
|
})
|
.catch(() => {
|
file.cancel();
|
});
|
}
|
|
onMounted(() => {
|
nextTick(() => {
|
(window as any).uploader = uploaderRef.value?.uploader;
|
});
|
});
|
</script>
|
|
<template>
|
<Uploader
|
ref="uploaderRef"
|
:auto-start="false"
|
:class="{ isFirst: value.length === 0 }"
|
:file-status-text="statusText"
|
:options="options"
|
class="uploader-app"
|
@file-added="onFileAdded"
|
@file-error="onFileError"
|
@file-progress="onFileProgress"
|
@file-success="onFileSuccess">
|
<UploaderUnsupport />
|
<UploaderBtn id="file-uploader-btn" ref="uploaderBtnRef" :attrs="attrs">选择文件</UploaderBtn>
|
<UploaderList>
|
<template #default="{ fileList }">
|
<div class="upload-file-list">
|
<div v-for="file in fileList" :key="file.id" class="upload-file-list__item">
|
<FileItem :class="`file_${file.id}`" :file="file" />
|
</div>
|
</div>
|
</template>
|
</UploaderList>
|
</Uploader>
|
</template>
|
<style lang="scss">
|
.uploader-app {
|
padding: 0;
|
margin: 0;
|
|
&.isFirst .upload-file-list {
|
.upload-file-list__item {
|
margin-top: 10px;
|
}
|
}
|
|
.upload-file-list {
|
.upload-file-list__item {
|
margin-top: 5px;
|
overflow: hidden;
|
border-radius: 4px;
|
}
|
|
.uploader-file {
|
height: 26px !important;
|
line-height: 26px;
|
border-bottom: none;
|
|
&:hover {
|
background-color: var(--selected-hover-bg);
|
}
|
}
|
}
|
|
.uploader-file-icon {
|
&::before {
|
content: '' !important;
|
}
|
}
|
}
|
|
/* 隐藏上传按钮 */
|
#file-uploader-btn {
|
position: absolute;
|
clip: rect(0, 0, 0, 0);
|
}
|
</style>
|