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