|
|
@@ -0,0 +1,359 @@
|
|
|
+<template>
|
|
|
+ <div class="chunk-upload">
|
|
|
+ <el-upload
|
|
|
+ ref="uploadRef"
|
|
|
+ :auto-upload="false"
|
|
|
+ :on-change="handleFileChange"
|
|
|
+ :show-file-list="false"
|
|
|
+ drag
|
|
|
+ >
|
|
|
+ <el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
|
|
+ <div class="el-upload__text">
|
|
|
+ 将文件拖到此处,或<em>点击上传</em>
|
|
|
+ </div>
|
|
|
+ <template #tip>
|
|
|
+ <div class="el-upload__tip">
|
|
|
+ 支持大文件分片上传,建议单个文件不超过5GB
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-upload>
|
|
|
+
|
|
|
+ <!-- 上传进度 -->
|
|
|
+ <div v-if="uploading" class="upload-progress">
|
|
|
+ <el-progress
|
|
|
+ :percentage="uploadProgress"
|
|
|
+ :status="uploadStatus"
|
|
|
+ :stroke-width="8"
|
|
|
+ />
|
|
|
+ <div class="upload-info">
|
|
|
+ <span>文件名: {{ currentFile?.name }}</span>
|
|
|
+ <span>大小: {{ formatFileSize(currentFile?.size) }}</span>
|
|
|
+ <span>进度: {{ uploadedChunks }}/{{ totalChunks }} 分片</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 上传结果 -->
|
|
|
+ <div v-if="uploadResult" class="upload-result">
|
|
|
+ <el-alert
|
|
|
+ :title="uploadResult.success ? '上传成功' : '上传失败'"
|
|
|
+ :type="uploadResult.success ? 'success' : 'error'"
|
|
|
+ :description="uploadResult.message"
|
|
|
+ show-icon
|
|
|
+ :closable="false"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, computed } from 'vue';
|
|
|
+import { ElMessage } from 'element-plus';
|
|
|
+import { UploadFilled } from '@element-plus/icons-vue';
|
|
|
+import {
|
|
|
+ initMultipartUpload,
|
|
|
+ uploadPart,
|
|
|
+ completeMultipartUpload,
|
|
|
+ abortMultipartUpload,
|
|
|
+ checkUploadedParts
|
|
|
+} from '@/api/system/oss/multipart';
|
|
|
+
|
|
|
+// 分片大小:8MB
|
|
|
+const CHUNK_SIZE = 8 * 1024 * 1024;
|
|
|
+// 并发数:3
|
|
|
+const CONCURRENT_LIMIT = 3;
|
|
|
+
|
|
|
+// 响应式数据
|
|
|
+const uploadRef = ref();
|
|
|
+const uploading = ref(false);
|
|
|
+const uploadProgress = ref(0);
|
|
|
+const uploadStatus = ref<'success' | 'exception' | 'warning' | ''>('');
|
|
|
+const uploadedChunks = ref(0);
|
|
|
+const totalChunks = ref(0);
|
|
|
+const currentFile = ref<File | null>(null);
|
|
|
+const uploadResult = ref<{ success: boolean; message: string } | null>(null);
|
|
|
+
|
|
|
+// 上传状态
|
|
|
+const uploadId = ref('');
|
|
|
+const partETags = ref<string[]>([]);
|
|
|
+const uploadedParts = ref<Set<number>>(new Set());
|
|
|
+
|
|
|
+// 计算属性
|
|
|
+const isUploading = computed(() => uploading.value);
|
|
|
+
|
|
|
+// 处理文件选择
|
|
|
+const handleFileChange = (file: any) => {
|
|
|
+ if (file.raw) {
|
|
|
+ currentFile.value = file.raw;
|
|
|
+ startUpload(file.raw);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 开始上传
|
|
|
+const startUpload = async (file: File) => {
|
|
|
+ try {
|
|
|
+ uploading.value = true;
|
|
|
+ uploadProgress.value = 0;
|
|
|
+ uploadStatus.value = '';
|
|
|
+ uploadResult.value = null;
|
|
|
+ uploadedChunks.value = 0;
|
|
|
+ partETags.value = [];
|
|
|
+ uploadedParts.value.clear();
|
|
|
+
|
|
|
+ // 计算分片数量
|
|
|
+ totalChunks.value = Math.ceil(file.size / CHUNK_SIZE);
|
|
|
+
|
|
|
+ // 初始化分片上传
|
|
|
+ const initResponse = await initMultipartUpload(file.name, file.size);
|
|
|
+ if (!initResponse.success) {
|
|
|
+ throw new Error(initResponse.msg || '初始化分片上传失败');
|
|
|
+ }
|
|
|
+ uploadId.value = initResponse.data;
|
|
|
+
|
|
|
+ // 检查已上传的分片(断点续传)
|
|
|
+ await checkExistingParts();
|
|
|
+
|
|
|
+ // 开始上传分片
|
|
|
+ await uploadChunks(file);
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('上传失败:', error);
|
|
|
+ uploadStatus.value = 'exception';
|
|
|
+ uploadResult.value = {
|
|
|
+ success: false,
|
|
|
+ message: error instanceof Error ? error.message : '上传失败'
|
|
|
+ };
|
|
|
+ } finally {
|
|
|
+ uploading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 检查已上传的分片
|
|
|
+const checkExistingParts = async () => {
|
|
|
+ try {
|
|
|
+ const response = await checkUploadedParts(uploadId.value);
|
|
|
+ if (response.success && response.data) {
|
|
|
+ uploadedParts.value = new Set(response.data);
|
|
|
+ uploadedChunks.value = uploadedParts.value.size;
|
|
|
+ console.log('已上传分片:', Array.from(uploadedParts.value));
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.warn('检查已上传分片失败:', error);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 上传分片
|
|
|
+const uploadChunks = async (file: File) => {
|
|
|
+ const chunks: { partNumber: number; data: Blob }[] = [];
|
|
|
+
|
|
|
+ // 准备所有分片
|
|
|
+ for (let i = 0; i < totalChunks.value; i++) {
|
|
|
+ const start = i * CHUNK_SIZE;
|
|
|
+ const end = Math.min(start + CHUNK_SIZE, file.size);
|
|
|
+ const chunk = file.slice(start, end);
|
|
|
+ chunks.push({
|
|
|
+ partNumber: i + 1,
|
|
|
+ data: chunk
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 过滤掉已上传的分片
|
|
|
+ const remainingChunks = chunks.filter(chunk => !uploadedParts.value.has(chunk.partNumber));
|
|
|
+
|
|
|
+ if (remainingChunks.length === 0) {
|
|
|
+ // 所有分片都已上传,直接完成
|
|
|
+ await completeUpload();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 并发上传分片
|
|
|
+ await uploadChunksConcurrently(remainingChunks);
|
|
|
+
|
|
|
+ // 完成上传
|
|
|
+ await completeUpload();
|
|
|
+};
|
|
|
+
|
|
|
+// 并发上传分片
|
|
|
+const uploadChunksConcurrently = async (chunks: { partNumber: number; data: Blob }[]) => {
|
|
|
+ const semaphore = new Semaphore(CONCURRENT_LIMIT);
|
|
|
+ const uploadPromises: Promise<void>[] = [];
|
|
|
+
|
|
|
+ for (const chunk of chunks) {
|
|
|
+ const promise = semaphore.acquire().then(async (release) => {
|
|
|
+ try {
|
|
|
+ await uploadSingleChunk(chunk.partNumber, chunk.data);
|
|
|
+ } finally {
|
|
|
+ release();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ uploadPromises.push(promise);
|
|
|
+ }
|
|
|
+
|
|
|
+ await Promise.all(uploadPromises);
|
|
|
+};
|
|
|
+
|
|
|
+// 上传单个分片
|
|
|
+const uploadSingleChunk = async (partNumber: number, chunkData: Blob) => {
|
|
|
+ const maxRetries = 3;
|
|
|
+ let retryCount = 0;
|
|
|
+
|
|
|
+ while (retryCount < maxRetries) {
|
|
|
+ try {
|
|
|
+ const formData = new FormData();
|
|
|
+ formData.append('file', chunkData);
|
|
|
+
|
|
|
+ const response = await uploadPart(uploadId.value, partNumber, formData);
|
|
|
+
|
|
|
+ if (response.success) {
|
|
|
+ partETags.value[partNumber - 1] = response.data;
|
|
|
+ uploadedChunks.value++;
|
|
|
+ uploadProgress.value = Math.round((uploadedChunks.value / totalChunks.value) * 100);
|
|
|
+ console.log(`分片 ${partNumber} 上传成功`);
|
|
|
+ return;
|
|
|
+ } else {
|
|
|
+ throw new Error(response.msg || '上传分片失败');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ retryCount++;
|
|
|
+ console.error(`分片 ${partNumber} 上传失败 (重试 ${retryCount}/${maxRetries}):`, error);
|
|
|
+
|
|
|
+ if (retryCount >= maxRetries) {
|
|
|
+ throw new Error(`分片 ${partNumber} 上传失败,已重试 ${maxRetries} 次`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 等待一段时间后重试
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 完成上传
|
|
|
+const completeUpload = async () => {
|
|
|
+ try {
|
|
|
+ const response = await completeMultipartUpload(
|
|
|
+ uploadId.value,
|
|
|
+ partETags.value.join(','),
|
|
|
+ currentFile.value?.name || '',
|
|
|
+ currentFile.value?.name || ''
|
|
|
+ );
|
|
|
+
|
|
|
+ if (response.success) {
|
|
|
+ uploadStatus.value = 'success';
|
|
|
+ uploadResult.value = {
|
|
|
+ success: true,
|
|
|
+ message: '文件上传成功'
|
|
|
+ };
|
|
|
+ ElMessage.success('文件上传成功');
|
|
|
+ } else {
|
|
|
+ throw new Error(response.msg || '完成上传失败');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('完成上传失败:', error);
|
|
|
+ uploadStatus.value = 'exception';
|
|
|
+ uploadResult.value = {
|
|
|
+ success: false,
|
|
|
+ message: error instanceof Error ? error.message : '完成上传失败'
|
|
|
+ };
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 取消上传
|
|
|
+const cancelUpload = async () => {
|
|
|
+ if (uploadId.value) {
|
|
|
+ try {
|
|
|
+ await abortMultipartUpload(uploadId.value);
|
|
|
+ console.log('已取消上传');
|
|
|
+ } catch (error) {
|
|
|
+ console.error('取消上传失败:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ uploading.value = false;
|
|
|
+ uploadProgress.value = 0;
|
|
|
+ uploadStatus.value = '';
|
|
|
+ uploadResult.value = null;
|
|
|
+ uploadedChunks.value = 0;
|
|
|
+ totalChunks.value = 0;
|
|
|
+ currentFile.value = null;
|
|
|
+ uploadId.value = '';
|
|
|
+ partETags.value = [];
|
|
|
+ uploadedParts.value.clear();
|
|
|
+};
|
|
|
+
|
|
|
+// 格式化文件大小
|
|
|
+const formatFileSize = (bytes: number | undefined) => {
|
|
|
+ if (!bytes) return '0 B';
|
|
|
+
|
|
|
+ const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
|
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
|
+ return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
|
|
+};
|
|
|
+
|
|
|
+// 信号量类,用于控制并发
|
|
|
+class Semaphore {
|
|
|
+ private permits: number;
|
|
|
+ private waiting: (() => void)[] = [];
|
|
|
+
|
|
|
+ constructor(permits: number) {
|
|
|
+ this.permits = permits;
|
|
|
+ }
|
|
|
+
|
|
|
+ async acquire(): Promise<() => void> {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ if (this.permits > 0) {
|
|
|
+ this.permits--;
|
|
|
+ resolve(() => this.release());
|
|
|
+ } else {
|
|
|
+ this.waiting.push(() => {
|
|
|
+ this.permits--;
|
|
|
+ resolve(() => this.release());
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private release() {
|
|
|
+ this.permits++;
|
|
|
+ if (this.waiting.length > 0) {
|
|
|
+ const next = this.waiting.shift();
|
|
|
+ if (next) next();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 暴露方法给父组件
|
|
|
+defineExpose({
|
|
|
+ cancelUpload,
|
|
|
+ isUploading
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.chunk-upload {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-progress {
|
|
|
+ margin-top: 20px;
|
|
|
+ padding: 20px;
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-info {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-top: 10px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-result {
|
|
|
+ margin-top: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.el-upload__tip {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 12px;
|
|
|
+ margin-top: 7px;
|
|
|
+}
|
|
|
+</style>
|