前端大文件上传解决方案

本文分享的内容是前端大文件上传的解决方案,文件上传是前端开发中常见的需求,特别是在处理视频、大型文档或数据集时。对于小文件上传不做详细介绍,在源码中已附带。

大文件上传前置条件
  1. 设置分片大小的值,即规定每个切片的大小
  2. 设置文件大小阈值,即超过多少M判定为大文件
大文件上传步骤
  1. 计算文件md5的值
  2. 前端对文件进行分割,每个切片中包含索引切片内容文件名称
  3. 对切片集合进行遍历,按照顺序上传切片
  4. 先校验切片是否已上传,若是则直接进入下一个切片的上传,否则进行上传操作
  5. 重复上一步,直至所有切片都已完成上传
  6. 调用合并接口,对切片进行合并
优化点:
  1. 源码中添加切片上传的并发控制
  2. 自动重试机制(每个分片最多重试3次)
重点代码解释
  1. 文件分片处理
    // 创建文件分片数据
    const createFileChunks = (file) => {
      const chunks = [];
      let cur = 0;
      while (cur < file.size) {
        chunks.push({
          fileName: file.name,
          chunkNumber: chunks.length + 1,
          file: file.slice(cur, cur + pageInfo.chunkSize),
        });
        cur += pageInfo.chunkSize;
      }
      return chunks;
    };
    
    • 将大文件分割为3MB大小的分片
    • 每个分片包含文件名、分片序号和分片数据
    • 使用file.slice方法进行文件分割
  2. MD5计算
    // 计算文件md5的值
    const calculateFileMD5 = (file) => {
      return new Promise((resolve) => {
        const spark = new SparkMD5.ArrayBuffer();
        const fileReader = new FileReader();
        const totalChunks = Math.ceil(file.size / pageInfo.chunkSize);
    
        let currentChunk = 0;
        const loadNextChunk = () => {
          const start = currentChunk * pageInfo.chunkSize;
          const end = Math.min(start + pageInfo.chunkSize, file.size);
          fileReader.readAsArrayBuffer(file.raw.slice(start, end));
        };
        
        fileReader.onload = (e) => {
          spark.append(e.target.result);
          currentChunk++;
          if (currentChunk < totalChunks) {
            loadNextChunk();
          } else {
            pageInfo.md5 = spark.end();
            resolve(state.md5);
          }
        };
        loadNextChunk();
      })
    };
    
    • 使用SparkMD5库计算文件MD5值
    • 分片读取文件内容,避免一次性加载大文件导致内存问题
    • 增量计算MD5,最终合并得到完整文件的MD5
  3. 并发数量控制
    const pageInfo = reactive({
    	...,
        concurrency: 3, // 并发数
    });
    const uploadChunks = async (chunks) => {
    	try {
            const results = [];
            const executing = new Set(); // 正在执行的	
            for (const chunk of chunks) {
                // 如果达到最大并发数,等待一个完成
                if (executing.size >= pageInfo.concurrency) {
                    await Promise.race(executing);
                }
                
                // 创建并跟踪请求
                const promise = processChunk(chunk).then(result => {
                          executing.delete(promise);
          	        return result;
          	    });
          	
          	    executing.add(promise);
          	    results.push(promise);
          	}
          	
          	// 等待所有剩余请求完成
          	return await Promise.all(results);
    	} catch (error) {
    		return false;
    	}
    };
    
    • 使用 Set 跟踪正在执行的请求
    • 通过 Promise.race 实现并发限制
    • 最大并发数由 pageInfo.concurrency 控制(默认为3)
  4. 重试机制
    // 每个分片最多重试3次,对应retries参数的值
    
    const processChunk = async (chunk, retries = 3) => {
    	for (let i = 0; i < retries; i++) {
            try {
                // 先检查是否已上传
                const { data } = await checkChunkUploadStatus(chunk);
                if (!data) {
                    console.log(`分片 ${chunk.chunkNumber} 未上传,开始上传 (尝试 ${i + 1}/${retries})`);
                     return await chunkUploadF(chunk);
                 }
                 console.log(`分片 ${chunk.chunkNumber} 已存在,跳过上传`);
                 return {
                     code: 0,
                     data: true,
                     message: "成功",
                     resultMsg: null,
                     chunkNumber: chunk.chunkNumber,
                 };
             } catch (error) {
                 console.error(`分片 ${chunk.chunkNumber} 上传失败 (尝试 ${i + 1}/${retries}):`, error);
                 if (i === retries - 1) throw error;
                 await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); // 指数退避
             }
         }
    };
    
  5. 指数退避策略
    await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); // 指数退避
    
    • 第一次重试等待:1秒 (1000 * 1)
    • 第二次重试等待:2秒 (1000 * 2)
    • 第三次重试等待:3秒 (1000 * 3)
    • 这种逐渐增加等待时间的方式称为"指数退避",可以有效避免网络拥塞
  6. 文件上传入口
    // 文件上传主函数
    const uploadFileF = async () => {
      pageInfo.fileLoading = true;
      if (!pageInfo.isOverThreshold) {
        // 小文件直接上传
        const formData = new FormData();
        formData.append("file", pageInfo.formInfo.fileRaw);
        // ...省略上传代码
      } else {
        // 大文件分片上传
        const fileChunkList = createFileChunks(pageInfo.formInfo.fileRaw);
        const res = await uploadChunks(fileChunkList);
        if (res) {
          // 检查所有分片是否上传成功
          const flag = res.every((ele) => ele.data);
          if (!flag) {
            proxy.$message.error("存在上传失败的分片,请重新上传失败");
          }
        }
        // 合并分片
        const { code, data, message } = await mergeChunks(
          pageInfo.formInfo.fileName,
          fileChunkList.length
        );
        // 处理合并结果
      }
    };
    
    • 根据文件大小选择不同上传策略(5MB为阈值)
    • 小文件直接上传,大文件走分片上传流程
    • 最终合并分片完成上传

本文源码中并未增加进度设计,如有需要可以自行添加。根据上传的切片数量计算即可。其次在最后我添加了测试文件,该文件可以用于前端在开发前的测试和功能集成。

使用
npm install spark-md5
# 或者
yarn add spark-md5
源码
<template>
	<el-dialog
		title="文件上传"
		:visible.sync="dialogVisible"
		width="500px"
		:close-on-click-modal="false"
		append-to-body
		:before-close="cancalF"
	>
		<el-form
			ref="uploadForm"
			:model="formInfo"
			label-width="80px"
			:rules="rules"
			v-loading="fileLoading"
			element-loading-text="上传中..."
			:element-loading-spinner="$loadingStyle"
		>
			<el-form-item label="文件级别" prop="level">
				<el-select
					v-model="formInfo.level"
					placeholder="请选择文件级别"
					clearable
				>
					<el-option
						v-for="item in fileLevelList"
						:key="item.value"
						:label="item.label"
						:value="item.value"
					>
					</el-option>
				</el-select>
			</el-form-item>
			<el-form-item label="文件类型" prop="fileType">
				<el-select
					v-model="formInfo.fileType"
					placeholder="请选择文件类型"
					clearable
				>
					<el-option
						v-for="item in fileTypeList"
						:key="item.value"
						:label="item.label"
						:value="item.value"
					>
					</el-option>
				</el-select>
			</el-form-item>
			<el-form-item label="文件" prop="fileRaw">
				<el-upload
					class="upload-demo"
					ref="uploadRef"
					:limit="1"
					:on-change="handleChange"
					:on-exceed="handleExceed"
					show-file-list
					:auto-upload="false"
					:file-list="fileList"
					:before-upload="() => false"
					action=""
				>
					<el-button size="small" type="primary">点击上传</el-button>
				</el-upload>
			</el-form-item>
		</el-form>
		<span slot="footer" class="dialog-footer">
			<el-button type="primary" @click="markSureF">确 定</el-button>
			<el-button @click="cancalF">取 消</el-button>
		</span>
	</el-dialog>
</template>

<script lang='js'>
import useCommon from "@/hooks/use-common";
import {
	ref,
	reactive,
	defineComponent,
	onMounted,
	computed,
	toRefs,
} from "@vue/composition-api";
import useResizeSearch from "@/hooks/use-resizeSearch";
import api from "@/api";
import request from "@/axios/fetch";
import SparkMD5 from "spark-md5";

export default defineComponent({
	name: "UploadDialog",
	components: {},
	props: {
		value: {
			type: Boolean,
			required: true,
		},
	},
	setup(props, { emit }) {
		const { proxy } = useCommon(); // 作为this使用
		const { isXLCol } = useResizeSearch();
		const uploadForm = ref(null);
		const uploadRef = ref();
		const pageInfo = reactive({
			formInfo: {
				fileType: null,
				fileRaw: null,
                fileName: null,
                level: null,
			},
			fileTypeList: [
				{ label: "down", value: 1 },
				{ label: "常用动态库", value: 2 },
			],
            fileLevelList: [
                { label: "收费站", value: 1 },
				{ label: "路公司", value: 3 },
				{ label: "省中心", value: 4 },
            ],
			rules: {
				level: [
					{
						required: true,
						message: "请选择文件级别",
						trigger: "change",
					},
				],
                fileType: [
					{
						required: true,
						message: "请选择文件类型",
						trigger: "change",
					},
				],
				fileRaw: [
					{
						required: true,
						message: "请上传文件",
						trigger: "change",
					},
				],
			},
			fileList: [],
			fileLoading: false,
			chunkSize: 3 * 1024 * 1024,
			isOverThreshold: false, // 文件大小是否超过阈值
			md5: null,
            chunksData: [], // 分片数据
            concurrency: 3, // 并发数
		});
		const dialogVisible = computed({
			get() {
				return props.value;
			},
			set(newValue) {
				emit("input", newValue);
			},
		});
		const cancalF = () => {
			uploadRef.value.clearFiles();
			pageInfo.formInfo.fileType = null;
			uploadForm.value.resetFields();
			emit("input", false);
		};
        // 创建文件分片数据
		const createFileChunks = (file) => {
			const chunks = [];
			let cur = 0;
			while (cur < file.size) {
				chunks.push({
					fileName: file.name,
					chunkNumber: chunks.length + 1,
					file: file.slice(cur, cur + pageInfo.chunkSize),
				});
				cur += pageInfo.chunkSize;
			}
			return chunks;
		};
		// 计算文件md5的值
		const calculateFileMD5 = (file) => {
            return new Promise((resolve) => {
                const spark = new SparkMD5.ArrayBuffer();
                const fileReader = new FileReader();
                const totalChunks = Math.ceil(file.size / pageInfo.chunkSize);

                let currentChunk = 0;
                const loadNextChunk = () => {
                    const start = currentChunk * pageInfo.chunkSize;
                    const end = Math.min(start + pageInfo.chunkSize, file.size);

                    fileReader.readAsArrayBuffer(file.raw.slice(start, end));
                };
                fileReader.onload = (e) => {
                    spark.append(e.target.result);
                    currentChunk++;
                    if (currentChunk < totalChunks) {
                        loadNextChunk();
                    } else {
                        pageInfo.md5 = spark.end();
                        resolve(state.md5);
                    }
                };
                loadNextChunk();
            })
			
		};
		// 上传文件分片
		const chunkUploadF = (chunk) => {
			const formData = new FormData();
			formData.append("file", chunk.file);
			formData.append("chunkNumber", chunk.chunkNumber);
			formData.append("fileName", chunk.fileName);
			let params = {
				...api.fileManageUploadChunk,
				data: formData,
			};
			return request(params);
		};
		// 查看分片是否已上传
		const checkChunkUploadStatus = async (chunk) => {
			try {
				let params = {
					...api.fileManageUploadCheckChunk,
					data: {
						fileName: chunk.fileName,
						chunkNumber: chunk.chunkNumber,
					},
				};
				return await request(params);
			} catch (error) {
				console.log("检查分片状态失败:", error);
				return false;
			}
		};
		// 上传分片
		const processChunk = async (chunk, retries = 3) => {
			for (let i = 0; i < retries; i++) {
                try {
                    // 先检查是否已上传
                    const { data } = await checkChunkUploadStatus(chunk);
                    if (!data) {
                        console.log(`分片 ${chunk.chunkNumber} 未上传,开始上传 (尝试 ${i + 1}/${retries})`);
                        return await chunkUploadF(chunk);
                    }
                    console.log(`分片 ${chunk.chunkNumber} 已存在,跳过上传`);
                    return {
                        code: 0,
                        data: true,
                        message: "成功",
                        resultMsg: null,
                        chunkNumber: chunk.chunkNumber,
                    };
                } catch (error) {
                    console.error(`分片 ${chunk.chunkNumber} 上传失败 (尝试 ${i + 1}/${retries}):`, error);
                    if (i === retries - 1) throw error;
                    await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); // 指数退避
                }
            }
		};
		// 合并分片
		const mergeChunks = (fileName, totalChunks) => {
			let params = {
				...api.fileManageUploadMerge,
				data: {
					fileName,
					fileType: pageInfo.formInfo.fileType,
					totalChunks,
					md5: pageInfo.md5,
                    level: pageInfo.formInfo.level,
				},
			};
			return request(params);
		};
		const uploadChunks = async (chunks) => {
			try {
                const results = [];
                const executing = new Set(); // 正在执行的请求

                for (const chunk of chunks) {
                    // 如果达到最大并发数,等待一个完成
                    if (executing.size >= pageInfo.concurrency) {
                        await Promise.race(executing);
                    }
                    
                    // 创建并跟踪请求
                    const promise = processChunk(chunk).then(result => {
                        executing.delete(promise);
                        return result;
                    });
                
                    executing.add(promise);
                    results.push(promise);
                }
                
                // 等待所有剩余请求完成
                return await Promise.all(results);
			} catch (error) {
				return false;
			}
		};
		const handleChange = async (file, fileList) => {
			const MAX_FILE_SIZE = 5 * 1024 * 1024;
            pageInfo.isOverThreshold = file.size > MAX_FILE_SIZE
            pageInfo.fileList = fileList.slice(-1);
			pageInfo.formInfo.fileRaw = fileList[0].raw || null;

            // 验证字段
			uploadForm.value.validateField("fileRaw");

			if (pageInfo.isOverThreshold) {
                pageInfo.formInfo.fileName = file.name;
                // 计算文件md5的值
                await calculateFileMD5(file);
			} else {
				pageInfo.isOverThreshold = false;
			}
			
		};
		const handleExceed = (files, fileList) => {
			proxy.$message.warning(
				`当前限制选择 1 个文件,请先删除已有文件再上传`
			);
		};

		const uploadFileF = async () => {
			pageInfo.fileLoading = true;
			if (!pageInfo.isOverThreshold) {
				const formData = new FormData();
				formData.append("file", pageInfo.formInfo.fileRaw);
				let params = {
					url: `${api.fileUpload.url}/${pageInfo.formInfo.fileType}/${pageInfo.formInfo.level}`,
					method: "post",
					data: formData,
				};
				request(params)
					.then((response) => {
						if (!response.isError) {
							proxy.$message.success("上传成功");
							emit("input", false);
							uploadRef.value.clearFiles();
							pageInfo.formInfo.fileType = null;
							uploadForm.value.resetFields();
							emit("updateList", 1);
						} else {
							proxy.$message.error(response.message);
						}
						pageInfo.fileLoading = false;
					})
					.catch((error) => {
						pageInfo.fileLoading = false;
						proxy.$message.error(error.message);
					});
			} else {
                const fileChunkList = createFileChunks(pageInfo.formInfo.fileRaw);
				const res = await uploadChunks(fileChunkList);
				if (res) {
					const flag = res.every((ele) => ele.data);
					if (!flag) {
						proxy.$message.error("存在上传失败的分片,请重新上传失败");
					}
				} else {
					proxy.$message.error("上传失败");
				}
				// 合并分片
				const { code, data, message } = await mergeChunks(
					pageInfo.formInfo.fileName,
					fileChunkList.length
				);
                if (code == 0 && data) {
                    proxy.$message.success(message);
                    pageInfo.fileLoading = false;
                    emit("input", false);
                    uploadRef.value.clearFiles();
                    pageInfo.formInfo.fileType = null;
                    uploadForm.value.resetFields();
                    emit("updateList", 1);
                } else {
                    proxy.$message.error(message);
                }
                
			}
		};
		const markSureF = () => {
			uploadForm.value.validate((valid) => {
				if (valid) {
					uploadFileF();
				}
			});
		};

		onMounted(() => {});
		return {
			...toRefs(pageInfo),
			dialogVisible,
			cancalF,
			markSureF,
			uploadForm,
			handleChange,
			handleExceed,
			uploadRef,
		};
	},
});
</script>
测试文件
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件分片上传测试工具</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
    <script>
        tailwind.config = {
            theme: {
                extend: {
                    colors: {
                        primary: '#165DFF',
                        secondary: '#0A2463',
                        accent: '#3E92CC',
                        dark: '#050A30',
                        light: '#E8F1F2',
                        success: '#36D399',
                        warning: '#FFAB00',
                        error: '#F87272'
                    },
                    fontFamily: {
                        inter: ['Inter', 'system-ui', 'sans-serif'],
                    },
                }
            }
        }
    </script>
    <style type="text/tailwindcss">
        @layer utilities {
            .content-auto {
                content-visibility: auto;
            }
            .upload-drop-area {
                @apply border-2 border-dashed border-primary/30 rounded-lg p-8 text-center transition-all duration-300 hover:border-primary/60 hover:bg-primary/5;
            }
            .upload-drop-area-active {
                @apply border-primary bg-primary/10;
            }
            .progress-bar {
                @apply h-2 bg-gray-200 rounded-full overflow-hidden;
            }
            .progress-value {
                @apply h-full bg-primary transition-all duration-300 ease-out;
            }
            .btn-primary {
                @apply bg-primary hover:bg-primary/90 text-white font-medium py-2 px-6 rounded-lg transition-all duration-300 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
            }
            .btn-secondary {
                @apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-6 rounded-lg transition-all duration-300;
            }
            .file-item {
                @apply bg-white rounded-lg shadow-md p-4 mb-4 flex items-center justify-between transition-all duration-300 hover:shadow-lg;
            }
            .file-icon {
                @apply w-12 h-12 flex items-center justify-center rounded-lg mr-4 text-2xl;
            }
            .file-info {
                @apply flex-1 min-w-0;
            }
            .file-name {
                @apply font-medium text-gray-900 truncate;
            }
            .file-size {
                @apply text-sm text-gray-500;
            }
            .upload-status {
                @apply flex items-center text-sm;
            }
            .fade-in {
                animation: fadeIn 0.5s ease-in-out;
            }
            @keyframes fadeIn {
                from { opacity: 0; transform: translateY(10px); }
                to { opacity: 1; transform: translateY(0); }
            }
            .pulse {
                animation: pulse 2s infinite;
            }
            @keyframes pulse {
                0% { opacity: 1; }
                50% { opacity: 0.5; }
                100% { opacity: 1; }
            }
        }
    </style>
</head>
<body class="bg-gray-50 font-inter text-gray-800 min-h-screen">
    <div class="container mx-auto px-4 py-8 max-w-6xl">
        <!-- 头部 -->
        <header class="mb-8 text-center">
            <h1 class="text-[clamp(1.8rem,4vw,2.5rem)] font-bold text-dark mb-2">
                <i class="fa fa-cloud-upload text-primary mr-2"></i>文件分片上传测试工具
            </h1>
            <p class="text-gray-600 max-w-2xl mx-auto">支持大文件分片上传、断点续传和MD5校验,可自定义分片大小和并发数</p>
        </header>

        <!-- 主内容区 -->
        <main class="bg-white rounded-xl shadow-xl overflow-hidden">
            <!-- 配置区 -->
            <div class="p-6 border-b border-gray-200 bg-gray-50">
                <h2 class="text-xl font-semibold mb-4 flex items-center">
                    <i class="fa fa-sliders text-primary mr-2"></i>上传配置
                </h2>
                <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">分片大小</label>
                        <div class="flex items-center">
                            <input type="number" id="chunkSize" value="5" min="1" max="100" 
                                class="w-full rounded-l-lg border border-gray-300 py-2 px-3 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary">
                            <span class="bg-gray-100 border border-l-0 border-gray-300 rounded-r-lg px-3 py-2 text-gray-700">MB</span>
                        </div>
                    </div>
                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">并发数</label>
                        <input type="number" id="concurrency" value="3" min="1" max="10" 
                            class="w-full rounded-lg border border-gray-300 py-2 px-3 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary">
                    </div>
                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">服务器URL</label>
                        <input type="url" id="uploadUrl" value="/api/upload/chunk" 
                            class="w-full rounded-lg border border-gray-300 py-2 px-3 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary">
                    </div>
                </div>
            </div>

            <!-- 文件选择和拖放区 -->
            <div class="p-8">
                <div id="dropArea" class="upload-drop-area mb-8">
                    <div class="space-y-4">
                        <i class="fa fa-cloud-upload text-5xl text-primary/60"></i>
                        <h3 class="text-xl font-semibold text-gray-800">拖放文件到此处上传</h3>
                        <p class="text-gray-500">或者</p>
                        <label for="fileInput" class="btn-primary inline-flex items-center">
                            <i class="fa fa-file-text-o mr-2"></i>选择文件
                            <input type="file" id="fileInput" class="hidden" multiple>
                        </label>
                        <p class="text-sm text-gray-400">支持多文件上传,最大文件大小无限制</p>
                    </div>
                </div>

                <!-- 文件列表 -->
                <div>
                    <h2 class="text-xl font-semibold mb-4 flex items-center">
                        <i class="fa fa-file-o text-primary mr-2"></i>文件列表
                    </h2>
                    <div id="fileList" class="space-y-3"></div>
                </div>
            </div>
        </main>

        <!-- 页脚 -->
        <footer class="mt-12 text-center text-gray-500 text-sm">
            <p>© 2023 文件分片上传测试工具 | 支持断点续传和MD5校验</p>
        </footer>
    </div>

    <script>
        // 文件上传管理器
        class FileUploadManager {
            constructor() {
                this.files = [];
                this.chunkSize = 5 * 1024 * 1024; // 默认5MB
                this.concurrency = 3; // 默认并发数
                this.uploadUrl = '/api/upload/chunk';
                this.initEventListeners();
            }

            // 初始化事件监听
            initEventListeners() {
                // 文件选择
                document.getElementById('fileInput').addEventListener('change', e => {
                    this.handleFiles(e.target.files);
                });

                // 拖放事件
                const dropArea = document.getElementById('dropArea');
                ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
                    dropArea.addEventListener(eventName, this.preventDefaults, false);
                });

                ['dragenter', 'dragover'].forEach(eventName => {
                    dropArea.addEventListener(eventName, () => {
                        dropArea.classList.add('upload-drop-area-active');
                    }, false);
                });

                ['dragleave', 'drop'].forEach(eventName => {
                    dropArea.addEventListener(eventName, () => {
                        dropArea.classList.remove('upload-drop-area-active');
                    }, false);
                });

                dropArea.addEventListener('drop', e => {
                    this.handleFiles(e.dataTransfer.files);
                }, false);

                // 配置更改
                document.getElementById('chunkSize').addEventListener('change', e => {
                    this.chunkSize = parseInt(e.target.value) * 1024 * 1024;
                });

                document.getElementById('concurrency').addEventListener('change', e => {
                    this.concurrency = parseInt(e.target.value);
                });

                document.getElementById('uploadUrl').addEventListener('change', e => {
                    this.uploadUrl = e.target.value;
                });
            }

            // 阻止默认事件
            preventDefaults(e) {
                e.preventDefault();
                e.stopPropagation();
            }

            // 处理选择的文件
            handleFiles(files) {
                if (!files.length) return;
                Array.from(files).forEach(file => {
                    if (this.isFileAdded(file)) return;

                    const fileItem = this.createFileItem(file);
                    document.getElementById('fileList').appendChild(fileItem);
                    this.files.push({
                        file,
                        element: fileItem,
                        status: 'ready',
                        progress: 0,
                        md5: null,
                        chunks: []
                    });
                    // 计算文件MD5
                    this.calculateFileMD5(file, fileItem);
                });
            }

            // 检查文件是否已添加
            isFileAdded(file) {
                return this.files.some(item => item.file.name === file.name && item.file.size === file.size);
            }

            // 创建文件项DOM
            createFileItem(file) {
                const fileType = this.getFileType(file.name);
                const fileSize = this.formatFileSize(file.size);

                const div = document.createElement('div');
                div.className = 'file-item fade-in';
                div.innerHTML = `
                    <div class="flex items-center">
                        <div class="file-icon ${this.getFileIconClass(fileType)}">
                            <i class="fa ${this.getFileIcon(fileType)}"></i>
                        </div>
                        <div class="file-info">
                            <div class="file-name">${file.name}</div>
                            <div class="file-size">${fileSize}</div>
                            <div class="mt-2 progress-bar">
                                <div class="progress-value" style="width: 0%"></div>
                            </div>
                        </div>
                    </div>
                    <div class="upload-status ml-4">
                        <span class="status-text">准备中...</span>
                        <span class="status-icon ml-2"><i class="fa fa-spinner fa-spin"></i></span>
                    </div>
                    <div class="ml-4 flex space-x-2">
                        <button class="upload-btn btn-primary px-3 py-1 text-sm hidden">
                            <i class="fa fa-upload mr-1"></i>上传
                        </button>
                        <button class="cancel-btn btn-secondary px-3 py-1 text-sm hidden">
                            <i class="fa fa-times mr-1"></i>取消
                        </button>
                    </div>
                `;

                // 添加事件监听
                div.querySelector('.upload-btn').addEventListener('click', () => {
                    this.startUpload(file);
                });

                div.querySelector('.cancel-btn').addEventListener('click', () => {
                    this.cancelUpload(file);
                });

                return div;
            }

            // 获取文件类型
            getFileType(fileName) {
                const ext = fileName.split('.').pop().toLowerCase();
                const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
                const videoExts = ['mp4', 'avi', 'mov', 'mkv', 'wmv'];
                const audioExts = ['mp3', 'wav', 'ogg', 'flac'];
                const docExts = ['doc', 'docx', 'pdf', 'txt', 'ppt', 'pptx', 'xls', 'xlsx'];

                if (imageExts.includes(ext)) return 'image';
                if (videoExts.includes(ext)) return 'video';
                if (audioExts.includes(ext)) return 'audio';
                if (docExts.includes(ext)) return 'document';
                return 'unknown';
            }

            // 获取文件图标
            getFileIcon(fileType) {
                const icons = {
                    'image': 'fa-file-image-o',
                    'video': 'fa-file-video-o',
                    'audio': 'fa-file-audio-o',
                    'document': 'fa-file-text-o',
                    'unknown': 'fa-file-o'
                };
                return icons[fileType] || 'fa-file-o';
            }

            // 获取文件图标颜色类
            getFileIconClass(fileType) {
                const colors = {
                    'image': 'bg-blue-100 text-blue-600',
                    'video': 'bg-red-100 text-red-600',
                    'audio': 'bg-green-100 text-green-600',
                    'document': 'bg-yellow-100 text-yellow-600',
                    'unknown': 'bg-gray-100 text-gray-600'
                };
                return colors[fileType] || 'bg-gray-100 text-gray-600';
            }

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

            // 计算文件MD5
            calculateFileMD5(file, fileItem) {
                const fileObj = this.getFileObject(file);
                if (!fileObj) return;

                const statusText = fileItem.querySelector('.status-text');
                const statusIcon = fileItem.querySelector('.status-icon');
                statusText.textContent = '计算MD5...';
                
                const spark = new SparkMD5.ArrayBuffer();
                const fileReader = new FileReader();
                const chunkSize = 10 * 1024 * 1024; // MD5计算块大小
                const totalChunks = Math.ceil(file.size / chunkSize);
                let currentChunk = 0;
                
                const processChunk = () => {
                    const start = currentChunk * chunkSize;
                    const end = Math.min(start + chunkSize, file.size);
                    
                    fileReader.readAsArrayBuffer(file.slice(start, end));
                };
                
                fileReader.onload = (e) => {
                    spark.append(e.target.result);
                    currentChunk++;
                    
                    const progress = Math.round((currentChunk / totalChunks) * 100);
                    statusText.textContent = `计算MD5: ${progress}%`;
                    
                    if (currentChunk < totalChunks) {
                        processChunk();
                    } else {
                        const md5 = spark.end();
                        fileObj.md5 = md5;
                        statusText.textContent = 'MD5计算完成';
                        statusIcon.innerHTML = '<i class="fa fa-check text-success"></i>';
                        
                        // 准备分片
                        this.prepareChunks(file);
                        
                        // 显示上传按钮
                        fileItem.querySelector('.upload-btn').classList.remove('hidden');
                        fileItem.querySelector('.cancel-btn').classList.remove('hidden');
                    }
                };
                
                fileReader.onerror = () => {
                    statusText.textContent = 'MD5计算失败';
                    statusIcon.innerHTML = '<i class="fa fa-times text-error"></i>';
                };
                
                processChunk();
            }

            // 准备文件分片
            prepareChunks(file) {
                const fileObj = this.getFileObject(file);
                if (!fileObj) return;

                const totalSize = file.size;
                const totalChunks = Math.ceil(totalSize / this.chunkSize);
                
                fileObj.chunks = [];
                
                for (let i = 0; i < totalChunks; i++) {
                    const start = i * this.chunkSize;
                    const end = Math.min(start + this.chunkSize, totalSize);
                    
                    fileObj.chunks.push({
                        index: i,
                        start,
                        end,
                        size: end - start,
                        status: 'pending',
                        retries: 0
                    });
                }
                
                fileObj.totalChunks = totalChunks;
            }

            // 开始上传文件
            startUpload(file) {
                const fileObj = this.getFileObject(file);
                if (!fileObj || fileObj.status === 'uploading') return;

                fileObj.status = 'uploading';
                
                const fileItem = fileObj.element;
                fileItem.querySelector('.status-text').textContent = '上传中...';
                fileItem.querySelector('.status-icon').innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
                fileItem.querySelector('.upload-btn').disabled = true;
                
                // 并发上传分片
                this.uploadChunksConcurrently(file);
            }

            // 并发上传分片
            uploadChunksConcurrently(file) {
                const fileObj = this.getFileObject(file);
                if (!fileObj) return;

                const pendingChunks = fileObj.chunks.filter(chunk => chunk.status === 'pending');
                const activeUploads = this.getActiveUploads(file);
                
                // 如果所有分片都上传完成,发起合并请求
                if (pendingChunks.length === 0 && activeUploads === 0) {
                    this.mergeChunks(file);
                    return;
                }
                
                // 控制并发数
                while (activeUploads < this.concurrency && pendingChunks.length > 0) {
                    const chunk = pendingChunks[0];
                    this.uploadChunk(file, chunk);
                    
                    // 标记为上传中
                    chunk.status = 'uploading';
                }
            }

            // 获取活跃的上传数
            getActiveUploads(file) {
                const fileObj = this.getFileObject(file);
                if (!fileObj) return 0;
                
                return fileObj.chunks.filter(chunk => chunk.status === 'uploading').length;
            }

            // 上传单个分片
            uploadChunk(file, chunk) {
                const fileObj = this.getFileObject(file);
                if (!fileObj) return;

                const formData = new FormData();
                formData.append('file', file.slice(chunk.start, chunk.end), file.name);
                formData.append('fileName', file.name);
                formData.append('chunkNumber', chunk.index);
                formData.append('totalChunks', fileObj.totalChunks);
                formData.append('md5', fileObj.md5);
                
                fetch(this.uploadUrl, {
                    method: 'POST',
                    body: formData
                })
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`HTTP error! status: ${response.status}`);
                    }
                    return response.json();
                })
                .then(data => {
                    if (data.success) {
                        // 分片上传成功
                        chunk.status = 'success';
                        
                        // 更新进度
                        this.updateUploadProgress(file);
                        
                        // 继续上传其他分片
                        this.uploadChunksConcurrently(file);
                    } else {
                        throw new Error(data.message || '上传失败');
                    }
                })
                .catch(error => {
                    console.error('上传分片失败:', error);
                    
                    // 重试机制
                    if (chunk.retries < 3) {
                        chunk.retries++;
                        chunk.status = 'pending';
                        
                        // 延迟重试
                        setTimeout(() => {
                            this.uploadChunk(file, chunk);
                        }, 1000 * chunk.retries);
                    } else {
                        // 重试次数用尽
                        chunk.status = 'failed';
                        fileObj.status = 'failed';
                        
                        const fileItem = fileObj.element;
                        fileItem.querySelector('.status-text').textContent = '上传失败';
                        fileItem.querySelector('.status-icon').innerHTML = '<i class="fa fa-times text-error"></i>';
                        fileItem.querySelector('.upload-btn').disabled = false;
                    }
                });
            }

            // 更新上传进度
            updateUploadProgress(file) {
                const fileObj = this.getFileObject(file);
                if (!fileObj) return;

                const completedChunks = fileObj.chunks.filter(chunk => chunk.status === 'success').length;
                const progress = Math.round((completedChunks / fileObj.totalChunks) * 100);
                
                fileObj.progress = progress;
                
                const fileItem = fileObj.element;
                fileItem.querySelector('.progress-value').style.width = `${progress}%`;
                fileItem.querySelector('.status-text').textContent = `上传中: ${progress}%`;
            }

            // 合并分片
            mergeChunks(file) {
                const fileObj = this.getFileObject(file);
                if (!fileObj) return;

                const fileItem = fileObj.element;
                fileItem.querySelector('.status-text').textContent = '合并分片中...';
                
                const formData = new FormData();
                formData.append('fileName', file.name);
                formData.append('totalChunks', fileObj.totalChunks);
                formData.append('md5', fileObj.md5);
                
                // 合并API通常是另一个端点
                const mergeUrl = this.uploadUrl.replace('/chunk', '/merge');
                
                fetch(mergeUrl, {
                    method: 'POST',
                    body: formData
                })
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`HTTP error! status: ${response.status}`);
                    }
                    return response.json();
                })
                .then(data => {
                    if (data.success) {
                        fileObj.status = 'completed';
                        
                        fileItem.querySelector('.status-text').textContent = '上传完成';
                        fileItem.querySelector('.status-icon').innerHTML = '<i class="fa fa-check text-success"></i>';
                        fileItem.querySelector('.upload-btn').classList.add('hidden');
                        fileItem.querySelector('.cancel-btn').classList.add('hidden');
                        
                        // 添加下载链接(如果服务器返回了文件URL)
                        if (data.fileUrl) {
                            const downloadLink = document.createElement('a');
                            downloadLink.href = data.fileUrl;
                            downloadLink.target = '_blank';
                            downloadLink.className = 'btn-secondary px-3 py-1 text-sm';
                            downloadLink.innerHTML = '<i class="fa fa-download mr-1"></i>下载';
                            fileItem.querySelector('div:last-child').appendChild(downloadLink);
                        }
                    } else {
                        throw new Error(data.message || '合并失败');
                    }
                })
                .catch(error => {
                    console.error('合并分片失败:', error);
                    
                    fileObj.status = 'failed';
                    fileItem.querySelector('.status-text').textContent = '合并失败';
                    fileItem.querySelector('.status-icon').innerHTML = '<i class="fa fa-times text-error"></i>';
                    fileItem.querySelector('.upload-btn').disabled = false;
                });
            }

            // 取消上传
            cancelUpload(file) {
                const fileObj = this.getFileObject(file);
                if (!fileObj) return;

                fileObj.status = 'cancelled';
                
                // 移除文件项
                setTimeout(() => {
                    fileObj.element.classList.add('opacity-0', 'translate-y-4');
                    fileObj.element.style.transition = 'all 0.5s ease-out';
                    
                    setTimeout(() => {
                        fileObj.element.remove();
                        this.files = this.files.filter(item => item.file !== file);
                    }, 500);
                }, 100);
            }

            // 获取文件对象
            getFileObject(file) {
                return this.files.find(item => item.file === file);
            }
        }

        // 初始化上传管理器
        document.addEventListener('DOMContentLoaded', () => {
            const uploadManager = new FileUploadManager();
        });
    </script>
</body>
</html>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值