commit
55898943c4
@ -87,9 +87,11 @@
|
|||||||
"@dcloudio/uni-cli-shared": "3.0.0-4060420250429001",
|
"@dcloudio/uni-cli-shared": "3.0.0-4060420250429001",
|
||||||
"@dcloudio/uni-stacktracey": "3.0.0-4060420250429001",
|
"@dcloudio/uni-stacktracey": "3.0.0-4060420250429001",
|
||||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060420250429001",
|
"@dcloudio/vite-plugin-uni": "3.0.0-4060420250429001",
|
||||||
|
"@types/html5plus": "^1.0.5",
|
||||||
"@vue/runtime-core": "^3.5.12",
|
"@vue/runtime-core": "^3.5.12",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.5.1",
|
||||||
"less": "^4.2.0",
|
"less": "^4.2.0",
|
||||||
|
"miniprogram-api-typings": "^4.1.0",
|
||||||
"sass": "1.78.0",
|
"sass": "1.78.0",
|
||||||
"sass-loader": "^16.0.1",
|
"sass-loader": "^16.0.1",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
|
|||||||
85
src/api/system/chunkUpload/index.js
Normal file
85
src/api/system/chunkUpload/index.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import config from "@/config";
|
||||||
|
import { getToken } from "@/utils/auth";
|
||||||
|
|
||||||
|
|
||||||
|
/**初始化上传 */
|
||||||
|
export function initChunkUpload(fileName, fileSize) {
|
||||||
|
return request({
|
||||||
|
url: '/file/initUpload',
|
||||||
|
method: 'post',
|
||||||
|
params: {
|
||||||
|
fileName,
|
||||||
|
fileSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**上传分片视频 */
|
||||||
|
export function uploadChunk(uploadId, filePath, chunkIndex, formattedPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: `${config.baseUrl}/file/uploadChunk`,
|
||||||
|
filePath: formattedPath,
|
||||||
|
name: "chunk",
|
||||||
|
timeout: 60000, // 增加超时时间到60秒
|
||||||
|
header: {
|
||||||
|
Authorization: `Bearer ${getToken()}`,
|
||||||
|
},
|
||||||
|
formData: {
|
||||||
|
uploadId: uploadId,
|
||||||
|
filePath: filePath,
|
||||||
|
partNumber: chunkIndex,
|
||||||
|
},
|
||||||
|
success: (res) => {
|
||||||
|
try {
|
||||||
|
const resultData = JSON.parse(res.data);
|
||||||
|
resolve(resultData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("解析上传结果失败:", error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error(`分片${chunkIndex}上传请求失败:`, err);
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**完成分片上传 */
|
||||||
|
export function completeChunkUpload(uploadId, filePath, fileSize, fileName, partETags) {
|
||||||
|
return request({
|
||||||
|
url: '/file/completeUpload',
|
||||||
|
method: 'post',
|
||||||
|
params: {
|
||||||
|
uploadId,
|
||||||
|
filePath,
|
||||||
|
fileSize,
|
||||||
|
fileName,
|
||||||
|
},
|
||||||
|
data: partETags
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<view class="confirm-dialog" v-if="visible">
|
||||||
|
<view class="dialog-mask"></view>
|
||||||
|
<view class="dialog-content">
|
||||||
|
<view class="dialog-header">提示</view>
|
||||||
|
<view class="dialog-title">{{ title }}</view>
|
||||||
|
<view class="dialog-buttons">
|
||||||
|
<button class="button cancel-btn" @click="handleCancel" hover-class="button-hover">{{ cancelText
|
||||||
|
}}</button>
|
||||||
|
<button class="button confirm-btn" @click="handleConfirm" hover-class="button-hover"
|
||||||
|
:style="{ background: color }">{{ confirmText }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '提示'
|
||||||
|
},
|
||||||
|
cancelText: {
|
||||||
|
type: String,
|
||||||
|
default: '取消'
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
type: String,
|
||||||
|
default: '确认'
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: '#1E88E5'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:visible', 'confirm', 'cancel']);
|
||||||
|
const handleConfirm = () => {
|
||||||
|
emit('update:visible', false);
|
||||||
|
emit('confirm')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('cancel')
|
||||||
|
emit('update:visible', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.confirm-dialog {
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 280px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
padding: 10px 20px 20px 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
flex: 1;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&.button-hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: #F5F5F5;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<view class="upload-box">
|
||||||
|
<view>
|
||||||
|
<view class="upload-container" v-if="!videoCover" @click="chooseVideo">
|
||||||
|
<uni-icons type="cloud-upload-filled" size="60" color="#ff6634"></uni-icons>
|
||||||
|
<text class="upload-text">{{ uploadText }}</text>
|
||||||
|
<text class="upload-desc">{{ displayUploadDesc }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="video-preview">
|
||||||
|
<view class="cover-container">
|
||||||
|
<view class="icon-cuo" @tap.stop="delectVideo">
|
||||||
|
<uni-icons type="close" size="16" color="#ffffff"></uni-icons>
|
||||||
|
</view>
|
||||||
|
<video :src="videoCover" class="video-cover" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<ConfirmDialog v-model:visible="showModal" title="是否要删除此视频" @confirm="confirmDelete" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import { defineProps, defineEmits } from 'vue'
|
||||||
|
import modal from '@/plugins/modal';
|
||||||
|
import ConfirmDialog from '@/components/geek-xd/components/geek-confirm-dialog/geek-confirm-dialog.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
uploadText: {
|
||||||
|
type: String,
|
||||||
|
default: '点击上传视频文件'
|
||||||
|
},
|
||||||
|
uploadDesc: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
sourceType: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ['album', 'camera']
|
||||||
|
},
|
||||||
|
maxSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 500 // 默认500M
|
||||||
|
},
|
||||||
|
supportedTypes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ['mp4', 'mov', 'avi'] // 支持的视频格式
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['upload', 'delete'])
|
||||||
|
|
||||||
|
const videoCover = ref('')
|
||||||
|
const showModal = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态生成上传描述文本
|
||||||
|
* 如果用户提供了自定义描述则使用,否则根据支持的格式和文件大小限制自动生成
|
||||||
|
* @returns {string} 上传描述文本
|
||||||
|
*/
|
||||||
|
const displayUploadDesc = computed(() => {
|
||||||
|
return props.uploadDesc || `支持${props.supportedTypes.join('/')}等格式,文件大小不超过${props.maxSize}M`
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除视频处理函数
|
||||||
|
* 阻止事件冒泡并显示确认删除对话框
|
||||||
|
* @param {Event} event - 点击事件对象
|
||||||
|
*/
|
||||||
|
const delectVideo = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
showModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认删除视频
|
||||||
|
* 关闭确认对话框,清空视频封面,并触发删除事件
|
||||||
|
*/
|
||||||
|
const confirmDelete = () => {
|
||||||
|
showModal.value = false
|
||||||
|
videoCover.value = null
|
||||||
|
emit('delete')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件路径中提取文件扩展名
|
||||||
|
* @param {string} filePath - 文件路径
|
||||||
|
* @returns {string} 文件扩展名(小写),如果没有扩展名则返回空字符串
|
||||||
|
*/
|
||||||
|
const getFileExtension = (filePath) => {
|
||||||
|
if (!filePath) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const dotIndex = filePath.lastIndexOf('.');
|
||||||
|
if (dotIndex === -1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return filePath.substring(dotIndex + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证视频文件类型是否符合支持的格式
|
||||||
|
* 统一的跨平台文件类型验证
|
||||||
|
* @param {string} filePath - 视频文件路径
|
||||||
|
* @returns {boolean} 验证通过返回true,否则返回false
|
||||||
|
*/
|
||||||
|
const validateVideoFileType = (filePath) => {
|
||||||
|
if (!filePath) {
|
||||||
|
modal.msg('无法识别文件类型');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExtension = getFileExtension(filePath);
|
||||||
|
console.log('文件类型:', fileExtension);
|
||||||
|
|
||||||
|
if (props.supportedTypes.includes(fileExtension)) return true;
|
||||||
|
|
||||||
|
modal.msg(`请上传正确格式的视频`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查视频文件大小是否超过限制
|
||||||
|
* @param {number} fileSize - 文件大小(字节)
|
||||||
|
* @returns {boolean} 文件大小符合要求返回true,否则返回false
|
||||||
|
*/
|
||||||
|
const checkVideoFileSize = (fileSize) => {
|
||||||
|
const maxSize = props.maxSize * 1024 * 1024;
|
||||||
|
if (fileSize <= maxSize) return true;
|
||||||
|
modal.msg(`视频大小不能超过${props.maxSize}M`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装视频数据对象
|
||||||
|
* 根据不同平台返回不同格式的视频数据
|
||||||
|
* @param {Object} res - uni.chooseVideo返回的结果对象
|
||||||
|
* @returns {Object} 组装后的视频数据对象
|
||||||
|
*/
|
||||||
|
const buildVideoData = (res) => {
|
||||||
|
let videoData = {}
|
||||||
|
console.log('选择的视频文件:', res);
|
||||||
|
|
||||||
|
// #ifdef APP-PLUS || H5
|
||||||
|
videoData = {
|
||||||
|
path: res.tempFilePath,
|
||||||
|
size: res.size,
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
videoData = {
|
||||||
|
path: res.tempFilePath,
|
||||||
|
size:res.size
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
|
||||||
|
console.log('组装后的视频数据:', videoData);
|
||||||
|
|
||||||
|
return videoData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择视频文件
|
||||||
|
* 调用uni-app的chooseVideo API选择视频文件
|
||||||
|
* 包含文件类型验证、大小验证和数据处理
|
||||||
|
*/
|
||||||
|
const chooseVideo = () => {
|
||||||
|
uni.chooseVideo({
|
||||||
|
count: 1,
|
||||||
|
compressed: false,
|
||||||
|
sourceType: props.sourceType,
|
||||||
|
success: (res) => {
|
||||||
|
if (!validateVideoFileType(res.tempFilePath)) return;
|
||||||
|
if (!checkVideoFileSize(res.size)) return;
|
||||||
|
videoCover.value = res.tempFilePath
|
||||||
|
const videoData = buildVideoData(res)
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.msgSuccess('上传成功')
|
||||||
|
emit('upload', videoData)
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
modal.msg('选择视频失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.upload-box {
|
||||||
|
height: 350rpx;
|
||||||
|
margin: 0 20rpx;
|
||||||
|
background: #FFFCFA;
|
||||||
|
border: 2rpx dashed #ff6634;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
|
||||||
|
.upload-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #ff6634;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.cover-container {
|
||||||
|
position: relative;
|
||||||
|
width: 80%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.icon-cuo {
|
||||||
|
position: absolute;
|
||||||
|
top: -20rpx;
|
||||||
|
right: -30rpx;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
background-color: #ff4d4f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 220rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -236,6 +236,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "code/index"
|
"path": "code/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "upload/index"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,13 @@ export default [
|
|||||||
icon: 'wxCenter',
|
icon: 'wxCenter',
|
||||||
title: '二维码',
|
title: '二维码',
|
||||||
title_en: 'index',
|
title_en: 'index',
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
path: '/pages_geek/pages/upload/index',
|
||||||
|
icon: 'wxCenter',
|
||||||
|
title: '分片上传',
|
||||||
|
title_en: 'index',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
103
src/pages_geek/pages/upload/index.vue
Normal file
103
src/pages_geek/pages/upload/index.vue
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<view class="upload-page">
|
||||||
|
<uni-section class="mb-10" title="视频上传" sub-title="支持大文件分片上传" type="line" />
|
||||||
|
|
||||||
|
<geek-uploadbox @upload="handleUpload" @delete="handleDelete" upload-text="选择视频文件"
|
||||||
|
upload-desc="支持 mp4/mov/avi 格式,最大 500M" />
|
||||||
|
|
||||||
|
<view class="upload-actions">
|
||||||
|
<button class="upload-btn" :disabled="!videoFile" @click="start">
|
||||||
|
{{ videoFile ? '开始上传' : '请先选择文件' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import modal from '@/plugins/modal'
|
||||||
|
import { chunkUpload } from '@/utils/ChunkUpload'
|
||||||
|
|
||||||
|
const videoFile = ref(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理视频上传
|
||||||
|
* @param {Object} data - 上传的视频数据
|
||||||
|
*/
|
||||||
|
const handleUpload = (data) => {
|
||||||
|
videoFile.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理视频删除
|
||||||
|
*/
|
||||||
|
const handleDelete = () => {
|
||||||
|
videoFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验上传参数
|
||||||
|
* @returns {boolean} 校验结果
|
||||||
|
*/
|
||||||
|
const validateParams = () => {
|
||||||
|
if (videoFile.value) return true
|
||||||
|
modal.msg('请先选择视频文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始上传
|
||||||
|
*/
|
||||||
|
const start = async () => {
|
||||||
|
if (!validateParams()) return
|
||||||
|
const result = await chunkUpload.upload({
|
||||||
|
file: videoFile.value,
|
||||||
|
onSuccess: () => {
|
||||||
|
modal.msgSuccess('上传成功')
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
modal.msg(error)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.upload-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f6f6f6;
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: linear-gradient(135deg, #1976d2, #42a5f5);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:not(:disabled):active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(25, 118, 210, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #cccccc;
|
||||||
|
color: #999999;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/types/upload.ts
Normal file
64
src/types/upload.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export interface UploadOptions {
|
||||||
|
/**文件 */
|
||||||
|
file: File
|
||||||
|
/**成功回调 */
|
||||||
|
onSuccess?: (result: any) => void;
|
||||||
|
/**失败回调 */
|
||||||
|
onError?: (error: any) => void;
|
||||||
|
/**上传配置 */
|
||||||
|
options?: UploadConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface File {
|
||||||
|
/**文件路径 */
|
||||||
|
path: string;
|
||||||
|
/**文件大小 */
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadConfig {
|
||||||
|
/**分片大小,单位字节 */
|
||||||
|
chunkSize?: number;
|
||||||
|
/**并发上传限制 */
|
||||||
|
concurrentLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadData {
|
||||||
|
/**上传编号 */
|
||||||
|
uploadId: string;
|
||||||
|
/**文件在云端保存路径 */
|
||||||
|
saveFilePath: string;
|
||||||
|
/**上传文件的大小 */
|
||||||
|
fileSize: number;
|
||||||
|
/**分片数量 */
|
||||||
|
chunkCount: number;
|
||||||
|
/**上传文件的路径 */
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartETag {
|
||||||
|
partNumber: number;
|
||||||
|
ETag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ChunkTask {
|
||||||
|
index: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传进度信息接口
|
||||||
|
*/
|
||||||
|
export interface ProgressInfo {
|
||||||
|
/** 已完成的分片数量 */
|
||||||
|
completedChunks: number;
|
||||||
|
/** 当前显示的上传进度(整数,如 0, 10, 20...) */
|
||||||
|
uploadProgress: number;
|
||||||
|
/** 总分片数量 */
|
||||||
|
chunkCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
721
src/utils/ChunkUpload.ts
Normal file
721
src/utils/ChunkUpload.ts
Normal file
@ -0,0 +1,721 @@
|
|||||||
|
import modal from '@/plugins/modal'
|
||||||
|
import { initChunkUpload, uploadChunk, completeChunkUpload } from '@/api/system/chunkUpload'
|
||||||
|
import { UploadOptions, PartETag, File, UploadData, ProgressInfo } from '@/types/upload'
|
||||||
|
import TaskQueue from '@/utils/TaskQueue'
|
||||||
|
|
||||||
|
// 声明微信小程序全局对象
|
||||||
|
declare const wx: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分片上传工具类
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class ChunkUpload {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分片大小,单位字节
|
||||||
|
*/
|
||||||
|
private chunkSize: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 并发上传的分片数量限制
|
||||||
|
*/
|
||||||
|
private concurrentLimit: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进度更新间隔(百分比)
|
||||||
|
*/
|
||||||
|
private static readonly PROGRESS_UPDATE_INTERVAL = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数 - 初始化分片上传器
|
||||||
|
* 设置默认分片大小为15MB,并发限制为2个分片
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.chunkSize = 15 * 1024 * 1024; // 默认分片大小15MB
|
||||||
|
this.concurrentLimit = 2; // 并发上传的分片数量
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主要的分片上传方法
|
||||||
|
* @param params 上传参数对象
|
||||||
|
*
|
||||||
|
* @param params.file 要上传的文件对象,包含path和size属性
|
||||||
|
* @param params.onSuccess 上传成功回调函数,接收 { success: true } 参数
|
||||||
|
* @param params.onError 上传失败回调函数,接收错误信息字符串参数
|
||||||
|
* @param params.options 上传配置选项 { chunkSize?: number; concurrentLimit?: number }
|
||||||
|
* @param params.options.chunkSize 分片大小(字节),默认15MB (15 * 1024 * 1024)
|
||||||
|
* @param params.options.concurrentLimit 并发上传分片数量限制,默认2个分片同时上传
|
||||||
|
*
|
||||||
|
* @returns Promise<boolean> 返回Promise,成功时resolve(true),失败时resolve(false)
|
||||||
|
*/
|
||||||
|
async upload(params: UploadOptions): Promise<boolean> {
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
options = {} as { chunkSize?: number; concurrentLimit?: number }
|
||||||
|
} = params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1.检验文件的参数
|
||||||
|
this._validateParams(file);
|
||||||
|
|
||||||
|
//2.获取文件信息
|
||||||
|
const { actualFilePath, actualFileSize, actualFileName } = this.getFileInfo(file);
|
||||||
|
|
||||||
|
modal.loading("准备上传...")
|
||||||
|
|
||||||
|
// 3.初始化分片数据
|
||||||
|
const chunkSize = options.chunkSize || this.chunkSize;
|
||||||
|
const chunkCount = Math.ceil(actualFileSize / chunkSize);
|
||||||
|
const concurrentLimit = options.concurrentLimit || this.concurrentLimit;
|
||||||
|
let partETags: PartETag[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
//4.初始化分片上传
|
||||||
|
const initResult = await initChunkUpload(actualFileName, actualFileSize)
|
||||||
|
if (initResult.code !== 200) throw new Error("初始化上传失败")
|
||||||
|
|
||||||
|
const { uploadId, filePath: serverFilePath } = initResult.data
|
||||||
|
|
||||||
|
|
||||||
|
//5.将文件移动到应用 沙盒 目录
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
const localFilePath = await this.copyFileToSandbox(actualFilePath)
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
|
||||||
|
//6.开始上传分片
|
||||||
|
modal.closeLoading()
|
||||||
|
modal.loading("上传中...")
|
||||||
|
|
||||||
|
const progressInfo: ProgressInfo = {
|
||||||
|
completedChunks: 0,
|
||||||
|
uploadProgress: 0,
|
||||||
|
chunkCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7.并发上传数据
|
||||||
|
const uploadData = {
|
||||||
|
uploadId,
|
||||||
|
saveFilePath: serverFilePath,
|
||||||
|
fileSize: actualFileSize,
|
||||||
|
chunkCount,
|
||||||
|
filePath: actualFilePath
|
||||||
|
};
|
||||||
|
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
partETags = await this.uploadChunksWithTaskQueue(
|
||||||
|
uploadData,
|
||||||
|
chunkSize,
|
||||||
|
concurrentLimit,
|
||||||
|
localFilePath,
|
||||||
|
progressInfo
|
||||||
|
)
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
partETags = await this._uploadChunks(uploadData, concurrentLimit, progressInfo);
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
//8.合并分片
|
||||||
|
modal.closeLoading();
|
||||||
|
modal.loading("正在合并分片...")
|
||||||
|
|
||||||
|
//完成分片上传
|
||||||
|
await completeChunkUpload(
|
||||||
|
uploadId, serverFilePath, actualFileSize, actualFileName, partETags
|
||||||
|
)
|
||||||
|
|
||||||
|
// 9.将临时文件删除,防止占用空间
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
await this.deleteLocalFile(localFilePath)
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
|
||||||
|
modal.closeLoading()
|
||||||
|
|
||||||
|
// 10.执行成功回调
|
||||||
|
onSuccess?.({ success: true })
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
modal.closeLoading()
|
||||||
|
const errorMessage = error instanceof Error ? error.message : `上传失败`
|
||||||
|
onError?.(errorMessage)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验上传参数
|
||||||
|
*
|
||||||
|
* @param file - 要上传的文件对象
|
||||||
|
* @throws {Error} 当文件路径不存在时抛出错误
|
||||||
|
* @throws {Error} 当文件大小不存在时抛出错误
|
||||||
|
*/
|
||||||
|
_validateParams(file: File) {
|
||||||
|
if (!file.path) throw new Error("文件路径不存在");
|
||||||
|
if (!file.size) throw new Error("文件大小不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件信息(路径、大小、文件名)
|
||||||
|
*
|
||||||
|
* @param file 文件对象,包含path和size属性
|
||||||
|
* @returns 包含文件信息的对象
|
||||||
|
* @returns actualFilePath 实际文件路径
|
||||||
|
* @returns actualFileSize 实际文件大小(字节)
|
||||||
|
* @returns actualFileName 实际文件名称(根据平台调整)
|
||||||
|
*/
|
||||||
|
getFileInfo(file: File): { actualFilePath: string; actualFileSize: number; actualFileName: string } {
|
||||||
|
const actualFilePath = file.path;
|
||||||
|
const actualFileSize = file.size;
|
||||||
|
|
||||||
|
let actualFileName: string;
|
||||||
|
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
actualFileName = this.getFileName(file.path);
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
actualFileName = `weixin_${Date.now()}.${this.getFileExtension(file.path)}`;
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
return {
|
||||||
|
actualFilePath,
|
||||||
|
actualFileSize,
|
||||||
|
actualFileName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件名称
|
||||||
|
* @param filePath 完整文件路径
|
||||||
|
* @returns string 从路径中提取的文件名
|
||||||
|
*/
|
||||||
|
getFileName(filePath: string): string {
|
||||||
|
if (!filePath) return ""
|
||||||
|
const slashIndex = filePath.lastIndexOf("/");
|
||||||
|
if (slashIndex === -1) return filePath;
|
||||||
|
return filePath.substring(slashIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件的扩展名称
|
||||||
|
* @param filePath 完整文件路径
|
||||||
|
* @returns string 文件扩展名(小写,不包含点号)
|
||||||
|
*/
|
||||||
|
getFileExtension(filePath: string): string {
|
||||||
|
if (!filePath) return ""
|
||||||
|
const dotIndex = filePath.lastIndexOf(".");
|
||||||
|
if (dotIndex === -1) return ""
|
||||||
|
return filePath.substring(dotIndex + 1).toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件复制到应用沙盒目录
|
||||||
|
* @param srcFilePath 源文件路径
|
||||||
|
* @returns Promise<string> 复制后的文件完整路径
|
||||||
|
*/
|
||||||
|
copyFileToSandbox(srcFilePath: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const newName = `file_${Date.now()}.${this.getFileExtension(srcFilePath)}`;
|
||||||
|
plus.io.requestFileSystem(
|
||||||
|
plus.io.PRIVATE_DOC,
|
||||||
|
(dstEntry) => {
|
||||||
|
plus.io.resolveLocalFileSystemURL(
|
||||||
|
srcFilePath,
|
||||||
|
(srcEntry) => {
|
||||||
|
srcEntry.copyTo(
|
||||||
|
dstEntry.root,
|
||||||
|
newName,
|
||||||
|
(entry) => {
|
||||||
|
if (entry.fullPath) {
|
||||||
|
resolve(entry.fullPath);
|
||||||
|
} else {
|
||||||
|
reject(new Error('File path is undefined'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(e) => reject(e)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(e) => reject(e)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(e) => reject(e)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取切片end位置
|
||||||
|
* @param start 切片开始位置
|
||||||
|
* @param chunkSize 切片大小
|
||||||
|
* @param fileSize 文件总大小
|
||||||
|
* @param index 当前切片索引
|
||||||
|
* @param totalChunks 总切片数量
|
||||||
|
* @returns number 切片结束位置
|
||||||
|
*/
|
||||||
|
getSliceEnd(start: number, chunkSize: number, fileSize: number, index: number, totalChunks: number) {
|
||||||
|
return index < totalChunks - 1 ? start + chunkSize - 1 : fileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用TaskQueue并发上传分片(APP端)
|
||||||
|
*
|
||||||
|
* @param uploadData 上传数据对象,包含 uploadId、saveFilePath、fileSize 等
|
||||||
|
* @param chunkSize 单个分片的大小(字节)
|
||||||
|
* @param concurrentLimit 并发上传的分片数量限制
|
||||||
|
* @param localFilePath APP端沙盒中的本地文件路径
|
||||||
|
* @param progressInfo 进度跟踪对象,包含completedChunks、uploadProgress、chunkCount等属性
|
||||||
|
*
|
||||||
|
* @returns Promise<PartETag[]> 返回所有分片的ETag信息数组
|
||||||
|
*
|
||||||
|
* @throws {Error} 当任何分片上传失败时抛出错误
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async uploadChunksWithTaskQueue(
|
||||||
|
uploadData: UploadData,
|
||||||
|
chunkSize: number,
|
||||||
|
concurrentLimit: number,
|
||||||
|
localFilePath: string,
|
||||||
|
progressInfo: ProgressInfo
|
||||||
|
): Promise<PartETag[]> {
|
||||||
|
const { chunkCount, fileSize, uploadId, saveFilePath } = uploadData;
|
||||||
|
const taskQueue = new TaskQueue(concurrentLimit);
|
||||||
|
const partETags: PartETag[] = [];
|
||||||
|
|
||||||
|
// 创建所有分片上传任务
|
||||||
|
const uploadPromises: Promise<PartETag>[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < chunkCount; i++) {
|
||||||
|
const task = {
|
||||||
|
index: i + 1,
|
||||||
|
start: i * chunkSize,
|
||||||
|
end: this.getSliceEnd(i * chunkSize, chunkSize, fileSize, i, chunkCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = taskQueue.add(async () => {
|
||||||
|
const chunk = await this.readAppFileChunk(localFilePath, task.start, task.end - task.start);
|
||||||
|
const response = await this.uploadAppChunk(uploadId, saveFilePath, task.index, chunk) as any;
|
||||||
|
|
||||||
|
if (!response.data || !response.data.etag) throw new Error('分片上传失败');
|
||||||
|
|
||||||
|
// 更新进度
|
||||||
|
this.updateUploadProgress(progressInfo);
|
||||||
|
|
||||||
|
return {
|
||||||
|
partNumber: task.index,
|
||||||
|
ETag: response.data.etag,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadPromises.push(promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有任务完成
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(uploadPromises);
|
||||||
|
// 收集所有 partETags
|
||||||
|
results.forEach(partETag => {
|
||||||
|
if (partETag) partETags.push(partETag);
|
||||||
|
});
|
||||||
|
// 按 partNumber 排序确保顺序正确
|
||||||
|
partETags.sort((a, b) => a.partNumber - b.partNumber);
|
||||||
|
return partETags;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '分片上传失败';
|
||||||
|
throw new Error(`分片上传失败: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP端分片上传单个分片
|
||||||
|
* @param uploadId 上传ID
|
||||||
|
* @param filePath 服务器文件路径
|
||||||
|
* @param chunkIndex 分片索引
|
||||||
|
* @param chunk 分片数据,可以是ArrayBuffer或字符串
|
||||||
|
* @returns Promise<any> 上传响应结果
|
||||||
|
*/
|
||||||
|
async uploadAppChunk(uploadId: string, filePath: string, chunkIndex: number, chunk: ArrayBuffer | string) {
|
||||||
|
try {
|
||||||
|
const response = await this.startUploadAppChunk(uploadId, filePath, chunkIndex, chunk)
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('分片上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行APP端分片上传
|
||||||
|
* @param uploadId 上传ID
|
||||||
|
* @param filePath 服务器文件路径
|
||||||
|
* @param chunkIndex 分片索引
|
||||||
|
* @param chunk 分片数据,可以是ArrayBuffer或字符串
|
||||||
|
* @returns Promise 返回上传结果的Promise
|
||||||
|
*/
|
||||||
|
startUploadAppChunk(uploadId: string, filePath: string, chunkIndex: number, chunk: ArrayBuffer | string) {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// 1. 准备临时文件信息
|
||||||
|
const tempFileName = `temp_chunk/chunk_${uploadId}_${chunkIndex}.bin`
|
||||||
|
const tempDirPath = plus.io.PRIVATE_DOC
|
||||||
|
|
||||||
|
// 2. 创建并写入临时文件
|
||||||
|
const tempFilePath = await this.createAndWriteTempFile(
|
||||||
|
tempDirPath,
|
||||||
|
tempFileName,
|
||||||
|
chunk
|
||||||
|
)
|
||||||
|
|
||||||
|
//设置文件的全路径
|
||||||
|
let formattedPath = tempFilePath
|
||||||
|
if (tempFilePath && !tempFilePath.startsWith("file://")) {
|
||||||
|
formattedPath = `file://${tempFilePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 上传文件
|
||||||
|
const result = await uploadChunk(uploadId, filePath, chunkIndex, formattedPath)
|
||||||
|
|
||||||
|
// 4. 删除临时文件
|
||||||
|
await this.deleteTempFile(tempDirPath, tempFileName)
|
||||||
|
|
||||||
|
resolve(result)
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除本地临时文件(临时文件是分片生成的)
|
||||||
|
* @param filePath 要删除的文件路径
|
||||||
|
* @returns Promise<boolean> 删除是否成功
|
||||||
|
*/
|
||||||
|
deleteLocalFile(filePath: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!filePath) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
plus.io.resolveLocalFileSystemURL(
|
||||||
|
filePath,
|
||||||
|
(entry) => {
|
||||||
|
entry.remove(
|
||||||
|
() => { resolve(true); },
|
||||||
|
() => { resolve(false); }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
() => { resolve(false); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建临时文件并写入数据
|
||||||
|
* @param dirPath 目录路径标识(plus.io.PRIVATE_DOC等)
|
||||||
|
* @param fileName 临时文件名
|
||||||
|
* @param data 要写入的数据,可以是ArrayBuffer或字符串
|
||||||
|
* @returns Promise<string> 创建的临时文件完整路径
|
||||||
|
*/
|
||||||
|
createAndWriteTempFile(dirPath: number, fileName: String, data: ArrayBuffer | string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
plus.io.requestFileSystem(
|
||||||
|
dirPath,
|
||||||
|
(dirEntry: any) => {
|
||||||
|
dirEntry.root.getFile(
|
||||||
|
fileName,
|
||||||
|
{ create: true, exclusive: false },
|
||||||
|
(fileEntry: any) => {
|
||||||
|
fileEntry.createWriter(
|
||||||
|
(writer: any) => {
|
||||||
|
const filePath = fileEntry.fullPath
|
||||||
|
writer.onwrite = function () { resolve(filePath) }
|
||||||
|
writer.onerror = function (e: any) { reject(e) }
|
||||||
|
try {
|
||||||
|
if (data) writer.writeAsBinary(data)
|
||||||
|
} catch (e) { reject(e) }
|
||||||
|
},
|
||||||
|
(err: any) => reject(err)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(err: any) => reject(err)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(err) => { reject(err) }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除临时文件
|
||||||
|
* @param dirPath 目录路径标识(plus.io.PRIVATE_DOC等)
|
||||||
|
* @param fileName 要删除的临时文件名
|
||||||
|
* @returns Promise<boolean> 删除是否成功
|
||||||
|
*/
|
||||||
|
deleteTempFile(dirPath: number, fileName: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
plus.io.requestFileSystem(
|
||||||
|
dirPath,
|
||||||
|
(dirEntry) => {
|
||||||
|
if (!dirEntry || !dirEntry.root) {
|
||||||
|
reject(new Error('Directory entry or root is undefined'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dirEntry.root.getFile(
|
||||||
|
fileName,
|
||||||
|
{ create: false },
|
||||||
|
(fileEntry) => {
|
||||||
|
fileEntry.remove(
|
||||||
|
() => { resolve(true); },
|
||||||
|
() => { resolve(true); }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
() => resolve(true)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
() => resolve(true)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取APP端文件分片的数据
|
||||||
|
* @param filePath 本地文件路径
|
||||||
|
* @param start 读取开始位置
|
||||||
|
* @param length 读取数据长度
|
||||||
|
* @returns Promise<string> Base64编码的分片数据
|
||||||
|
*/
|
||||||
|
readAppFileChunk(filePath: string, start: number, length: number): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
plus.io.resolveLocalFileSystemURL(
|
||||||
|
filePath,
|
||||||
|
(entry: any) => {
|
||||||
|
entry.file(
|
||||||
|
(file: any) => {
|
||||||
|
const reader = new plus.io.FileReader();
|
||||||
|
try {
|
||||||
|
const slice = file.slice(start, start + length);
|
||||||
|
reader.readAsDataURL(slice);
|
||||||
|
} catch (sliceError) {
|
||||||
|
reject(sliceError);
|
||||||
|
}
|
||||||
|
reader.onloadend = (e: any) => {
|
||||||
|
if (e.target.readyState == 2) {
|
||||||
|
try {
|
||||||
|
const base64 = e.target.result.split(",")[1];
|
||||||
|
resolve(base64);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = (err) => { reject(err); };
|
||||||
|
},
|
||||||
|
(error: any) => { reject(error); }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(error) => { reject(error); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用TaskQueue并发上传分片(微信小程序端)
|
||||||
|
*
|
||||||
|
* @param uploadData 上传数据对象
|
||||||
|
* @param concurrentLimit 并发上传的分片数量限制,控制同时进行的上传任务数
|
||||||
|
* @param progressInfo 进度跟踪对象
|
||||||
|
*
|
||||||
|
* @returns Promise<PartETag[]> 返回所有分片的ETag信息数组,按partNumber排序
|
||||||
|
*
|
||||||
|
* @throws {Error} 当任何分片上传失败时抛出错误,包含具体的错误信息
|
||||||
|
*/
|
||||||
|
async _uploadChunks(uploadData: UploadData, concurrentLimit: number, progressInfo: ProgressInfo) {
|
||||||
|
try {
|
||||||
|
const { uploadId, saveFilePath, fileSize, chunkCount, filePath } = uploadData;
|
||||||
|
const fileManager = uni.getFileSystemManager();
|
||||||
|
const partETags: PartETag[] = [];
|
||||||
|
|
||||||
|
const taskQueue = new TaskQueue(concurrentLimit);
|
||||||
|
|
||||||
|
// 创建所有分片上传任务
|
||||||
|
const uploadTasks = [];
|
||||||
|
for (let i = 0; i < chunkCount; i++) {
|
||||||
|
const task = taskQueue.add(async () => {
|
||||||
|
return await this._uploadSingleChunk(
|
||||||
|
fileManager,
|
||||||
|
uploadId,
|
||||||
|
saveFilePath,
|
||||||
|
filePath,
|
||||||
|
i,
|
||||||
|
fileSize,
|
||||||
|
progressInfo
|
||||||
|
);
|
||||||
|
});
|
||||||
|
uploadTasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有任务完成
|
||||||
|
const results = await Promise.all(uploadTasks);
|
||||||
|
|
||||||
|
// 收集所有 partETags
|
||||||
|
results.forEach(partETag => {
|
||||||
|
if (partETag) partETags.push(partETag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按 partNumber 排序确保顺序正确
|
||||||
|
partETags.sort((a, b) => a.partNumber - b.partNumber);
|
||||||
|
|
||||||
|
return partETags;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : '上传分片失败';
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传单个分片(微信小程序端)
|
||||||
|
* @param fileManager 文件管理器
|
||||||
|
* @param uploadId 上传ID
|
||||||
|
* @param saveFilePath 服务器保存路径
|
||||||
|
* @param filePath 本地文件路径
|
||||||
|
* @param chunkIndex 分片索引(从0开始)
|
||||||
|
* @param fileSize 文件总大小
|
||||||
|
* @param progressInfo 进度信息对象
|
||||||
|
* @returns Promise<PartETag> 返回分片的ETag信息
|
||||||
|
*/
|
||||||
|
private async _uploadSingleChunk(
|
||||||
|
fileManager: UniApp.FileSystemManager,
|
||||||
|
uploadId: string,
|
||||||
|
saveFilePath: string,
|
||||||
|
filePath: string,
|
||||||
|
chunkIndex: number,
|
||||||
|
fileSize: number,
|
||||||
|
progressInfo: ProgressInfo
|
||||||
|
): Promise<PartETag> {
|
||||||
|
const start = chunkIndex * this.chunkSize;
|
||||||
|
const end = Math.min(start + this.chunkSize, fileSize);
|
||||||
|
const tempChunkPath = `${wx.env.USER_DATA_PATH}/chunk_${chunkIndex}_${Date.now()}.tmp`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 处理分片数据
|
||||||
|
await this._processChunk(fileManager, filePath, tempChunkPath, start, end - start);
|
||||||
|
|
||||||
|
// 2. 上传分片
|
||||||
|
const partNumber = chunkIndex + 1;
|
||||||
|
const response = await uploadChunk(uploadId, saveFilePath, partNumber, tempChunkPath);
|
||||||
|
|
||||||
|
if (!response.data?.etag) {
|
||||||
|
throw new Error(`分片 ${partNumber} 上传失败,无效响应`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新进度
|
||||||
|
this.updateUploadProgress(progressInfo);
|
||||||
|
|
||||||
|
return {
|
||||||
|
partNumber,
|
||||||
|
ETag: response.data.etag,
|
||||||
|
};
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
// 4. 清理临时文件(无论成功失败都要清理)
|
||||||
|
this._cleanupTempFile(fileManager, tempChunkPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理单个分片数据
|
||||||
|
*
|
||||||
|
* @param fileManager - uni-app文件系统管理器实例
|
||||||
|
* @param filePath - 原始文件的完整路径
|
||||||
|
* @param tempChunkPath - 临时分片文件的保存路径
|
||||||
|
* @param start - 在原始文件中的起始位置
|
||||||
|
* @param length - 要读取的数据长度
|
||||||
|
* @returns Promise<void> - 操作完成的Promise
|
||||||
|
* @throws {Error} 当文件读取或写入失败时抛出错误
|
||||||
|
*/
|
||||||
|
async _processChunk(fileManager: UniApp.FileSystemManager, filePath: string, tempChunkPath: string, start: number, length: number) {
|
||||||
|
const readRes = await new Promise<ArrayBuffer | string>((resolve, reject) => {
|
||||||
|
fileManager.readFile({
|
||||||
|
filePath: filePath,
|
||||||
|
position: start,
|
||||||
|
length: length,
|
||||||
|
success: (res: any) => {
|
||||||
|
resolve(res.data as ArrayBuffer | string)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
reject(err)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// 写入临时文件
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
fileManager.writeFile({
|
||||||
|
filePath: tempChunkPath,
|
||||||
|
data: readRes,
|
||||||
|
success: () => {
|
||||||
|
resolve(true)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
reject(err)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理临时分片文件
|
||||||
|
*
|
||||||
|
* @param fileManager - uni-app文件系统管理器实例
|
||||||
|
* @param tempChunkPath - 要删除的临时文件路径
|
||||||
|
* @throws {Error} 当文件删除失败时抛出错误
|
||||||
|
*/
|
||||||
|
_cleanupTempFile(fileManager: UniApp.FileSystemManager, tempChunkPath: string) {
|
||||||
|
try {
|
||||||
|
fileManager.unlinkSync(tempChunkPath);
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : '未知错误';
|
||||||
|
throw new Error(`删除临时文件失败: ${tempChunkPath}, 错误: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新上传进度并显示加载状态
|
||||||
|
*
|
||||||
|
* @param progressInfo 进度信息对象,包含completedChunks、uploadProgress、chunkCount等属性
|
||||||
|
*/
|
||||||
|
private updateUploadProgress(progressInfo: ProgressInfo): void {
|
||||||
|
// 增加已完成分片数
|
||||||
|
progressInfo.completedChunks++;
|
||||||
|
|
||||||
|
// 计算当前进度百分比
|
||||||
|
const percent = Math.floor((progressInfo.completedChunks / progressInfo.chunkCount) * 100);
|
||||||
|
|
||||||
|
// 计算显示进度(按间隔更新,避免过于频繁的UI更新)
|
||||||
|
const displayPercent = Math.floor(percent / ChunkUpload.PROGRESS_UPDATE_INTERVAL) * ChunkUpload.PROGRESS_UPDATE_INTERVAL;
|
||||||
|
|
||||||
|
// 当显示进度发生变化或上传完成时,更新UI
|
||||||
|
if (displayPercent !== progressInfo.uploadProgress || progressInfo.completedChunks === progressInfo.chunkCount) {
|
||||||
|
modal.closeLoading();
|
||||||
|
const displayPercentForUI = progressInfo.completedChunks === progressInfo.chunkCount ? 100 : displayPercent;
|
||||||
|
modal.loading(`上传中 ${displayPercentForUI}% (请勿离开此页面)`);
|
||||||
|
progressInfo.uploadProgress = displayPercent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chunkUpload = new ChunkUpload();
|
||||||
246
src/utils/TaskQueue.ts
Normal file
246
src/utils/TaskQueue.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import { nextTick } from "vue";
|
||||||
|
|
||||||
|
export type TaskFn<T = unknown> = (signal?: AbortSignal) => Promise<T>;
|
||||||
|
export type ErrorMode = 'continue' | 'abort';
|
||||||
|
|
||||||
|
export interface TaskQueueOptions {
|
||||||
|
concurrency?: number; // 并发数,默认 4
|
||||||
|
errorMode?: ErrorMode; // 出错策略:继续 or 中止
|
||||||
|
autoStart?: boolean; // add 时是否自动启动,默认 true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddTaskOptions {
|
||||||
|
priority?: number; // 优先级,数值越大越先执行,默认 0
|
||||||
|
signal?: AbortSignal; // 任务级取消信号
|
||||||
|
timeout?: number; // 单任务超时(ms)
|
||||||
|
id?: string; // 任务标识,便于调试
|
||||||
|
}
|
||||||
|
|
||||||
|
class AbortError extends Error {
|
||||||
|
name = 'AbortError';
|
||||||
|
constructor(message = 'Aborted') {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueueItem<T = unknown> {
|
||||||
|
id?: string;
|
||||||
|
priority: number;
|
||||||
|
fn: TaskFn<T>;
|
||||||
|
resolve: (v: T) => void;
|
||||||
|
reject: (e: unknown) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeout?: number;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaskQueue {
|
||||||
|
/** 最大并发数 */
|
||||||
|
private concurrency!: number; // 在构造函数中设置
|
||||||
|
|
||||||
|
/** 当前正在执行的任务数 */
|
||||||
|
private runningCount = 0;
|
||||||
|
|
||||||
|
/** 待执行任务队列(使用 unknown 以避免泛型入队时的类型不兼容) */
|
||||||
|
private taskQueue: QueueItem<unknown>[] = [];
|
||||||
|
|
||||||
|
/** 出错策略 */
|
||||||
|
private errorMode: ErrorMode = 'abort';
|
||||||
|
|
||||||
|
/** 自动启动 */
|
||||||
|
private autoStart: boolean = true;
|
||||||
|
|
||||||
|
/** 是否暂停调度 */
|
||||||
|
private paused = false;
|
||||||
|
|
||||||
|
/** 是否已中止(如因错误或手动 abort) */
|
||||||
|
private aborted = false;
|
||||||
|
private abortReason?: string;
|
||||||
|
|
||||||
|
/** 错误收集(errorMode=continue 时有用) */
|
||||||
|
private errors: unknown[] = [];
|
||||||
|
|
||||||
|
/** empty/idle 等待者 */
|
||||||
|
private emptyWaiters: Array<() => void> = [];
|
||||||
|
private idleWaiters: Array<() => void> = [];
|
||||||
|
|
||||||
|
constructor(options: number | TaskQueueOptions = 4) {
|
||||||
|
if (typeof options === 'number') {
|
||||||
|
this.concurrency = Math.max(1, options);
|
||||||
|
this.errorMode = 'abort';
|
||||||
|
this.autoStart = true;
|
||||||
|
} else {
|
||||||
|
this.concurrency = Math.max(1, options.concurrency ?? 4);
|
||||||
|
this.errorMode = options.errorMode ?? 'abort';
|
||||||
|
this.autoStart = options.autoStart ?? true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态只读属性
|
||||||
|
get size() { return this.taskQueue.length; }
|
||||||
|
get pending() { return this.runningCount; }
|
||||||
|
get isPaused() { return this.paused; }
|
||||||
|
get isAborted() { return this.aborted; }
|
||||||
|
get collectedErrors() { return this.errors.slice(); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加任务(必须是函数),返回该任务自身的 Promise。
|
||||||
|
*/
|
||||||
|
public add<T>(fn: TaskFn<T>, opts: AddTaskOptions = {}): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
if (this.aborted) {
|
||||||
|
reject(new AbortError(this.abortReason || 'Queue aborted'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item: QueueItem<T> = {
|
||||||
|
id: opts.id,
|
||||||
|
priority: opts.priority ?? 0,
|
||||||
|
fn,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
signal: opts.signal,
|
||||||
|
timeout: opts.timeout,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.enqueue(item as unknown as QueueItem<unknown>);
|
||||||
|
if (this.autoStart && !this.paused) this.runNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量添加 */
|
||||||
|
public addAll<T>(fns: Array<TaskFn<T>>, opts?: AddTaskOptions): Promise<T>[] {
|
||||||
|
return fns.map((fn) => this.add(fn, opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等待队列空(无排队任务) */
|
||||||
|
public onEmpty(): Promise<void> {
|
||||||
|
if (this.size === 0) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => this.emptyWaiters.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等待完全空闲(无排队、无运行中) => 等待所有任务完成 */
|
||||||
|
public waitAll(): Promise<void> {
|
||||||
|
if (this.size === 0 && this.runningCount === 0) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => this.idleWaiters.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 暂停调度 */
|
||||||
|
public pause() { this.paused = true; }
|
||||||
|
|
||||||
|
/** 恢复调度 */
|
||||||
|
public resume() {
|
||||||
|
if (!this.paused) return;
|
||||||
|
this.paused = false;
|
||||||
|
this.runNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动中止:清空剩余队列并拒绝它们 */
|
||||||
|
public abort(reason = 'Aborted by user') {
|
||||||
|
if (this.aborted) return;
|
||||||
|
this.aborted = true;
|
||||||
|
this.abortReason = reason;
|
||||||
|
this.clear(new AbortError(reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空待执行任务(不影响已在运行中的任务) */
|
||||||
|
public clear(err: unknown = new AbortError('Cleared')) {
|
||||||
|
const pending = this.taskQueue.splice(0, this.taskQueue.length);
|
||||||
|
for (const item of pending) item.reject(err);
|
||||||
|
this.notifyEmptyIfNeeded();
|
||||||
|
this.notifyIdleIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 动态调整并发度 */
|
||||||
|
public setConcurrency(n: number) {
|
||||||
|
this.concurrency = Math.max(1, n | 0);
|
||||||
|
this.runNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改错误策略 */
|
||||||
|
public setErrorMode(mode: ErrorMode) { this.errorMode = mode; }
|
||||||
|
|
||||||
|
/** 入队(按优先级降序,稳定插入) */
|
||||||
|
private enqueue(item: QueueItem<unknown>) {
|
||||||
|
const idx = this.taskQueue.findIndex((q) => q.priority < item.priority);
|
||||||
|
if (idx === -1) this.taskQueue.push(item);
|
||||||
|
else this.taskQueue.splice(idx, 0, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调度下一批任务(在下一帧启动) */
|
||||||
|
private runNext() {
|
||||||
|
if (this.paused || this.aborted) return;
|
||||||
|
|
||||||
|
while (this.runningCount < this.concurrency && this.taskQueue.length > 0) {
|
||||||
|
const item = this.taskQueue.shift()!;
|
||||||
|
if (this.taskQueue.length === 0) this.notifyEmptyIfNeeded();
|
||||||
|
|
||||||
|
this.runningCount++;
|
||||||
|
nextTick(() => {
|
||||||
|
this.execute(item)
|
||||||
|
.catch(() => { /* 错误在 execute 中处理 */ })
|
||||||
|
.finally(() => {
|
||||||
|
this.runningCount--;
|
||||||
|
if (!this.aborted) this.runNext();
|
||||||
|
this.notifyIdleIfNeeded();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实际执行(处理 signal、timeout、错误策略) */
|
||||||
|
private async execute(item: QueueItem<unknown>): Promise<void> {
|
||||||
|
if (item.signal?.aborted) {
|
||||||
|
item.reject(new AbortError('Task aborted before start'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: number | undefined;
|
||||||
|
const onAbort = () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
item.reject(new AbortError('Task aborted'));
|
||||||
|
};
|
||||||
|
if (item.signal) item.signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
|
||||||
|
if (item.timeout && item.timeout > 0) {
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
item.reject(new Error(`Task timeout after ${item.timeout}ms`));
|
||||||
|
}, item.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await item.fn(item.signal);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
(item.resolve as (v: unknown) => void)(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
this.errors.push(err);
|
||||||
|
item.reject(err);
|
||||||
|
|
||||||
|
if (this.errorMode === 'abort' && !this.aborted) {
|
||||||
|
this.aborted = true;
|
||||||
|
this.abortReason = 'Aborted due to previous error';
|
||||||
|
this.clear(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (item.signal) item.signal.removeEventListener('abort', onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyEmptyIfNeeded() {
|
||||||
|
if (this.taskQueue.length === 0 && this.emptyWaiters.length) {
|
||||||
|
const callbacks = this.emptyWaiters.splice(0, this.emptyWaiters.length);
|
||||||
|
for (const cb of callbacks) cb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyIdleIfNeeded() {
|
||||||
|
if (this.taskQueue.length === 0 && this.runningCount === 0 && this.idleWaiters.length) {
|
||||||
|
const callbacks = this.idleWaiters.splice(0, this.idleWaiters.length);
|
||||||
|
for (const cb of callbacks) cb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskQueue;
|
||||||
Loading…
Reference in New Issue
Block a user