957 Star 5.1K Fork 1.6K

GVPsmallwei / Avue

 / 详情

需要上传组件支持分片上传

待办的
创建于  
2023-08-15 16:00

对于文件上传,存储都支持分片上传了,对已经支持分片上传的存储增加分片上传功能

评论 (8)

david4liu 创建了任务

export function getFileTypeFromUrl(url) {
  // 使用正则表达式从链接中提取文件后缀
  const extension = url.split('.').pop().toLowerCase();

  // 常见的视频文件后缀
  const videoExtensions = ['mp4', 'avi', 'mkv', 'mov', 'wmv'];

  // 常见的图片文件后缀
  const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp'];

  // 常见的音频文件后缀
  const audioExtensions = ['mp3', 'wav', 'ogg', 'flac', 'aac'];

  // 检查后缀是否属于视频、图片或音频类型
  if (videoExtensions.includes(extension)) {
    return 'video';
  } else if (imageExtensions.includes(extension)) {
    return 'image';
  } else if (audioExtensions.includes(extension)) {
    return 'audio';
  } else {
    // 如果不属于以上任何一种类型,返回文件后缀
    return extension;
  }
}

<!--
 * @Author: 舒 shd_cn@163.com
 * @Date: 2023-09-02 14:31:40
 * @Description: 切片上传
-->

<template>
    <div class="slice-upload" v-loading="loading">
        <!-- 遍历显示已上传的文件列表 -->
        <div class="img-list-item " :style="{ width: w, height: h }" v-for="(item, index) in fileList" :key="index">
            <template v-if="item.file">
                <div class="remove" @click="removeFile(item, index)" v-if="!disabled">
                    <el-icon size="20">
                        <CircleClose />
                    </el-icon>
                </div>
                <!-- 根据文件类型显示不同的内容 -->
                <video v-if="getFileTypeFromUrl(item.url) === 'video'" style="width: 100%; height: 100%;" controls
                    :src="item.url"> 您的浏览器不支持视频播放</video>
                <el-image v-if="getFileTypeFromUrl(item.url) === 'image'" @mouseover="picSrcList = [item.url]"
                    :preview-src-list="picSrcList" style="width: 100%; height: 100%;" :src="item.url"
                    fit="cover"></el-image>
                <div v-else class="other  ">
                    <el-icon size="30">
                        <Document />
                    </el-icon>
                    <div class="reveal">
                        <el-progress v-if="item?.progress < 100" :striped="true" :striped-flow="true" class="progress"
                            :percentage="item.progress" :color="customColors" />
                    </div>
                </div>
            </template>
            <template v-else>
                <div class="remove" @click="removeFile(item, index)" v-if="!disabled">
                    <el-icon size="20">
                        <CircleClose />
                    </el-icon>
                </div>
                <!-- 根据文件类型显示不同的内容 -->
                <video v-if="getFileTypeFromUrl(item) === 'video'" style="width: 100%; height: 100%;" controls :src="item">
                    您的浏览器不支持视频播放
                </video>
                <el-image v-if="getFileTypeFromUrl(item) === 'image'" @mouseover="picSrcList = [item]"
                    :preview-src-list="picSrcList" style="width: 100%; height: 100%;" :src="item" fit="cover"></el-image>
                <div v-else class="other  ">
                    <el-icon size="30">
                        <Document />
                    </el-icon>
                </div>
            </template>

        </div>
        <!-- 文件上传组件 -->
        <el-upload class="upload-holder" v-if="fileList.length < limit && !disabled" drag :accept="limitTypes"
            :file-list="fileList" :disabled="disabled || fileList.length >= limit" :limit="limit"
            :before-upload="beforeUpload" list-type="picture-card" :show-file-list="false" :multiple="limit > 1"
            :http-request="uploadFileContinue">
            <el-icon>
                <Plus />
            </el-icon>
            <template #tip v-if="tip">
                <div class="el-upload__tip">
                    {{ tip }}
                </div>
            </template>
        </el-upload>
    </div>
</template>

<script setup>
import { ElMessage } from 'element-plus';
import { getFileTypeFromUrl } from '@/utils/common-tools'
const props = defineProps({
    // 绑定值
    modelValue: {
        type: [Array, String],
        default: () => '',
    },
    // 是否禁用
    disabled: {
        type: Boolean,
        default: false,
    },
    // 限制文件数量
    limit: {
        type: Number,
        default: 1,
    },
    // 宽度
    w: {
        type: String,
        default: '150px'
    },
    // 高度
    h: {
        type: String,
        default: '150px'
    },
    // 限制文件大小 单位MB
    limitSize: {
        type: Number,
        default: 1,
    },
    // 限制文件类型
    limitTypes: {
        type: String,
        default: '',
    },
    // 提示文字
    tip: {
        type: String,
        default: '',
    },
});

const picSrcList = ref([])

const loading = ref(false)

const { limitSize, limitTypes } = toRefs(props);
// 文件列表
const fileList = ref([]);
// 终止上传的文件列表
const abortList = ref([]);
// 限制文件大小
const limitBytes = limitSize.value * 1024 * 1024; // 将以MB为单位的文件大小限制转换为字节


const customColors = [
    { color: '#e6a23c', percentage: 30 },
    { color: '#1989fa', percentage: 60 },
    { color: '#5cb87a', percentage: 100 },
]

// 格式化文件大小
const formatSize = (bytes) => {
    const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
    if (bytes === 0) return '0 Byte';
    const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
    return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i];
};

const emit = defineEmits([
    'update:modelValue',
]);


// 监听文件列表变化,触发更新外部数据
watch(() => fileList.value, (newVal) => {
    emit('update:modelValue', newVal);
}, {
    deep: true,
});

watch(() => props.modelValue, (newVal) => {
    console.log(newVal)
    if (fileList.value.length === 0, newVal.length != 0) {
        fileList.value = newVal;
    }
}, {
    deep: true,
    immediate: true,
});



// 删除资源
const removeFile = (file, index) => {
    fileList.value.splice(index, 1);
    if (file.file) {
        abortList.value.push(file.file);
    }
};

// 上传之前的钩子函数
const beforeUpload = async (file) => {

    if (fileList.value && fileList.value.length >= props.limit) {
        ElMessage.error(`当前限制上传 ${props.limit} 个文件,本次选择了 ${fileList.value.length} 个文件`);
        return false;
    }


    // const fileType = limitTypes.value.split(',')
    // // 在上传之前执行的函数
    // const isAllowedType = fileType.includes(file.type);

    // console.log(fileType.includes(file.type))
    // if (!isAllowedType && limitTypes.value.length > 0) {
    //     ElMessage.error(`上传文件格式不正确,请选择 ${limitTypes.value} 格式的文件`);
    //     return false;
    // }
    const isSizeValid = file.size <= limitBytes;
    if (!isSizeValid) {
        ElMessage.error(`上传文件大小不能超过 ${formatSize(limitSize.value)}MB`);
        return false;
    }
};




// 上传切片的钩子函数
const uploadFileContinue = async ({ data, file }) => {
    loading.value = true
    const uploadFun = useSliceUpload(file, (fileInfo) => {
        loading.value = false
        if (abortList.value.includes(fileInfo.file)) {
            return false
        }

        const index = fileList.value.findIndex(item => item.file.uid === file.uid)
        if (index !== -1) {
            fileList.value.splice(index, 1, fileInfo);
        } else {
            fileList.value.push(fileInfo);
        }
        return true
    })
    await uploadFun.then(res => {
        loading.value = false

    }).catch(err => {
        loading.value = false

        console.log(err)
        ElMessage.error(`上传文件出错`);
    })

}

</script>

<style scoped lang="scss">
.slice-upload {
    display: flex;
    width: 100%;
    flex-wrap: wrap;

    .upload-holder {
        position: relative;
        display: inline-block;
    }

    // 视频
    .img-list-item {
        position: relative;
        margin-right: 10px;
        border-radius: 8px;
        overflow: hidden;
        background-color: #00000010;

        .remove {
            cursor: pointer;
            position: absolute;
            border-radius: 50%;
            overflow: hidden;
            right: 5px;
            top: 5px;
            width: 20px;
            height: 20px;
            text-align: center;
            line-height: 20px;
            transition: all 0.3s;
            // 高斯模糊
            backdrop-filter: blur(10px);
            z-index: 3;
        }

        .other {
            width: 100%;
            height: 100%;
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;

            .reveal {
                width: 100%;
                height: 100%;
                position: absolute;
                background-color: #00000020;
            }

            .progress {
                width: 100%;
                position: absolute;
                bottom: 10%;
                margin-left: 10%;
                left: 50%;
                z-index: 2;
                transform: translateX(-50%);
            }
        }


    }


    .img-list-item i.del-img {
        width: 20px;
        height: 20px;
        display: inline-block;
        background: rgba(0, 0, 0, .6);
        background-image: url(../assets/images/close.png);
        background-size: 18px;
        background-repeat: no-repeat;
        background-position: 50%;
        position: absolute;
        top: 0;
        right: 9px;
    }
}
</style>
/*
 * @Author: 舒 shd_cn@163.com
 * @Date: 2023-09-04 09:31:02
 * @Description: 切片上传
 */

import { initUpload, uploadChunk, fileMerge, abort } from '@/api/resource/oss'


const concurrentUploadLimit = 5; // 并发上传限制
const chunkSize = 10 * 1024 * 1024; // 10MB,你可以根据需要调整切片大小



// 初始化上传
const initUploadFunc = async (fileName) => {
    // 调用初始化上传接口,传递文件名,返回 uploadId
    try {
        const response = await initUpload(fileName);
        if (response.data.success) {
            return response.data.data.uploadId;
        } else {
            ElMessage.error('初始化上传失败,请重试');
            return null;
        }
    } catch (error) {
        ElMessage.error('初始化上传失败,请重试');
        return null;
    }
};



// 获取当前chunk数据
const getChunkInfo = (file, index) => {
    let start = index * chunkSize;
    let end = Math.min(file.size, start + chunkSize);
    let chunk = file.slice(start, end);
    return { start, end, chunk };
};




/**  上传文件
 * @param file 文件对象
 * @param callback 回调函数,用于获取上传进度
 * @returns return new Promise((resolve, reject) => {
 *   resolve(formData)
 * })
 * 
*/
export default async function (file, callback = null) {
    let formData = shallowReactive({
        chunks: Math.ceil(file.size / chunkSize),
        chunk: 0,
        url: '',
        uploadId: null,
        file,
        progress: 0
    })

    // 用于控制并发上传的队列
    let uploadQueue = [];
    const uploadChunkFunc = async (file, callback) => {
        let hasError = false; // 用于标志是否有错误发生
        let err = null
        // 是否调用取消上传
        let abortUpload = false;
        for (let index = 0; index < formData.chunks; index++) {
            if (hasError) {
                // 如果已经发生错误,立即返回false,终止循环
                err = '切片上传失败,请重试'
                return false;
            }

            const { chunk } = getChunkInfo(file, index);
            // 将上传切片的操作添加到队列中
            uploadQueue.push(
                uploadChunk(
                    formData.uploadId,
                    index + 1,
                    chunk
                ).then(res => {
                    // 计算上传进度
                    formData.progress += Math.ceil((100 / formData.chunks));
                    if (callback && !callback(formData)) {
                        !abortUpload && abort(formData.uploadId, file.name).then(res => {
                            abortUpload = true
                        })
                        hasError = true
                        err = '取消上传'
                    }
                }).catch(error => {
                    ElMessage.error(`切片(${index + 1})上传失败`);
                    hasError = true
                    err = error
                    return false;
                })
            );


            // 当队列长度达到并发限制或已上传完所有切片时,等待队列中的所有操作完成
            if (uploadQueue.length === concurrentUploadLimit || index === formData.chunks - 1) {
                await Promise.all(uploadQueue).then(res => {
                    uploadQueue = []
                }).catch(error => {
                    ElMessage.error('切片上传失败,请重试');
                    uploadQueue = [];
                    hasError = true
                    err = error
                    return false;
                })
            }
        }

        return { fulfil: !hasError, err }
    };

    const uploadMethod = new Promise(async (resolve, reject) => {


        const uploadId = await initUploadFunc(file.name);
        if (uploadId !== null) {
            // 初始化上传,获取 uploadId
            formData.uploadId = uploadId;
            if (callback && !callback(formData)) {
                reject(err);
                return false
            }
            // 上传切片
            const { fulfil, err } = await uploadChunkFunc(file, callback)
            if (fulfil) {
                await fileMerge(formData.uploadId).then(res => {
                    formData.url = res.data.data.url
                    if (callback && !callback(formData)) {
                        reject(err);
                        return false
                    }
                    resolve(formData)
                }).catch(err => {
                    reject(err)
                })

            } else {
                reject(err);
            }


        } else {
            reject('初始化上传失败,请重试');
        }

    })
    return uploadMethod
}

这是 useSliceUpload 方法内容

import request from '@/axios';

export const getList = (current, size, params) => {
  return request({
    url: '/blade-resource/oss/list',
    method: 'get',
    params: {
      ...params,
      current,
      size,
    },
  });
};

export const getDetail = id => {
  return request({
    url: '/blade-resource/oss/detail',
    method: 'get',
    params: {
      id,
    },
  });
};

export const remove = ids => {
  return request({
    url: '/blade-resource/oss/remove',
    method: 'post',
    params: {
      ids,
    },
  });
};

export const add = row => {
  return request({
    url: '/blade-resource/oss/submit',
    method: 'post',
    data: row,
  });
};

export const update = row => {
  return request({
    url: '/blade-resource/oss/submit',
    method: 'post',
    data: row,
  });
};

export const enable = id => {
  return request({
    url: '/blade-resource/oss/enable',
    method: 'post',
    params: {
      id,
    },
  });
};

// 大文件上传初始化
/**
 * @description: 大文件上传初始化
 * @param {*} fileName 文件名
 * @returns 
 */
export const initUpload = fileName => {
  return request({
    url: '/blade-resource/oss/endpoint/initUpload',
    method: 'post',
    params: {
      fileName,
    },
  })
};


/**
* @description: 大文件上传分片文件
* @param {*} uploadId  上传id
* @param {*} chunkId   分片id
* @param {*} chunkFile 分片文件
*/
export const uploadChunk = (uploadId, chunkId, chunkFile) => {
  return request({
    url: '/blade-resource/oss/endpoint/uploadChunk',
    method: 'post',
    headers: {
      'Content-Type': 'multipart/form-data',
    },
    params: {
      uploadId,
      chunkId,
    },
    data: {
      chunkFile,
    }
  })
};


/**
 * @description: 大文件上传合并
 * @param {*} uploadId  上传id
 * @returns 
 */
export const fileMerge = (uploadId) => {
  return request({
    url: '/blade-resource/oss/endpoint/merge',
    method: 'post',
    params: {
      uploadId,
    }
  })
};


/**
 * @description: 大文件上传取消
 * @param {*} uploadId  上传id
 * @returns 
 */
export const abort = (uploadId, fileName) => {
  return request({
    url: '/blade-resource/oss/endpoint/abort',
    method: 'post',
    params: {
      uploadId,
      fileName
    }
  })
};


这是import { initUpload, uploadChunk, fileMerge, abort } from '@/api/resource/oss'
的接口代码内容

这是使用方式

  <!-- 视频 -->
      <template #resource-form="{ type }">
        <slice-upload
          :limitSize="100"
          :limit="1"
          limitTypes="video/*"
          :disabled="type == 'view'"
          v-model="form.resource"
        ></slice-upload>
      </template>

我就不PR了好麻烦 直接贴代码吧 应该能看懂,有问题可以回复

登录 后才可以发表评论

状态
负责人
里程碑
Pull Requests
关联的 Pull Requests 被合并后可能会关闭此 issue
分支
开始日期   -   截止日期
-
置顶选项
优先级
参与者(2)
5365333 shudd 1696845763
JavaScript
1
https://gitee.com/smallweigit/avue.git
git@gitee.com:smallweigit/avue.git
smallweigit
avue
Avue

搜索帮助