.NET WebAPI 切片上传Demo

Server-side

/// <summary>
/// 上传片段
/// </summary>
/// <param name="uploadId"></param>
/// <param name="chunkIndex"></param>
/// <param name="chunk"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> UploadChunk(
[FromForm]string uploadId,
[FromForm]int chunkIndex,
[FromForm]IFormFile chunk)
{
    if (string.IsNullOrWhiteSpace(uploadId))
        return BadRequest("缺少 uploadId");

    if (chunk == null || chunk.Length == 0)
        return BadRequest("分片为空");

    var chunkDir = Path.Combine(_tempUploadingPath, uploadId);
    Directory.CreateDirectory(chunkDir);

    var chunkPath = Path.Combine(chunkDir, $"{chunkIndex}.part");

    if (System.IO.File.Exists(chunkPath))
        return Ok(new { message = "分片已存在" });

    await using var fs = new FileStream(chunkPath, FileMode.Create, FileAccess.Write);
    await chunk.CopyToAsync(fs);

    // 写日志
    _logger.LogWarning($"上传分片: uploadId={uploadId}, chunkIndex={chunkIndex}, 文件大小={chunk.Length},保存路径={chunkPath}");

    return Ok(new { message = "分片上传成功" });
}

/// <summary>
/// 合并片段
/// </summary>
/// <param name="uploadId"></param>
/// <param name="fileName"></param>
/// <param name="totalChunks"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> MergeChunks(
    [FromForm]string uploadId,
    [FromForm]string fileName,
    [FromForm]int totalChunks)
{
    var chunkDir = Path.Combine(_tempUploadingPath, uploadId);
    if (!Directory.Exists(chunkDir))
        return BadRequest("未找到上传记录");

    var safeFileName = Path.GetFileName(fileName);
    var finalPath = Path.Combine(_tempUploadingCompletedPath, safeFileName);

    await using (var finalStream = new FileStream(finalPath, FileMode.Create, FileAccess.Write, FileShare.None))
    {
        for (int i = 0; i < totalChunks; i++)
        {
            var chunkPath = Path.Combine(chunkDir, $"{i}.part");
            if (!System.IO.File.Exists(chunkPath))
                return BadRequest($"缺少分片 {i}");

            await using var chunkStream = new FileStream(chunkPath, FileMode.Open, FileAccess.Read, FileShare.Read);
            byte[] buffer = new byte[81920];
            int bytesRead;
            while ((bytesRead = await chunkStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
            {
                await finalStream.WriteAsync(buffer, 0, bytesRead);
            }
        }
    }

    // 删除分片文件夹
    Directory.Delete(chunkDir, true);

    using var fStream = System.IO.File.OpenRead(finalPath);
    using var memStream = new MemoryStream();
    await fStream.CopyToAsync(memStream);
    memStream.Position = 0;

    var wrapperStream = new TimeoutSupportedStream(memStream);

    var url = await _fileAppService.SaveBlobStreamAsync(new SaveBlobStreamDto(
        FileName: safeFileName,
        stream: wrapperStream,
        GenerateNewFileName:true
        ));

    fStream.Dispose();
    System.IO.File.Delete(finalPath);

    return Ok(new { message = "文件上传完成", url = $"{BlobConfiguration.HostUrl}{url}" });
}

/// <summary>
/// 片段是否存在
/// </summary>
/// <param name="uploadId">文件Id</param>
/// <param name="chunkIndex">片段索引</param>
/// <returns></returns>
[HttpGet]
public IActionResult CheckChunk(string uploadId, int chunkIndex)
{
    var chunkPath = Path.Combine(_tempUploadingPath, uploadId, $"{chunkIndex}.part");
    bool exists = System.IO.File.Exists(chunkPath);
    return Ok(new { exists });
}

public class TimeoutSupportedStream : Stream
{
    private readonly Stream _innerStream;

    public TimeoutSupportedStream(Stream innerStream)
    {
        _innerStream = innerStream;
    }

    public override bool CanRead => _innerStream.CanRead;
    public override bool CanSeek => _innerStream.CanSeek;
    public override bool CanWrite => _innerStream.CanWrite;
    public override long Length => _innerStream.Length;

    public override long Position
    {
        get => _innerStream.Position;
        set => _innerStream.Position = value;
    }

    public override int ReadTimeout
    {
        get => System.Threading.Timeout.Infinite; // 不抛异常,返回无限超时
        set { /* 不需要实现 */ }
    }

    public override int WriteTimeout
    {
        get => System.Threading.Timeout.Infinite;
        set { }
    }

    public override void Flush() => _innerStream.Flush();

    public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count);

    public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);

    public override void SetLength(long value) => _innerStream.SetLength(value);

    public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            _innerStream.Dispose();
        }
        base.Dispose(disposing);
    }
}

vue-side

<template>
  <div>
    <el-upload drag action="" :auto-upload="false" :file-list="fileList" :show-file-list="false"
      :on-change="handleFileChange" :on-remove="handleFileRemove" multiple>
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">拖拽文件到此处 或 <em>点击选择</em></div>
    </el-upload>
    <div style="width: 402px;">
      <div v-for="file in fileList" :key="file.uid" style="margin-top: 10px; position: relative; padding-right: 30px;">
        <div class="el-upload-list__item is-ready"
          style="display: flex; justify-content: space-between; align-items: center; font-size: 14px; position: relative;">
          <a class="el-upload-list__item-name" style="display: inline-flex; align-items: center;">
            <i class="el-icon-document" style="margin-right: 6px;"></i>{{ file.name }}
          </a>
          <i class="el-icon-close" style="cursor: pointer; color: #c0c4cc;" title="删除文件" @click="removeFile(file)"></i>
        </div>
        <div style="display: flex; align-items: center; margin-top: 4px;">
          <el-progress :percentage="file.progress || 0" style="flex: 1; margin-right: 8px;" />
          <span style="width: 24px; text-align: center;">
            <template v-if="file.status === 'done'">
              <i class="el-icon-circle-check" style="color: green; font-size: 18px;"></i>
            </template>
            <template v-else-if="file.status === 'error'">
              <i class="el-icon-circle-close" style="color: red; font-size: 18px;"></i>
            </template>
          </span>
        </div>
      </div>
    </div>

    <el-button type="primary" @click="onGetFilelist">Get fileList</el-button>
  </div>
</template>
<script>
import SparkMD5 from 'spark-md5'
import { Message } from 'element-ui'
import { UploadChunk, MergeChunks, CheckChunk } from '@/api/fileService'

export default {
  data() {
    return {
      chunkSize: 5 * 1024 * 1024,
      fileList: []
    }
  },
  methods: {
    onGetFilelist(){
      console.log(JSON.stringify(this.fileList))
    },
    // 当文件列表变化时触发
    handleFileChange(file, newFileList) {
      // 给新文件列表中每个文件加上进度和状态字段
      this.fileList = newFileList.map(f => ({
        ...f,
        progress: f.progress || 0,
        status: f.status || 'ready'  // ready 表示准备上传
      }))

      // 触发上传所有未上传的文件
      this.fileList.forEach(f => {
        if (f.status === 'ready' && f.raw) {
          this.uploadFileInChunks(f)
        }
      })
    },
    // 删除文件时更新列表
    handleFileRemove(file, newFileList) {
      this.fileList = newFileList
    },
    removeFile(file) {
      // 从 fileList 里移除该文件
      this.fileList = this.fileList.filter(f => f.uid !== file.uid)

      // 如果你实现了上传取消逻辑,这里也可以取消上传任务
    },
    async uploadFileInChunks(file) {
      // 找到这个文件的索引,方便更新进度
      const index = this.fileList.findIndex(f => f.uid === file.uid)
      if (index === -1) return

      this.fileList[index].status = 'uploading'
      this.fileList[index].progress = 0

      try {
        const fileMd5 = await this.calculateMd5(file.raw)
        const totalChunks = Math.ceil(file.raw.size / this.chunkSize)
        let uploadedChunks = 0

        for (let i = 0; i < totalChunks; i++) {
          // 先检查分片是否已上传
          const checkRes = await CheckChunk({ uploadId: fileMd5, chunkIndex: i })
          if (checkRes.data.exists) {
            uploadedChunks++
            this.fileList[index].progress = Math.round((uploadedChunks / totalChunks) * 100)
            continue
          }

          // 上传分片
          const chunk = file.raw.slice(i * this.chunkSize, (i + 1) * this.chunkSize)
          const formData = new FormData()
          formData.append('uploadId', fileMd5)
          formData.append('chunkIndex', i)
          formData.append('chunk', chunk)

          await UploadChunk(formData)
          uploadedChunks++
          this.fileList[index].progress = Math.round((uploadedChunks / totalChunks) * 100)
        }

        // 合并分片
        const mergeData = new FormData()
        mergeData.append('uploadId', fileMd5)
        mergeData.append('fileName', file.raw.name)
        mergeData.append('totalChunks', totalChunks)

        await MergeChunks(mergeData)

        this.fileList[index].status = 'done'
        this.fileList[index].progress = 100
        Message.success(`${file.name} 上传完成`)
      } catch (error) {
        console.error(error)
        this.fileList[index].status = 'error'
        this.fileList[index].progress = 0
        Message.error(`${file.name} 上传失败`)
      }
    },
    calculateMd5(file) {
      return new Promise((resolve, reject) => {
        const chunkSize = 2 * 1024 * 1024
        const chunks = Math.ceil(file.size / chunkSize)
        let currentChunk = 0
        const spark = new SparkMD5.ArrayBuffer()
        const fileReader = new FileReader()

        fileReader.onload = (e) => {
          spark.append(e.target.result)
          currentChunk++
          if (currentChunk < chunks) {
            loadNext()
          } else {
            resolve(spark.end())
          }
        }

        fileReader.onerror = () => reject('文件读取错误')

        function loadNext() {
          const start = currentChunk * chunkSize
          const end = Math.min(start + chunkSize, file.size)
          fileReader.readAsArrayBuffer(file.slice(start, end))
        }

        loadNext()
      })
    }
  }
}
</script>

Preview

切片上传.png
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

友情链接更多精彩内容