最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

Vue中大文件上傳企業(yè)級(jí)實(shí)現(xiàn)方案的完整代碼

 更新時(shí)間:2026年04月26日 09:25:04   作者:前端那點(diǎn)事  
在Vue項(xiàng)目中,大文件直接上傳會(huì)面臨請(qǐng)求超時(shí)、瀏覽器卡死等問(wèn)題,本文基于Vue3+Node.js實(shí)現(xiàn)企業(yè)級(jí)完整版,代碼精簡(jiǎn)可直接運(yùn)行,無(wú)復(fù)雜第三方依賴,有需要的可以了解下

在Vue項(xiàng)目中,大文件(通常指100MB以上)直接上傳會(huì)面臨請(qǐng)求超時(shí)、瀏覽器卡死等問(wèn)題,核心解決方案是「分片上傳」。本文基于Vue3+Node.js實(shí)現(xiàn)企業(yè)級(jí)完整版,包含斷點(diǎn)續(xù)傳、秒傳、失敗自動(dòng)重試、暫停/繼續(xù)、多文件上傳、取消上傳、上傳隊(duì)列管理所有核心功能,代碼精簡(jiǎn)可直接運(yùn)行,無(wú)復(fù)雜第三方依賴。

一、核心原理

將大文件按固定大?。?MB)切割成多個(gè)小分片,并發(fā)上傳分片,所有分片上傳完成后通知后端合并;通過(guò)文件哈希實(shí)現(xiàn)秒傳,通過(guò)校驗(yàn)已上傳分片實(shí)現(xiàn)斷點(diǎn)續(xù)傳,結(jié)合隊(duì)列管理實(shí)現(xiàn)多文件有序上傳,配套暫停/繼續(xù)、取消、失敗重試機(jī)制,適配企業(yè)級(jí)使用場(chǎng)景。

  • 秒傳:計(jì)算文件唯一哈希值,上傳前校驗(yàn)后端是否已存在該文件,存在則直接返回成功。
  • 斷點(diǎn)續(xù)傳:上傳前校驗(yàn)已上傳的分片,僅上傳未完成的分片;刷新頁(yè)面、斷網(wǎng)后重新上傳,可自動(dòng)恢復(fù)上傳進(jìn)度。
  • 隊(duì)列管理:多文件上傳時(shí),按選擇順序排隊(duì)上傳,支持調(diào)整隊(duì)列順序、刪除隊(duì)列文件。
  • 其他特性:失敗自動(dòng)重試、暫停/繼續(xù)上傳、單個(gè)/全部取消上傳,覆蓋企業(yè)級(jí)所有常見需求。

二、前端實(shí)現(xiàn)(Vue3 + 原生JS)

1. 依賴準(zhǔn)備

僅需2個(gè)基礎(chǔ)依賴,執(zhí)行命令安裝:

// 安裝axios(接口請(qǐng)求)、spark-md5(文件哈希計(jì)算)
npm install axios spark-md5

2. 工具類封裝(utils/upload.js)

封裝所有核心方法,包含失敗重試、分片處理、接口請(qǐng)求,可直接復(fù)用:

import SparkMD5 from 'spark-md5';
import axios from 'axios';

// 核心配置(可根據(jù)企業(yè)需求微調(diào))
export const UPLOAD_CONFIG = {
  chunkSize: 2 * 1024 * 1024, // 分片大小:2MB(適配大多數(shù)場(chǎng)景)
  baseUrl: 'http://localhost:3000', // 后端接口地址
  maxRetry: 3, // 分片失敗最大重試次數(shù)(企業(yè)級(jí)常用配置)
  concurrency: 3, // 并發(fā)上傳數(shù)量(避免請(qǐng)求過(guò)多壓垮服務(wù)器)
  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 // 單個(gè)分片進(jìn)度
    });
    current += UPLOAD_CONFIG.chunkSize;
  }
  return chunks;
}

// 計(jì)算文件哈希值(秒傳/斷點(diǎn)續(xù)傳校驗(yàn)用,優(yōu)化計(jì)算速度)
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()); // 計(jì)算完成,返回哈希值
        return;
      }
      // 讀取當(dāng)前分片(ArrayBuffer格式,計(jì)算哈希更高效)
      fileReader.readAsArrayBuffer(chunks[currentChunk].chunk);
      currentChunk++;
    };

    fileReader.onload = (e) => spark.append(e.target.result);
    fileReader.onloadend = loadNextChunk;
    fileReader.onerror = (err) => reject(`哈希計(jì)算失?。?{err.message}`);

    loadNextChunk(); // 開始讀取第一個(gè)分片
  });
}

// 校驗(yàn)文件(秒傳、斷點(diǎn)續(xù)傳核心接口)
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('文件校驗(yàn)失敗', err);
    return { isExist: false, uploadedChunks: [] };
  }
}

// 單個(gè)分片上傳(帶失敗自動(dòng)重試)
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) => {
        // 實(shí)時(shí)更新單個(gè)分片進(jìn)度
        chunkInfo.progress = (e.loaded / e.total) * 100;
      },
      timeout: 30000 // 超時(shí)時(shí)間30秒,適配大分片上傳
    });
    return res.data;
  } catch (err) {
    // 失敗自動(dòng)重試(未超過(guò)最大重試次數(shù))
    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}上傳失敗,已超過(guò)最大重試次數(shù)`);
  }
}

// 合并分片(所有分片上傳完成后調(diào)用)
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('分片合并失敗,請(qǐng)重試');
  }
}

// 取消上傳(刪除后端臨時(shí)分片)
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)

完整實(shí)現(xiàn)所有企業(yè)級(jí)功能,包含多文件上傳、隊(duì)列管理、暫停/繼續(xù)、取消、斷點(diǎn)續(xù)傳、秒傳、失敗重試,界面簡(jiǎn)潔貼合企業(yè)使用:

<template>
  <div style="padding: 30px; max-width: 1000px; margin: 0 auto">
    <h3>Vue大文件上傳(企業(yè)級(jí)完整版)</h3>
    <!-- 文件選擇(支持多文件) -->
    <div style="margin: 20px 0">
      <input
        type="file"
        @change="handleFileChange"
        multiple
        :disabled="isAllUploading"
      />
      <span style="margin-left: 10px; font-size: 14px; color: #666">
        支持多文件上傳,單個(gè)文件建議不超過(guò)10GB
      </span>
    </div>
    <!-- 上傳隊(duì)列管理 -->
    <div v-if="uploadQueue.length > 0" style="margin: 20px 0">
      <h4 style="margin-bottom: 10px">上傳隊(duì)列({{ uploadQueue.length }}個(gè)文件)</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>
          </div>
          <div>
            <!-- 隊(duì)列操作:刪除 -->
            <button 
              @click="removeFromQueue(index)"
              :disabled="item.uploading"
              style="margin-right: 10px; color: #f44336; border: none; background: transparent; cursor: pointer"
            >
              刪除
            </button>
            <!-- 上傳操作:暫停/繼續(xù)/取消 -->
            <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 ? '繼續(xù)' : '暫停' }}
            </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>
        </div>
        <!-- 單個(gè)文件進(jìn)度條 -->
        <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>進(jìn)度:{{ item.totalProgress.toFixed(2) }}%</span>
            <span>狀態(tài):{{ 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 }} 個(gè)分片
          </div>
        </div>
        <!-- 提示信息 -->
        <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 }}
        </div>
      </div>
    </div>
    <!-- 批量操作按鈕 -->
    <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"
      >
        繼續(xù)所有上傳
      </button>
      <button 
        @click="handleCancelAll"
        :disabled="isAllCompleted || isAllCanceled"
        style="border: none; padding: 6px 12px; border-radius: 4px; background: #f44336; color: #fff; cursor: pointer"
      >
        取消所有上傳
      </button>
    </div>
   <!-- 空隊(duì)列提示 -->
    <div v-if="uploadQueue.length === 0" style="padding: 20px; text-align: center; color: #666">
      暫無(wú)上傳文件,請(qǐng)選擇文件添加到隊(duì)列
    </div>
  </div>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import {
  createFileChunk,
  calculateFileHash,
  checkFile,
  uploadSingleChunk,
  mergeChunks,
  cancelUpload,
  UPLOAD_CONFIG
} from '@/utils/upload';
// 上傳隊(duì)列(多文件管理核心)
const uploadQueue = ref([]);
// 隊(duì)列狀態(tài)計(jì)算(批量操作使用)
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));
// 選擇多文件,添加到上傳隊(duì)列
const handleFileChange = async (e) => {
  const selectedFiles = e.target.files;
  if (!selectedFiles || selectedFiles.length === 0) return;
  // 遍歷選中的文件,添加到隊(duì)列(去重:相同文件哈希不重復(fù)添加)
  for (const file of selectedFiles) {
    // 先切割分片,計(jì)算哈希(用于去重和后續(xù)上傳)
    const chunks = createFileChunk(file);
    const fileHash = await calculateFileHash(file, chunks);
    // 去重:判斷隊(duì)列中是否已存在該文件(通過(guò)哈希值)
    const isExistInQueue = uploadQueue.value.some(item => item.fileHash === fileHash);
    if (isExistInQueue) {
      alert(`文件${file.name}已在上傳隊(duì)列中,無(wú)需重復(fù)添加`);
      continue;
    }
    // 添加到上傳隊(duì)列,初始化狀態(tài)
    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值,避免重復(fù)選擇同一文件
  e.target.value = '';
};
// 獲取文件狀態(tài)文本
const getStatusText = (item) => {
  if (item.isCompleted) return '上傳完成';
  if (item.isCanceled) return '已取消';
  if (item.uploading) return '上傳中';
  if (item.paused) return '已暫停';
  return '待上傳';
};
// 單個(gè)文件:開始/繼續(xù)上傳(核心方法,支持?jǐn)帱c(diǎn)續(xù)傳)
const handleItemUpload = async (item) => {
  if (item.uploading || item.isCompleted || item.isCanceled) return;
  try {
    item.uploading = true;
    item.paused = false;
    item.message = '準(zhǔn)備上傳(校驗(yàn)文件+計(jì)算哈希)...';
    // 1. 校驗(yàn)文件(秒傳、斷點(diǎn)續(xù)傳)
    const checkResult = await checkFile(item.fileHash, item.file.name);
    if (checkResult.isExist) {
      // 秒傳:文件已存在,直接標(biāo)記完成
      item.message = '文件已存在,秒傳成功!';
      item.isSuccess = true;
      item.isCompleted = true;
      item.totalProgress = 100;
      item.uploading = false;
      return;
    }
    // 2. 過(guò)濾已上傳分片(斷點(diǎn)續(xù)傳:刷新頁(yè)面/斷網(wǎng)后恢復(fù))
    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. 并發(fā)上傳未完成的分片(帶失敗自動(dòng)重試)
    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; // 失敗后自動(dòng)暫停,方便用戶重試
  } finally {
    item.uploading = false;
  }
};
// 并發(fā)上傳分片(控制并發(fā)數(shù)量,監(jiān)聽進(jìn)度)
const uploadChunksConcurrently = async (unUploadedChunks, item) => {
  // 給分片添加總分片數(shù),用于上傳接口
  const chunksWithMeta = unUploadedChunks.map(chunk => ({
    ...chunk,
    total: item.totalChunkCount
  }));
  // 監(jiān)聽分片進(jìn)度,更新文件總進(jìn)度
  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 }
  );
  // 并發(fā)控制:每次最多上傳UPLOAD_CONFIG.concurrency個(gè)分片
  for (let i = 0; i < chunksWithMeta.length; i += UPLOAD_CONFIG.concurrency) {
    // 暫停狀態(tài)時(shí),等待繼續(xù)上傳
    if (item.paused) {
      await new Promise(resolve => {
        const watcher = watch(() => item.paused, (newVal) => {
          if (!newVal) {
            watcher(); // 取消監(jiān)聽
            resolve();
          }
        });
      });
    }
    // 取消上傳時(shí),終止當(dāng)前批量上傳
    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;
  }
};
// 單個(gè)文件:暫停/繼續(xù)上傳
const handleItemPauseResume = (item) => {
  if (item.uploading) {
    // 暫停上傳
    item.paused = true;
    item.uploading = false;
    item.message = '上傳已暫停,點(diǎn)擊繼續(xù)可恢復(fù)';
  } else if (item.paused && !item.isCompleted && !item.isCanceled) {
    // 繼續(xù)上傳
    handleItemUpload(item);
  }
};
// 單個(gè)文件:取消上傳
const handleItemCancel = async (item) => {
  if (item.isCompleted || item.isCanceled) return;
  // 取消后端臨時(shí)分片
  await cancelUpload(item.fileHash);
  // 更新文件狀態(tài)
  item.isCanceled = true;
  item.uploading = false;
  item.paused = false;
  item.message = '已取消上傳';
  item.isError = true;
};
// 從隊(duì)列中刪除文件
const removeFromQueue = (index) => {
  const item = uploadQueue.value[index];
  if (item.uploading) {
    alert('當(dāng)前文件正在上傳,無(wú)法刪除,請(qǐng)先暫停或取消上傳');
    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 = '上傳已暫停,點(diǎn)擊繼續(xù)可恢復(fù)';
    }
  });
};
// 批量操作:繼續(xù)所有文件上傳
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;
    }
  }
};
// 頁(yè)面刷新時(shí),恢復(fù)未完成的上傳(斷點(diǎn)續(xù)傳核心:刷新頁(yè)面不丟失進(jìn)度)
const restoreUploadProgress = async () => {
  // 這里可根據(jù)實(shí)際需求,從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;
    // 重新讀取文件(注:瀏覽器無(wú)法直接從哈?;謴?fù)文件,需用戶重新選擇,此處為示例)
    // 實(shí)際企業(yè)級(jí)場(chǎng)景可結(jié)合后端存儲(chǔ),通過(guò)哈希重新獲取文件信息
    alert(`檢測(cè)到未完成的上傳:${savedItem.file.name},請(qǐng)重新選擇該文件以恢復(fù)進(jìn)度`);
  }
};
// 監(jiān)聽隊(duì)列變化,保存到localStorage(刷新頁(yè)面恢復(fù)進(jìn)度)
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 }
);
// 頁(yè)面初始化時(shí),恢復(fù)未完成的上傳
restoreUploadProgress();
</script>

三、后端實(shí)現(xiàn)(Node.js + Express)

適配前端所有企業(yè)級(jí)功能,新增取消上傳接口,優(yōu)化分片存儲(chǔ)和合并邏輯,可直接運(yùn)行:

1. 安裝后端依賴

// 新建server文件夾,執(zhí)行以下命令
mkdir server && cd server
npm init -y
npm install express cors fs-extra multer

2. 服務(wù)端代碼(server.js)

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;

// 中間件配置(適配企業(yè)級(jí)跨域、請(qǐng)求解析)
app.use(cors({
  origin: '*', // 生產(chǎn)環(huán)境需替換為前端實(shí)際域名,提升安全性
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type']
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 存儲(chǔ)目錄配置(企業(yè)級(jí)建議掛載獨(dú)立磁盤或云存儲(chǔ))
const UPLOAD_DIR = path.resolve(__dirname, 'upload'); // 最終文件存儲(chǔ)目錄
const CHUNK_DIR = path.resolve(__dirname, 'chunks'); // 臨時(shí)分片存儲(chǔ)目錄

// 確保目錄存在(不存在則創(chuàng)建)
fs.ensureDirSync(UPLOAD_DIR);
fs.ensureDirSync(CHUNK_DIR);

// multer配置(處理分片上傳,臨時(shí)存儲(chǔ)分片)
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // 分片存儲(chǔ)路徑:chunks/文件哈希/分片索引(確保每個(gè)文件的分片獨(dú)立存儲(chǔ))
    const fileHash = req.body.fileHash;
    const chunkPath = path.resolve(CHUNK_DIR, fileHash);
    fs.ensureDirSync(chunkPath); // 確保該文件的分片目錄存在
    cb(null, chunkPath);
  },
  filename: (req, file, cb) => {
    // 分片文件名:分片索引(確保合并時(shí)順序正確)
    cb(null, req.body.index);
  }
});

// 限制分片大?。源笥谇岸朔制笮?,避免接收失?。?
const upload = multer({ 
  storage,
  limits: { fileSize: UPLOAD_CONFIG.chunkSize + 1024 * 100 } // 2MB + 100KB緩沖
});

// 配置前端分片大?。ㄅc前端保持一致)
const UPLOAD_CONFIG = {
  chunkSize: 2 * 1024 * 1024
};

// 接口1:校驗(yàn)文件(秒傳、斷點(diǎn)續(xù)傳核心接口)
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. 秒傳校驗(yàn):文件已存在,直接返回成功
    if (await fs.pathExists(finalFilePath)) {
      return res.json({
        code: 0,
        msg: '文件已存在',
        isExist: true,
        uploadedChunks: []
      });
    }

    // 2. 斷點(diǎn)續(xù)傳校驗(yàn):查詢已上傳的分片
    const chunkDir = path.resolve(CHUNK_DIR, fileHash);
    let uploadedChunks = [];
    if (await fs.pathExists(chunkDir)) {
      // 讀取該文件的所有已上傳分片(文件名即分片索引)
      uploadedChunks = await fs.readdir(chunkDir);
      // 轉(zhuǎn)為數(shù)字類型,確保合并時(shí)順序正確
      uploadedChunks = uploadedChunks.map(index => parseInt(index));
    }

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

// 接口2:上傳分片(支持失敗自動(dòng)重試,與前端重試邏輯配合)
app.post('/upload', upload.single('chunk'), async (req, res) => {
  try {
    // 前端傳遞的參數(shù):fileHash(文件哈希)、index(分片索引)、total(總分片數(shù))
    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:合并分片(所有分片上傳完成后調(diào)用)
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);

    // 校驗(yàn)分片目錄是否存在
    if (!await fs.pathExists(chunkDir)) {
      return res.status(400).json({
        code: 1,
        msg: '分片目錄不存在,無(wú)法合并'
      });
    }

    // 讀取所有分片,按索引排序(確保合并順序正確)
    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: '無(wú)分片數(shù)據(jù),無(wú)法合并'
      });
    }

    // 合并所有分片(企業(yè)級(jí)優(yōu)化:使用流合并,提升大文件合并效率)
    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); // 合并后刪除單個(gè)分片,節(jié)省空間
    }

    // 關(guān)閉寫入流,刪除分片目錄
    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:取消上傳(新增,企業(yè)級(jí)必備功能)
app.post('/cancel', async (req, res) => {
  try {
    const { fileHash } = req.body;
    const chunkDir = path.resolve(CHUNK_DIR, fileHash);

    // 刪除該文件的所有臨時(shí)分片
    if (await fs.pathExists(chunkDir)) {
      await fs.remove(chunkDir);
    }

    res.json({
      code: 0,
      msg: '取消上傳成功,已清理臨時(shí)分片'
    });
  } catch (err) {
    res.status(500).json({
      code: 1,
      msg: `取消上傳失?。?{err.message}`
    });
  }
});

// 啟動(dòng)服務(wù)(企業(yè)級(jí)建議添加日志、進(jìn)程守護(hù))
app.listen(PORT, () => {
  console.log(`后端服務(wù)啟動(dòng)成功:http://localhost:${PORT}`);
  console.log(`最終文件存儲(chǔ)目錄:${UPLOAD_DIR}`);
  console.log(`臨時(shí)分片存儲(chǔ)目錄:${CHUNK_DIR}`);
});

四、運(yùn)行步驟(直接復(fù)制可跑)

  1. 啟動(dòng)后端:進(jìn)入server文件夾,執(zhí)行 node server.js,提示服務(wù)啟動(dòng)成功即可。
  2. 啟動(dòng)前端:將前端工具類和組件復(fù)制到Vue3項(xiàng)目,安裝依賴后執(zhí)行 npm run dev。
  3. 測(cè)試功能:訪問(wèn)上傳頁(yè)面,測(cè)試多文件上傳、隊(duì)列管理、暫停/繼續(xù)、取消、斷點(diǎn)續(xù)傳(刷新頁(yè)面)、秒傳(重復(fù)上傳同一文件)、失敗重試功能。

五、注意事項(xiàng)

  • 分片大小:固定為2MB,適配大多數(shù)企業(yè)場(chǎng)景,若需上傳超大文件(10GB+),可調(diào)整為5MB,同時(shí)修改前后端配置保持一致。
  • 跨域配置:后端當(dāng)前為允許所有域名跨域,生產(chǎn)環(huán)境需替換為前端實(shí)際域名(如xxx.com),提升安全性。
  • 存儲(chǔ)優(yōu)化:生產(chǎn)環(huán)境需將UPLOAD_DIR和CHUNK_DIR掛載到獨(dú)立磁盤或云存儲(chǔ)(如阿里云OSS、騰訊云COS),避免服務(wù)器磁盤占滿。
  • 斷點(diǎn)續(xù)傳:頁(yè)面刷新后,需用戶重新選擇未完成的文件,即可自動(dòng)恢復(fù)上傳進(jìn)度;企業(yè)級(jí)可結(jié)合后端存儲(chǔ)文件元信息,實(shí)現(xiàn)無(wú)需重新選擇文件的恢復(fù)功能。
  • 失敗重試:分片上傳失敗會(huì)自動(dòng)重試3次(可配置),若仍失敗,會(huì)自動(dòng)暫停,用戶可手動(dòng)繼續(xù)上傳。
  • 瀏覽器兼容性:僅支持現(xiàn)代瀏覽器(Chrome、Edge、Firefox等),支持File.slice()方法,無(wú)需兼容舊瀏覽器(如IE)。
  • 隊(duì)列管理:支持多文件排隊(duì)上傳,批量操作(開始/暫停/繼續(xù)/取消),可根據(jù)企業(yè)需求添加隊(duì)列排序功能。

到此這篇關(guān)于Vue中大文件上傳企業(yè)級(jí)實(shí)現(xiàn)方案的完整代碼的文章就介紹到這了,更多相關(guān)Vue大文件上傳方法內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!

相關(guān)文章

  • vue 翻頁(yè)組件vue-flip-page效果

    vue 翻頁(yè)組件vue-flip-page效果

    這篇文章主要介紹了vue 翻頁(yè)組件vue-flip-page,本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下
    2020-02-02
  • vue路由切換之淡入淡出的簡(jiǎn)單實(shí)現(xiàn)

    vue路由切換之淡入淡出的簡(jiǎn)單實(shí)現(xiàn)

    今天小編就為大家分享一篇vue路由切換之淡入淡出的簡(jiǎn)單實(shí)現(xiàn),具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧
    2019-10-10
  • Vue-scoped(局部)樣式使用方法及實(shí)例代碼

    Vue-scoped(局部)樣式使用方法及實(shí)例代碼

    這篇文章主要介紹了Vue-scoped(局部)樣式使用方法及實(shí)例代碼,文中示例代碼介紹了的非常詳細(xì)感興趣的同學(xué)可以參考閱讀一下
    2023-05-05
  • vue項(xiàng)目中做編輯功能傳遞數(shù)據(jù)時(shí)遇到問(wèn)題的解決方法

    vue項(xiàng)目中做編輯功能傳遞數(shù)據(jù)時(shí)遇到問(wèn)題的解決方法

    這篇文章主要介紹了vue項(xiàng)目中做編輯功能傳遞數(shù)據(jù)時(shí)遇到問(wèn)題的解決方法,vue父組件向子組件傳遞數(shù)據(jù)的問(wèn)題,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2016-12-12
  • Vue提示框組件vue-notification使用詳解

    Vue提示框組件vue-notification使用詳解

    這篇文章主要介紹了Vue提示框組件vue-notification使用詳解,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下
    2023-03-03
  • Vue3?-?setup?script的使用體驗(yàn)分享

    Vue3?-?setup?script的使用體驗(yàn)分享

    Vue3中的setup一種是setup函數(shù),一種是script setup,這篇文章主要給大家介紹了關(guān)于Vue3?-?setup?script使用的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),需要的朋友可以參考下
    2022-03-03
  • 關(guān)于element中el-cascader的使用方式

    關(guān)于element中el-cascader的使用方式

    這篇文章主要介紹了關(guān)于element中el-cascader的使用方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2022-08-08
  • Vue中的計(jì)算屬性computed傳參方式

    Vue中的計(jì)算屬性computed傳參方式

    這篇文章主要介紹了Vue中的計(jì)算屬性computed傳參方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2023-11-11
  • Vue項(xiàng)目中使用pdfjs-dist實(shí)現(xiàn)在線瀏覽PDF文件功能

    Vue項(xiàng)目中使用pdfjs-dist實(shí)現(xiàn)在線瀏覽PDF文件功能

    pdfjs-dist是一個(gè)用于在瀏覽器中渲染 PDF 文件的 JavaScript 庫(kù),它能夠?qū)?nbsp;PDF 文件解析并渲染為 HTML5 的 canvas 元素,從而實(shí)現(xiàn)跨平臺(tái)、無(wú)需插件的 PDF 預(yù)覽,本文給大家介紹了Vue項(xiàng)目中如何使用pdfjs-dist實(shí)現(xiàn)在線瀏覽PDF文件功能,需要的朋友可以參考下
    2025-08-08
  • Vue項(xiàng)目配置、切換主題顏色詳細(xì)教程(mixin+scss方式,簡(jiǎn)單高效)

    Vue項(xiàng)目配置、切換主題顏色詳細(xì)教程(mixin+scss方式,簡(jiǎn)單高效)

    這篇文章主要給大家介紹了關(guān)于Vue項(xiàng)目配置、切換主題顏色(mixin+scss方式)的相關(guān)資料,根據(jù)預(yù)設(shè)的配色方案,在前端實(shí)現(xiàn)動(dòng)態(tài)切換系統(tǒng)主題顏色,文中通過(guò)代碼示例介紹的非常詳細(xì),需要的朋友可以參考下
    2023-11-11

最新評(píng)論

台北市| 玉山县| 沈阳市| 霸州市| 会泽县| 武陟县| 晋宁县| 东至县| 巴青县| 大兴区| 鹤岗市| 富平县| 罗定市| 富源县| 海南省| 深水埗区| 石林| 侯马市| 曲松县| 德安县| 司法| 镶黄旗| 湖北省| 黔西| 桐梓县| 古田县| 通化市| 泰宁县| 越西县| 自贡市| 山东省| 吉首市| 长汀县| 新建县| 保山市| 楚雄市| 汪清县| 班玛县| 海原县| 宣汉县| 揭阳市|