Vue大文件上传实现方案(企业级完整版)

在Vue项目中,大文件(通常指100MB以上)直接上传会面临请求超时、浏览器卡死等问题,核心解决方案是「分片上传」。本文基于Vue3+Node.js实现企业级完整版,包含断点续传、秒传、失败自动重试、暂停/继续、多文件上传、取消上传、上传队列管理所有核心功能,代码精简可直接运行,无复杂第三方依赖。

一、核心原理

将大文件按固定大小(2MB)切割成多个小分片,并发上传分片,所有分片上传完成后通知后端合并;通过文件哈希实现秒传,通过校验已上传分片实现断点续传,结合队列管理实现多文件有序上传,配套暂停/继续、取消、失败重试机制,适配企业级使用场景。

  • 秒传:计算文件唯一哈希值,上传前校验后端是否已存在该文件,存在则直接返回成功。
  • 断点续传:上传前校验已上传的分片,仅上传未完成的分片;刷新页面、断网后重新上传,可自动恢复上传进度。
  • 队列管理:多文件上传时,按选择顺序排队上传,支持调整队列顺序、删除队列文件。
  • 其他特性:失败自动重试、暂停/继续上传、单个/全部取消上传,覆盖企业级所有常见需求。

二、前端实现(Vue3 + 原生JS)

1. 依赖准备

仅需2个基础依赖,执行命令安装:

arduino 复制代码
// 安装axios(接口请求)、spark-md5(文件哈希计算)
npm install axios spark-md5

2. 工具类封装(utils/upload.js)

封装所有核心方法,包含失败重试、分片处理、接口请求,可直接复用:

javascript 复制代码
import SparkMD5 from 'spark-md5';
import axios from 'axios';

// 核心配置(可根据企业需求微调)
export const UPLOAD_CONFIG = {
  chunkSize: 2 * 1024 * 1024, // 分片大小:2MB(适配大多数场景)
  baseUrl: 'http://localhost:3000', // 后端接口地址
  maxRetry: 3, // 分片失败最大重试次数(企业级常用配置)
  concurrency: 3, // 并发上传数量(避免请求过多压垮服务器)
  retryDelay: 1000 // 失败重试延迟(1秒,避免频繁重试)
};

// 切割文件为分片
export function createFileChunk(file) {
  const chunks = [];
  let current = 0;
  while (current< file.size) {
    chunks.push({
      chunk: file.slice(current, current + UPLOAD_CONFIG.chunkSize),
      index: chunks.length,
      progress: 0 // 单个分片进度
    });
    current += UPLOAD_CONFIG.chunkSize;
  }
  return chunks;
}

// 计算文件哈希值(秒传/断点续传校验用,优化计算速度)
export async function calculateFileHash(file, chunks) {
  return new Promise((resolve, reject) => {
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    let currentChunk = 0;

    const loadNextChunk = () => {
      if (currentChunk >= chunks.length) {
        resolve(spark.end()); // 计算完成,返回哈希值
        return;
      }
      // 读取当前分片(ArrayBuffer格式,计算哈希更高效)
      fileReader.readAsArrayBuffer(chunks[currentChunk].chunk);
      currentChunk++;
    };

    fileReader.onload = (e) => spark.append(e.target.result);
    fileReader.onloadend = loadNextChunk;
    fileReader.onerror = (err) => reject(`哈希计算失败:${err.message}`);

    loadNextChunk(); // 开始读取第一个分片
  });
}

// 校验文件(秒传、断点续传核心接口)
export async function checkFile(fileHash, filename) {
  try {
    const res = await axios.post(`${UPLOAD_CONFIG.baseUrl}/check`, { fileHash, filename });
    return res.data; // 后端返回:{ isExist: boolean, uploadedChunks: [] }
  } catch (err) {
    console.error('文件校验失败', err);
    return { isExist: false, uploadedChunks: [] };
  }
}

// 单个分片上传(带失败自动重试)
export async function uploadSingleChunk(chunkInfo, fileHash, retryCount = 0) {
  const { chunk, index, total } = chunkInfo;
  const formData = new FormData();
  formData.append('chunk', chunk);
  formData.append('fileHash', fileHash);
  formData.append('index', index);
  formData.append('total', total);

  try {
    const res = await axios.post(`${UPLOAD_CONFIG.baseUrl}/upload`, formData, {
      onUploadProgress: (e) => {
        // 实时更新单个分片进度
        chunkInfo.progress = (e.loaded / e.total) * 100;
      },
      timeout: 30000 // 超时时间30秒,适配大分片上传
    });
    return res.data;
  } catch (err) {
    // 失败自动重试(未超过最大重试次数)
    if (retryCount < UPLOAD_CONFIG.maxRetry) {
      await new Promise(resolve => setTimeout(resolve, UPLOAD_CONFIG.retryDelay));
      console.log(`分片${index}重试(${retryCount + 1}/${UPLOAD_CONFIG.maxRetry})`);
      return uploadSingleChunk(chunkInfo, fileHash, retryCount + 1);
    }
    throw new Error(`分片${index}上传失败,已超过最大重试次数`);
  }
}

// 合并分片(所有分片上传完成后调用)
export async function mergeChunks(fileHash, filename) {
  try {
    const res = await axios.post(`${UPLOAD_CONFIG.baseUrl}/merge`, { fileHash, filename });
    return res.data;
  } catch (err) {
    console.error('分片合并失败', err);
    throw new Error('分片合并失败,请重试');
  }
}

// 取消上传(删除后端临时分片)
export async function cancelUpload(fileHash) {
  try {
    await axios.post(`${UPLOAD_CONFIG.baseUrl}/cancel`, { fileHash });
    return { code: 0, msg: '取消上传成功' };
  } catch (err) {
    console.error('取消上传失败', err);
    return { code: 1, msg: '取消上传失败' };
  }
}

3. 上传组件(views/LargeFileUpload.vue)

完整实现所有企业级功能,包含多文件上传、队列管理、暂停/继续、取消、断点续传、秒传、失败重试,界面简洁贴合企业使用:

ini 复制代码
<template>
  <div style="padding: 30px; max-width: 1000px; margin: 0 auto">
    <h3>Vue大文件上传(企业级完整版)&lt;/h3&gt;
    
    <!-- 文件选择(支持多文件) -->
    <div style="margin: 20px 0">
      <input
        type="file"
        @change="handleFileChange"
        multiple
        :disabled="isAllUploading"
      />
      <span style="margin-left: 10px; font-size: 14px; color: #666">
        支持多文件上传,单个文件建议不超过10GB
      &lt;/span&gt;
    &lt;/div&gt;
    
    <!-- 上传队列管理 -->
    <div v-if="uploadQueue.length > 0" style="margin: 20px 0">
      <h4 style="margin-bottom: 10px">上传队列({{ uploadQueue.length }}个文件)</h4>
      <div 
        v-for="(item, index) in uploadQueue" 
        :key="item.fileHash"
        style="border: 1px solid #eee; padding: 15px; border-radius: 4px; margin-bottom: 10px"
      >
        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px">
          <div>
            <span>文件:{{ item.file.name }}</span>
            <span style="margin-left: 10px; color: #666">
              大小:{{ (item.file.size / 1024 / 1024).toFixed(2) }} MB
            </span&gt;
          &lt;/div&gt;
          &lt;div&gt;
            <!-- 队列操作:删除 -->
            <button 
              @click="removeFromQueue(index)"
              :disabled="item.uploading"
              style="margin-right: 10px; color: #f44336; border: none; background: transparent; cursor: pointer"
            >
              删除
            </button&gt;
            <!-- 上传操作:暂停/继续/取消 -->
            <button 
              @click="handleItemPauseResume(item)"
              :disabled="item.isCompleted || item.isCanceled"
              style="margin-right: 10px; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer"
              :style="{ background: item.paused ? '#2196f3' : '#f5a623', color: '#fff' }"
            >
              {{ item.paused ? '继续' : '暂停' }}
            </button>
            <button 
              @click="handleItemCancel(item)"
              :disabled="item.isCompleted || item.isCanceled"
              style="border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; background: #f44336; color: #fff"
            >
              取消
            </button>
          </div&gt;
        &lt;/div&gt;
        
        <!-- 单个文件进度条 -->
        <div v-if="item.totalProgress > 0 || item.uploading || item.paused">
          <div style="display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 5px">
            <span>进度:{{ item.totalProgress.toFixed(2) }}%</span>
            <span>状态:{{ getStatusText(item) }}</span>
          </div>
          <div style="height: 8px; background: #eee; border-radius: 4px">
            <div
              style="height: 100%; background: #42b983; border-radius: 4px; transition: width 0.3s ease"
              :style="{ width: `${item.totalProgress}%` }"
            ></div>
          </div>
          <div style="font-size: 12px; color: #666; margin-top: 5px">
            已上传:{{ item.uploadedChunkCount }}/{{ item.totalChunkCount }} 个分片
          </div>
        </div&gt;
        
        <!-- 提示信息 -->
        <div 
          v-if="item.message" 
          style="margin-top: 10px; padding: 6px; border-radius: 4px; font-size: 12px"
          :style="{ background: item.isSuccess ? '#e8f5e9' : '#ffebee', color: item.isSuccess ? '#2e7d32' : '#c62828' }"
        >
          {{ item.message }}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    
    <!-- 批量操作按钮 -->
    <div v-if="uploadQueue.length > 0" style="margin: 10px 0">
      <button 
        @click="handleStartAll"
        :disabled="isAllUploading || isAllCompleted || isAllCanceled"
        style="margin-right: 10px; border: none; padding: 6px 12px; border-radius: 4px; background: #42b983; color: #fff; cursor: pointer"
      >
        开始所有上传
      </button>
      <button 
        @click="handlePauseAll"
        :disabled="!hasUploading || isAllPaused"
        style="margin-right: 10px; border: none; padding: 6px 12px; border-radius: 4px; background: #f5a623; color: #fff; cursor: pointer"
      >
        暂停所有上传
      </button>
      <button 
        @click="handleResumeAll"
        :disabled="!hasPaused"
        style="margin-right: 10px; border: none; padding: 6px 12px; border-radius: 4px; background: #2196f3; color: #fff; cursor: pointer"
      >
        继续所有上传
      </button>
      <button 
        @click="handleCancelAll"
        :disabled="isAllCompleted || isAllCanceled"
        style="border: none; padding: 6px 12px; border-radius: 4px; background: #f44336; color: #fff; cursor: pointer"
      >
        取消所有上传
      </button>
    </div>
    
   <!-- 空队列提示 -->
    <div v-if="uploadQueue.length === 0" style="padding: 20px; text-align: center; color: #666">
      暂无上传文件,请选择文件添加到队列
    </div>
  </div>
</template>

<script setup>
import { ref, watch, computed } from 'vue';
import {
  createFileChunk,
  calculateFileHash,
  checkFile,
  uploadSingleChunk,
  mergeChunks,
  cancelUpload,
  UPLOAD_CONFIG
} from '@/utils/upload';

// 上传队列(多文件管理核心)
const uploadQueue = ref([]);

// 队列状态计算(批量操作使用)
const isAllUploading = computed(() => uploadQueue.value.every(item => item.uploading));
const isAllCompleted = computed(() => uploadQueue.value.every(item => item.isCompleted));
const isAllCanceled = computed(() => uploadQueue.value.every(item => item.isCanceled));
const isAllPaused = computed(() => uploadQueue.value.every(item => item.paused && !item.isCompleted && !item.isCanceled));
const hasUploading = computed(() => uploadQueue.value.some(item => item.uploading));
const hasPaused = computed(() => uploadQueue.value.some(item => item.paused && !item.isCompleted && !item.isCanceled));

// 选择多文件,添加到上传队列
const handleFileChange = async (e) => {
  const selectedFiles = e.target.files;
  if (!selectedFiles || selectedFiles.length === 0) return;

  // 遍历选中的文件,添加到队列(去重:相同文件哈希不重复添加)
  for (const file of selectedFiles) {
    // 先切割分片,计算哈希(用于去重和后续上传)
    const chunks = createFileChunk(file);
    const fileHash = await calculateFileHash(file, chunks);

    // 去重:判断队列中是否已存在该文件(通过哈希值)
    const isExistInQueue = uploadQueue.value.some(item => item.fileHash === fileHash);
    if (isExistInQueue) {
      alert(`文件${file.name}已在上传队列中,无需重复添加`);
      continue;
    }

    // 添加到上传队列,初始化状态
    uploadQueue.value.push({
      file,
      fileHash,
      chunks,
      totalChunkCount: chunks.length,
      uploadedChunkCount: 0,
      totalProgress: 0,
      uploading: false,
      paused: false,
      isCompleted: false,
      isCanceled: false,
      message: '',
      isSuccess: false,
      isError: false
    });
  }

  // 清空input值,避免重复选择同一文件
  e.target.value = '';
};

// 获取文件状态文本
const getStatusText = (item) => {
  if (item.isCompleted) return '上传完成';
  if (item.isCanceled) return '已取消';
  if (item.uploading) return '上传中';
  if (item.paused) return '已暂停';
  return '待上传';
};

// 单个文件:开始/继续上传(核心方法,支持断点续传)
const handleItemUpload = async (item) => {
  if (item.uploading || item.isCompleted || item.isCanceled) return;

  try {
    item.uploading = true;
    item.paused = false;
    item.message = '准备上传(校验文件+计算哈希)...';

    // 1. 校验文件(秒传、断点续传)
    const checkResult = await checkFile(item.fileHash, item.file.name);
    if (checkResult.isExist) {
      // 秒传:文件已存在,直接标记完成
      item.message = '文件已存在,秒传成功!';
      item.isSuccess = true;
      item.isCompleted = true;
      item.totalProgress = 100;
      item.uploading = false;
      return;
    }

    // 2. 过滤已上传分片(断点续传:刷新页面/断网后恢复)
    const unUploadedChunks = item.chunks.filter(
      (chunk) => !checkResult.uploadedChunks.includes(chunk.index)
    );
    item.uploadedChunkCount = item.chunks.length - unUploadedChunks.length;
    item.totalProgress = (item.uploadedChunkCount / item.totalChunkCount) * 100;

    // 3. 所有分片已上传,直接合并
    if (unUploadedChunks.length === 0) {
      await mergeChunks(item.fileHash, item.file.name);
      item.message = '所有分片已上传,合并完成!';
      item.isSuccess = true;
      item.isCompleted = true;
      item.totalProgress = 100;
      item.uploading = false;
      return;
    }

    // 4. 并发上传未完成的分片(带失败自动重试)
    item.message = '开始上传分片...';
    await uploadChunksConcurrently(unUploadedChunks, item);

    // 5. 合并分片
    item.message = '分片上传完成,正在合并文件...';
    await mergeChunks(item.fileHash, item.file.name);

    // 上传成功
    item.message = '文件上传成功!';
    item.isSuccess = true;
    item.isCompleted = true;
    item.totalProgress = 100;
  } catch (err) {
    item.message = `上传失败:${err.message}`;
    item.isError = true;
    item.paused = true; // 失败后自动暂停,方便用户重试
  } finally {
    item.uploading = false;
  }
};

// 并发上传分片(控制并发数量,监听进度)
const uploadChunksConcurrently = async (unUploadedChunks, item) => {
  // 给分片添加总分片数,用于上传接口
  const chunksWithMeta = unUploadedChunks.map(chunk => ({
    ...chunk,
    total: item.totalChunkCount
  }));

  // 监听分片进度,更新文件总进度
  watch(
    () => chunksWithMeta.map(chunk => chunk.progress),
    () => {
      const totalLoaded = chunksWithMeta.reduce((sum, chunk) => sum + chunk.progress, 0);
      item.totalProgress = (item.uploadedChunkCount / item.totalChunkCount) * 100 + (totalLoaded / item.totalChunkCount / 100);
    },
    { deep: true }
  );

  // 并发控制:每次最多上传UPLOAD_CONFIG.concurrency个分片
  for (let i = 0; i < chunksWithMeta.length; i += UPLOAD_CONFIG.concurrency) {
    // 暂停状态时,等待继续上传
    if (item.paused) {
      await new Promise(resolve => {
        const watcher = watch(() => item.paused, (newVal) => {
          if (!newVal) {
            watcher(); // 取消监听
            resolve();
          }
        });
      });
    }

    // 取消上传时,终止当前批量上传
    if (item.isCanceled) break;

    const batch = chunksWithMeta.slice(i, i + UPLOAD_CONFIG.concurrency);
    await Promise.all(batch.map(chunk => uploadSingleChunk(chunk, item.fileHash)));
    item.uploadedChunkCount += batch.length;
  }
};

// 单个文件:暂停/继续上传
const handleItemPauseResume = (item) => {
  if (item.uploading) {
    // 暂停上传
    item.paused = true;
    item.uploading = false;
    item.message = '上传已暂停,点击继续可恢复';
  } else if (item.paused && !item.isCompleted && !item.isCanceled) {
    // 继续上传
    handleItemUpload(item);
  }
};

// 单个文件:取消上传
const handleItemCancel = async (item) => {
  if (item.isCompleted || item.isCanceled) return;

  // 取消后端临时分片
  await cancelUpload(item.fileHash);

  // 更新文件状态
  item.isCanceled = true;
  item.uploading = false;
  item.paused = false;
  item.message = '已取消上传';
  item.isError = true;
};

// 从队列中删除文件
const removeFromQueue = (index) => {
  const item = uploadQueue.value[index];
  if (item.uploading) {
    alert('当前文件正在上传,无法删除,请先暂停或取消上传');
    return;
  }
  uploadQueue.value.splice(index, 1);
};

// 批量操作:开始所有文件上传
const handleStartAll = () => {
  uploadQueue.value.forEach(item => {
    if (!item.uploading && !item.isCompleted && !item.isCanceled && !item.paused) {
      handleItemUpload(item);
    }
  });
};

// 批量操作:暂停所有文件上传
const handlePauseAll = () => {
  uploadQueue.value.forEach(item => {
    if (item.uploading) {
      item.paused = true;
      item.uploading = false;
      item.message = '上传已暂停,点击继续可恢复';
    }
  });
};

// 批量操作:继续所有文件上传
const handleResumeAll = () => {
  uploadQueue.value.forEach(item => {
    if (item.paused && !item.isCompleted && !item.isCanceled) {
      handleItemUpload(item);
    }
  });
};

// 批量操作:取消所有文件上传
const handleCancelAll = async () => {
  for (const item of uploadQueue.value) {
    if (!item.isCompleted && !item.isCanceled) {
      await cancelUpload(item.fileHash);
      item.isCanceled = true;
      item.uploading = false;
      item.paused = false;
      item.message = '已取消上传';
      item.isError = true;
    }
  }
};

// 页面刷新时,恢复未完成的上传(断点续传核心:刷新页面不丢失进度)
const restoreUploadProgress = async () => {
  // 这里可根据实际需求,从localStorage读取未完成的文件信息(示例逻辑)
  const savedQueue = localStorage.getItem('uploadQueue');
  if (!savedQueue) return;

  const parsedQueue = JSON.parse(savedQueue);
  for (const savedItem of parsedQueue) {
    if (savedItem.isCompleted || savedItem.isCanceled) continue;

    // 重新读取文件(注:浏览器无法直接从哈希恢复文件,需用户重新选择,此处为示例)
    // 实际企业级场景可结合后端存储,通过哈希重新获取文件信息
    alert(`检测到未完成的上传:${savedItem.file.name},请重新选择该文件以恢复进度`);
  }
};

// 监听队列变化,保存到localStorage(刷新页面恢复进度)
watch(
  () => uploadQueue.value,
  (newQueue) => {
    // 只保存未完成、未取消的文件信息
    const savedQueue = newQueue.filter(item => !item.isCompleted && !item.isCanceled).map(item => ({
      fileHash: item.fileHash,
      file: { name: item.file.name, size: item.file.size },
      totalChunkCount: item.totalChunkCount,
      uploadedChunkCount: item.uploadedChunkCount,
      totalProgress: item.totalProgress,
      isCompleted: item.isCompleted,
      isCanceled: item.isCanceled
    }));
    localStorage.setItem('uploadQueue', JSON.stringify(savedQueue));
  },
  { deep: true }
);

// 页面初始化时,恢复未完成的上传
restoreUploadProgress();
</script>

三、后端实现(Node.js + Express)

适配前端所有企业级功能,新增取消上传接口,优化分片存储和合并逻辑,可直接运行:

1. 安装后端依赖

vbscript 复制代码
// 新建server文件夹,执行以下命令
mkdir server && cd server
npm init -y
npm install express cors fs-extra multer

2. 服务端代码(server.js)

ini 复制代码
const express = require('express');
const cors = require('cors');
const fs = require('fs-extra');
const path = require('path');
const multer = require('multer');

const app = express();
const PORT = 3000;

// 中间件配置(适配企业级跨域、请求解析)
app.use(cors({
  origin: '*', // 生产环境需替换为前端实际域名,提升安全性
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type']
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 存储目录配置(企业级建议挂载独立磁盘或云存储)
const UPLOAD_DIR = path.resolve(__dirname, 'upload'); // 最终文件存储目录
const CHUNK_DIR = path.resolve(__dirname, 'chunks'); // 临时分片存储目录

// 确保目录存在(不存在则创建)
fs.ensureDirSync(UPLOAD_DIR);
fs.ensureDirSync(CHUNK_DIR);

// multer配置(处理分片上传,临时存储分片)
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // 分片存储路径:chunks/文件哈希/分片索引(确保每个文件的分片独立存储)
    const fileHash = req.body.fileHash;
    const chunkPath = path.resolve(CHUNK_DIR, fileHash);
    fs.ensureDirSync(chunkPath); // 确保该文件的分片目录存在
    cb(null, chunkPath);
  },
  filename: (req, file, cb) => {
    // 分片文件名:分片索引(确保合并时顺序正确)
    cb(null, req.body.index);
  }
});

// 限制分片大小(略大于前端分片大小,避免接收失败)
const upload = multer({ 
  storage,
  limits: { fileSize: UPLOAD_CONFIG.chunkSize + 1024 * 100 } // 2MB + 100KB缓冲
});

// 配置前端分片大小(与前端保持一致)
const UPLOAD_CONFIG = {
  chunkSize: 2 * 1024 * 1024
};

// 接口1:校验文件(秒传、断点续传核心接口)
app.post('/check', async (req, res) => {
  try {
    const { fileHash, filename } = req.body;
    const ext = path.extname(filename); // 文件后缀(如.mp4、.zip)
    const finalFilePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);

    // 1. 秒传校验:文件已存在,直接返回成功
    if (await fs.pathExists(finalFilePath)) {
      return res.json({
        code: 0,
        msg: '文件已存在',
        isExist: true,
        uploadedChunks: []
      });
    }

    // 2. 断点续传校验:查询已上传的分片
    const chunkDir = path.resolve(CHUNK_DIR, fileHash);
    let uploadedChunks = [];
    if (await fs.pathExists(chunkDir)) {
      // 读取该文件的所有已上传分片(文件名即分片索引)
      uploadedChunks = await fs.readdir(chunkDir);
      // 转为数字类型,确保合并时顺序正确
      uploadedChunks = uploadedChunks.map(index => parseInt(index));
    }

    res.json({
      code: 0,
      msg: '文件校验成功',
      isExist: false,
      uploadedChunks
    });
  } catch (err) {
    res.status(500).json({
      code: 1,
      msg: `文件校验失败:${err.message}`,
      isExist: false,
      uploadedChunks: []
    });
  }
});

// 接口2:上传分片(支持失败自动重试,与前端重试逻辑配合)
app.post('/upload', upload.single('chunk'), async (req, res) => {
  try {
    // 前端传递的参数:fileHash(文件哈希)、index(分片索引)、total(总分片数)
    const { fileHash, index, total } = req.body;
    res.json({
      code: 0,
      msg: `分片${index}/${total}上传成功`
    });
  } catch (err) {
    res.status(500).json({
      code: 1,
      msg: `分片上传失败:${err.message}`
    });
  }
});

// 接口3:合并分片(所有分片上传完成后调用)
app.post('/merge', async (req, res) => {
  try {
    const { fileHash, filename } = req.body;
    const ext = path.extname(filename);
    const finalFilePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
    const chunkDir = path.resolve(CHUNK_DIR, fileHash);

    // 校验分片目录是否存在
    if (!await fs.pathExists(chunkDir)) {
      return res.status(400).json({
        code: 1,
        msg: '分片目录不存在,无法合并'
      });
    }

    // 读取所有分片,按索引排序(确保合并顺序正确)
    const chunks = (await fs.readdir(chunkDir)).sort((a, b) => parseInt(a) - parseInt(b));
    if (chunks.length === 0) {
      await fs.remove(chunkDir); // 删除空目录
      return res.status(400).json({
        code: 1,
        msg: '无分片数据,无法合并'
      });
    }

    // 合并所有分片(企业级优化:使用流合并,提升大文件合并效率)
    const writeStream = fs.createWriteStream(finalFilePath);
    for (const chunk of chunks) {
      const chunkPath = path.resolve(chunkDir, chunk);
      const readStream = fs.createReadStream(chunkPath);
      await new Promise(resolve => {
        readStream.pipe(writeStream, { end: false });
        readStream.on('end', resolve);
      });
      await fs.remove(chunkPath); // 合并后删除单个分片,节省空间
    }

    // 关闭写入流,删除分片目录
    writeStream.end();
    await fs.remove(chunkDir);

    res.json({
      code: 0,
      msg: '分片合并成功',
      filePath: finalFilePath // 可选:返回最终文件路径,用于前端下载
    });
  } catch (err) {
    res.status(500).json({
      code: 1,
      msg: `分片合并失败:${err.message}`
    });
  }
});

// 接口4:取消上传(新增,企业级必备功能)
app.post('/cancel', async (req, res) => {
  try {
    const { fileHash } = req.body;
    const chunkDir = path.resolve(CHUNK_DIR, fileHash);

    // 删除该文件的所有临时分片
    if (await fs.pathExists(chunkDir)) {
      await fs.remove(chunkDir);
    }

    res.json({
      code: 0,
      msg: '取消上传成功,已清理临时分片'
    });
  } catch (err) {
    res.status(500).json({
      code: 1,
      msg: `取消上传失败:${err.message}`
    });
  }
});

// 启动服务(企业级建议添加日志、进程守护)
app.listen(PORT, () => {
  console.log(`后端服务启动成功:http://localhost:${PORT}`);
  console.log(`最终文件存储目录:${UPLOAD_DIR}`);
  console.log(`临时分片存储目录:${CHUNK_DIR}`);
});

四、运行步骤(直接复制可跑)

  1. 启动后端:进入server文件夹,执行 node server.js,提示服务启动成功即可。
  2. 启动前端:将前端工具类和组件复制到Vue3项目,安装依赖后执行 npm run dev
  3. 测试功能:访问上传页面,测试多文件上传、队列管理、暂停/继续、取消、断点续传(刷新页面)、秒传(重复上传同一文件)、失败重试功能。

五、注意事项

  • 分片大小:固定为2MB,适配大多数企业场景,若需上传超大文件(10GB+),可调整为5MB,同时修改前后端配置保持一致。
  • 跨域配置:后端当前为允许所有域名跨域,生产环境需替换为前端实际域名(如xxx.com),提升安全性。
  • 存储优化:生产环境需将UPLOAD_DIR和CHUNK_DIR挂载到独立磁盘或云存储(如阿里云OSS、腾讯云COS),避免服务器磁盘占满。
  • 断点续传:页面刷新后,需用户重新选择未完成的文件,即可自动恢复上传进度;企业级可结合后端存储文件元信息,实现无需重新选择文件的恢复功能。
  • 失败重试:分片上传失败会自动重试3次(可配置),若仍失败,会自动暂停,用户可手动继续上传。
  • 浏览器兼容性:仅支持现代浏览器(Chrome、Edge、Firefox等),支持File.slice()方法,无需兼容旧浏览器(如IE)。
  • 队列管理:支持多文件排队上传,批量操作(开始/暂停/继续/取消),可根据企业需求添加队列排序功能。
相关推荐
~无忧花开~2 小时前
CSS全攻略:从基础到实战技巧
开发语言·前端·css·学习·css3
哈基不哈2 小时前
elpis学习笔记-工程化篇(webpack5)
前端
Misnice2 小时前
CSS Flex 布局中flex-shrink: 0 使用
前端·css
天才熊猫君2 小时前
容器与图片同步旋转:获取真实占位尺寸方案
前端·javascript·vue.js
骑自行车的码农2 小时前
React 是如何协调的 ?
前端
morethanilove2 小时前
小程序-添加粘性布局
开发语言·前端·javascript
We་ct2 小时前
HTML5 原生拖拽 API 实战案例与拓展避坑
前端·html·api·html5·拖拽
英俊潇洒美少年2 小时前
Vue2业务组件库生产级最佳实践:零依赖+依赖注入方案
前端·vue.js·重构
白日梦想家6812 小时前
定时器实战避坑+高级用法,从入门到精通
开发语言·前端·javascript