1.断点续传介绍
通常一些文件如视频文件体积都比较大,对于这些文件的上传需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。
什么是断点续传:
引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。
编辑
流程如下:
1、前端上传前先把文件分成块
2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传
3、各分块上传完成最后在服务端合并文件
2.MinIO大文件分块合并思路
2.1 整体思路
编辑
1、前端对文件进行分块。
2、前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传。
3、如果分块文件不存在则前端开始上传
4、前端请求媒资服务上传分块。
5、媒资服务将分块上传至MinIO。
6、前端将分块上传完毕请求媒资服务合并分块。
7、媒资服务判断分块上传完成则请求MinIO合并文件。
8、合并完成校验合并后的文件是否完整,如果完整则上传完成,否则删除文件
为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。
2.2 文件分块
文件分块的流程如下:
1、获取源文件长度
2、根据设定的分块文件的大小计算出块数
3、从源文件读数据依次向每一个块文件写数据。
本地将大文件分片:
//本地大文件切片 @Test public void splitLocalFile() throws Exception { //源大文件 File sourceFile=new File("D:\\dOWN\\ttt.zip"); //计算分块数量 long fileTotalSize=sourceFile.length(); long chunkSize=1024*1024*5; //每块5MB long chunkNum= (long) Math.ceil(fileTotalSize*1.0/chunkSize); try (RandomAccessFile rafRead = new RandomAccessFile(sourceFile, "r")) { // 1MB缓冲区(平衡IO次数和内存占用) byte[] buffer = new byte[1024 * 1024]; // 遍历生成每个分块 for (int i = 0; i < chunkNum; i++) { // 分块文件命名规则:数字(保证排序有序) File chunkFile = new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\testBigFile\\" + i); // 创建空分块文件 boolean createSuccess = chunkFile.createNewFile(); if (!createSuccess) { throw new IOException("分块文件创建失败:" + chunkFile.getAbsolutePath()); } // 写入分块数据(try-with-resources自动关闭写流) try (RandomAccessFile rafWrite = new RandomAccessFile(chunkFile, "rw")) { int readLen; // 单次读取的字节数 // 循环读取源文件数据,写入分块文件 while ((readLen = rafRead.read(buffer)) != -1) { rafWrite.write(buffer, 0, readLen); // 分块文件大小达到阈值则停止写入(避免单个分块超过5MB) if (chunkFile.length() >= chunkSize) { break; } } } } } }
编辑
本地分片上传到minio:
//将本地分片上传到minio @Test public void test() throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { minioClient = MinioClient.builder() .endpoint("http://127.0.0.1:9000") .credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm") .build(); // 1. 校验本地分块目录有效性 File chunkFolder=new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\testBigFile\\"); File[] chunkFiles=chunkFolder.listFiles(); //2.分块文件排序(按文件数字升序排序,保证上传顺序) List<File> chunkFileList=Arrays.asList(chunkFiles); chunkFileList.sort((f1, f2) -> { int num1 = Integer.parseInt(f1.getName()); int num2 = Integer.parseInt(f2.getName()); return Integer.compare(num1, num2); }); //3.逐个上传分块到minio for (File chunkFile : chunkFileList) { // MinIO中分块对象名:前缀+分块编号(如chunk/0) String chunkObjectName = "/chunk/" + chunkFile.getName(); // 构建上传参数 UploadObjectArgs uploadArgs = UploadObjectArgs.builder() .bucket("test") // 目标存储桶 .object(chunkObjectName) // 分块对象名 .filename(chunkFile.getAbsolutePath()) // 本地分块路径 .build(); // 执行上传(异常直接抛出) minioClient.uploadObject(uploadArgs); System.out.println("分块[" + chunkFile.getName() + "]上传成功,MinIO路径:" + chunkObjectName); } System.out.println("===== 所有分块上传完成 ====="); }
编辑
2.3 分片合并
合并minio中的文件分片并校验其合法性:
//minio中合并分片 @Test public void mergeChunksInMinio() throws Exception { minioClient = MinioClient.builder() .endpoint("http://127.0.0.1:9000") .credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm") .build(); // 1. 查询MinIO中的分块数量(避免硬编码分块数) ListObjectsArgs listArgs = ListObjectsArgs.builder() .bucket("test") .prefix("/chunk/") // 只查询分块前缀下的对象 .build(); Iterable<Result<io.minio.messages.Item>> chunkItems = minioClient.listObjects(listArgs); long chunkNum = 0; for (Result<Item> ignored : chunkItems) { chunkNum++; } if (chunkNum == 0) { throw new Exception("MinIO中未查询到分块文件,无法合并"); } System.out.println("===== 合并参数 ====="); System.out.println("待合并分块数:" + chunkNum); // 2. 构建分块源列表(按顺序关联每个分块) List<ComposeSource> composeSources = Stream.iterate(0, i -> i + 1) .limit(chunkNum) // 限制生成数量为分块总数 .map(i -> ComposeSource.builder() .bucket("test") // 分块所在桶 .object("/chunk/" + i) // 分块对象名 .build()) .collect(Collectors.toList()); // 3. 构建合并参数并执行合并 ComposeObjectArgs composeArgs = ComposeObjectArgs.builder() .bucket("test") // 合并后文件所在桶 .object("/merge/mergedFile") // 合并后文件名 .sources(composeSources) // 分块源列表 .build(); minioClient.composeObject(composeArgs); System.out.println("===== MinIO分块合并完成 ====="); // 4. 校验合并文件完整性(对比源文件MD5) validateMergeFile(); } /** * 辅助方法:校验合并后的文件完整性 * 逻辑:对比本地源文件和MinIO合并文件的MD5值 * 异常:直接抛出 */ private void validateMergeFile() throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { System.out.println("===== 开始校验合并文件 ====="); // 1. 下载MinIO合并后的文件到本地临时文件 File mergeTempFile = new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\merres"); if (mergeTempFile.exists()) { mergeTempFile.delete(); } DownloadObjectArgs downloadArgs = DownloadObjectArgs.builder() .bucket("test") .object("/merge/mergedFile") .filename(mergeTempFile.getAbsolutePath()) .build(); minioClient.downloadObject(downloadArgs); // 2. 计算源文件MD5 File sourceFile = new File("D:\\dOWN\\ttt.zip"); String sourceMd5; try (FileInputStream sourceIs = new FileInputStream(sourceFile)) { sourceMd5 = DigestUtils.md5Hex(sourceIs); } // 3. 计算合并文件MD5 String mergeMd5; try (FileInputStream mergeIs = new FileInputStream(mergeTempFile)) { mergeMd5 = DigestUtils.md5Hex(mergeIs); } // 4. 对比MD5 if (sourceMd5.equals(mergeMd5)) { System.out.println("文件校验成功!MD5值:" + sourceMd5); // 删除临时文件 mergeTempFile.delete(); } else { throw new IOException("文件校验失败!源文件MD5:" + sourceMd5 + ",合并文件MD5:" + mergeMd5); } System.out.println("===== 校验完成 ====="); }
编辑 编辑
清理MinIO中的文件分片(合并后执行):
//清理Minio中的文件分片(合并后执行) @Test public void cleanChunksInMinio() throws Exception { minioClient = MinioClient.builder() .endpoint("http://127.0.0.1:9000") .credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm") .build(); // 1. 查询待删除的分块列表 ListObjectsArgs listArgs = ListObjectsArgs.builder() .bucket("test") .prefix("/chunk/") .build(); Iterable<Result<Item>> chunkItems = minioClient.listObjects(listArgs); // 2. 构建删除对象列表 List<DeleteObject> deleteObjects = new ArrayList<>(); for (Result<Item> itemResult : chunkItems) { Item item = itemResult.get(); deleteObjects.add(new DeleteObject(item.objectName())); } if (deleteObjects.isEmpty()) { System.out.println("MinIO中无分块文件需要清理"); return; } // 3. 批量删除分块 System.out.println("===== 开始清理分块 ====="); RemoveObjectsArgs removeArgs = RemoveObjectsArgs.builder() .bucket("test") .objects(deleteObjects) // 待删除的分块列表 .build(); Iterable<Result<DeleteError>> deleteResults = minioClient.removeObjects(removeArgs); // 4. 遍历删除结果(捕获单个分块删除失败的异常) for (Result<DeleteError> deleteResult : deleteResults) { DeleteError error = deleteResult.get(); if (error != null) { throw new IOException("分块[" + error.objectName() + "]删除失败:" + error.message()); } } System.out.println("===== 分块清理完成 ====="); System.out.println("共删除分块数:" + deleteObjects.size()); }
3.大文件上传案例演示
3.1 整体思路实现
大文件分片上传方案基于 “前端分片 + 后端合并 + 断点续传 + 秒传” 核心设计,完整流程分为 前端预处理 → 校验阶段 → 分片上传 → 后端合并 → 播放 / 下载 五个核心阶段,前后端分工明确:
- 前端:负责文件切割、MD5 计算、分片上传(并发 + 断点)、进度回调;
- 后端:负责分片接收、MinIO 存储、分块校验、合并分块、MD5 验真、在线播放 / 下载支持。
3.2 后端接口设计
3.2.1 整体设计
文件上传:
编辑
文件合并:
编辑
3.2.2 接口设计
Controller:
import com.lgh.common.result.Result; import com.lgh.web.service.VedioService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; /** * 视频接口测试 * @Author GuihaoLv */ @RestController @RequestMapping("/web/vedio") @Tag(name="视频接口测试",description = "视频接口测试") @Slf4j public class VedioController { @Autowired private VedioService vedioService; /** * 文件上传前检查 * @param fileMd5 * @return */ @PostMapping("/upload/checkFile") @Operation(summary = "文件上传前检查") public Result<Boolean> checkFile(@RequestParam("fileMd5") String fileMd5) { Boolean exists = vedioService.checkFileExists(fileMd5); return Result.success(exists); } /** * 分块上传前检查 * @param fileMd5 * @param chunk * @return */ @PostMapping("/upload/checkChunk") @Operation(summary = "文件上传前检查") public Result<Boolean> checkChunk(@RequestParam("fileMd5") String fileMd5, @RequestParam("chunk")int chunk) { Boolean exists = vedioService.checkChunkExists(fileMd5,chunk); return Result.success(exists); } /** * 上传分块文件 * @param fileMd5 * @param chunk * @return */ @PostMapping("/upload/uploadChunk") @Operation(summary = "传分块文件") public Result<Boolean> uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("fileMd5") String fileMd5, @RequestParam("chunk")int chunk) { Boolean success = vedioService.uploadChunk(file, fileMd5, chunk); return Result.success(success); } /** * 合并分块文件 * @param fileMd5 * @param chunkTotal * @return */ @PostMapping("/upload/mergeChunk") @Operation(summary = "合并分块文件") public Result<Boolean> mergeChunk(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName, @RequestParam("chunkTotal") int chunkTotal) { Boolean success = vedioService.mergeChunk(fileMd5, fileName, chunkTotal); return Result.success(success); } }
Service:
import org.springframework.web.multipart.MultipartFile; public interface VedioService { /** * 文件上传前检查 * @param fileMd5 * @return */ Boolean checkFileExists(String fileMd5); /** * 检查分块是否存在 * @param fileMd5 * @param chunk * @return */ Boolean checkChunkExists(String fileMd5, int chunk); /** * 检查分块是否存在 * @param file * @param fileMd5 * @param chunk * @return */ Boolean uploadChunk(MultipartFile file, String fileMd5, int chunk); /** * 合并分块 * @param fileMd5 * @param fileName * @return */ Boolean mergeChunk(String fileMd5, String fileName, int chunkTotal); }
import com.lgh.common.properties.MinIoProperties; import com.lgh.web.service.VedioService; import io.minio.*; import io.minio.errors.MinioException; import io.minio.messages.DeleteError; import io.minio.messages.DeleteObject; import io.minio.messages.Item; import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.compress.utils.IOUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.*; import java.util.List; import java.util.ArrayList; import java.util.stream.Collectors; import java.util.stream.Stream; /** * 视频服务实现类 * @Author GuihaoLv */ @Service @Slf4j public class VedioServiceImpl implements VedioService { @Autowired private MinioClient minioClient; @Autowired private MinIoProperties minIoProperties; /** * 文件上传前检查 * @param fileMd5 * @return */ public Boolean checkFileExists(String fileMd5) { // 1. 构建完整文件在MinIO中的存储路径(与分块目录规则一致) String fullFilePath = getFullFilePath(fileMd5); try { // 2. 检查MinIO中是否存在该对象 StatObjectArgs statArgs = StatObjectArgs.builder() .bucket(minIoProperties.getBucketName()) .object(fullFilePath) .build(); minioClient.statObject(statArgs); log.info("文件已存在,MD5:{},MinIO路径:{}", fileMd5, fullFilePath); return true; } catch (MinioException e) { // MinIO返回对象不存在异常,说明文件未上传 if (e.getMessage().equals("NoSuchKey")) { log.info("文件不存在,MD5:{}", fileMd5); return false; } // 其他异常打印日志,返回false log.error("检查文件存在性失败,MD5:{}", fileMd5, e); return false; } catch (Exception e) { log.error("检查文件存在性异常,MD5:{}", fileMd5, e); return false; } } /** * 检查分块是否已存在 * 逻辑:拼接分块文件路径,检查MinIO中是否存在该分块对象 */ public Boolean checkChunkExists(String fileMd5, int chunk) { // 1. 构建分块文件在MinIO中的路径 String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk; try { // 2. 检查分块对象是否存在 StatObjectArgs statArgs = StatObjectArgs.builder() .bucket(minIoProperties.getBucketName()) .object(chunkFilePath) .build(); minioClient.statObject(statArgs); log.info("分块已存在,MD5:{},分块索引:{}", fileMd5, chunk); return true; } catch (MinioException e) { if (e.getMessage().equals("NoSuchKey")) { log.info("分块不存在,MD5:{},分块索引:{}", fileMd5, chunk); return false; } log.error("检查分块存在性失败,MD5:{},分块索引:{}", fileMd5, chunk, e); return false; } catch (Exception e) { log.error("检查分块存在性异常,MD5:{},分块索引:{}", fileMd5, chunk, e); return false; } } /** * 上传分块文件到MinIO * 逻辑:MultipartFile转InputStream,上传到分块指定路径 */ public Boolean uploadChunk(MultipartFile file, String fileMd5, int chunk) { // 1. 校验参数 if (file.isEmpty()) { log.error("上传分块失败,文件为空,MD5:{},分块索引:{}", fileMd5, chunk); return false; } // 2. 构建分块存储路径 String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk; try (InputStream inputStream = file.getInputStream()) { // 3. 上传分块到MinIO PutObjectArgs putArgs = PutObjectArgs.builder() .bucket(minIoProperties.getBucketName()) .object(chunkFilePath) .stream(inputStream, file.getSize(), -1) // -1表示自动检测文件大小 .contentType(file.getContentType()) .build(); minioClient.putObject(putArgs); log.info("分块上传成功,MD5:{},分块索引:{},路径:{}", fileMd5, chunk, chunkFilePath); return true; } catch (Exception e) { log.error("分块上传失败,MD5:{},分块索引:{}", fileMd5, chunk, e); return false; } } /** * 合并分块文件(优化版) * 核心逻辑:1. 有序构建分块列表 2. 合并文件 3. MD5校验 4. 清理分块 * @param fileMd5 文件唯一标识(MD5) * @param fileName 原始文件名(含扩展名) * @param chunkTotal 分块总数(新增参数:避免遍历MinIO获取分块,提升性能) * @return 合并是否成功 */ public Boolean mergeChunk(String fileMd5, String fileName, int chunkTotal) { // 1. 基础路径构建 String chunkFileFolderPath = getChunkFileFolderPath(fileMd5); //分块文件存储目录 String extName = fileName.substring(fileName.lastIndexOf(".")); // 提取文件扩展名 String mergeFilePath = getFilePathByMd5(fileMd5, extName); // 合并后文件路径 try { // 2. 有序构建分块源列表(参考代码核心逻辑:按索引生成,保证顺序) List<ComposeSource> sourceObjectList = Stream.iterate(0, i -> ++i) .limit(chunkTotal) .map(i -> ComposeSource.builder() .bucket(minIoProperties.getBucketName()) .object(chunkFileFolderPath.concat(Integer.toString(i))) .build()) .collect(Collectors.toList()); // 3. 执行MinIO分块合并 ObjectWriteResponse response = minioClient.composeObject( ComposeObjectArgs.builder() .bucket(minIoProperties.getBucketName()) .object(mergeFilePath) .sources(sourceObjectList) .build()); log.info("合并文件成功:{}", mergeFilePath); // 4. 下载合并后的文件,进行MD5校验(核心:保证文件完整性) File minioFile = downloadFileFromMinIO(minIoProperties.getBucketName(), mergeFilePath); if (minioFile == null) { log.error("下载合并后文件失败,mergeFilePath:{}", mergeFilePath); return false; } // 5. MD5校验逻辑 try (InputStream newFileInputStream = new FileInputStream(minioFile)) { String md5Hex = DigestUtils.md5Hex(newFileInputStream); // 比对MD5,不一致则返回失败 if (!fileMd5.equals(md5Hex)) { log.error("文件合并校验失败,MD5不一致:原始{},合并后{}", fileMd5, md5Hex); return false; } // 可选:此处可添加文件大小记录、入库等业务逻辑 log.info("文件MD5校验通过,MD5:{}", fileMd5); } // 6. 清理分块文件(参考代码的清理逻辑,增加容错) clearChunkFiles(chunkFileFolderPath, chunkTotal); // 7. 临时文件删除(finally中兜底) return true; } catch (Exception e) { log.error("合并文件失败,fileMd5:{},异常:{}", fileMd5, e.getMessage(), e); return false; } finally { // 兜底:删除临时文件(若存在) File minioFile = new File(System.getProperty("java.io.tmpdir"), "minio.merge"); if (minioFile.exists()) { minioFile.delete(); } } } /** * 从MinIO下载文件到本地临时文件(参考代码的downloadFileFromMinIO) * @param bucket 桶名 * @param objectName 对象路径 * @return 本地临时文件 */ private File downloadFileFromMinIO(String bucket, String objectName) { File minioFile = null; FileOutputStream outputStream = null; try { // 从MinIO获取文件流 InputStream stream = minioClient.getObject(GetObjectArgs.builder() .bucket(bucket) .object(objectName) .build()); // 创建临时文件 minioFile = File.createTempFile("minio", ".merge"); outputStream = new FileOutputStream(minioFile); IOUtils.copy(stream, outputStream); // 拷贝流到临时文件 return minioFile; } catch (Exception e) { log.error("下载MinIO文件失败,bucket:{},object:{}", bucket, objectName, e); return null; } finally { // 关闭流 if (outputStream != null) { try { outputStream.close(); } catch (IOException e) { log.error("关闭文件输出流失败", e); } } } } /** * 清除分块文件(参考代码的clearChunkFiles,优化异常处理) * @param chunkFileFolderPath 分块文件目录 * @param chunkTotal 分块总数 */ private void clearChunkFiles(String chunkFileFolderPath, int chunkTotal) { try { List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i) .limit(chunkTotal) .map(i -> new DeleteObject(chunkFileFolderPath.concat(Integer.toString(i)))) .collect(Collectors.toList()); RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder() .bucket(minIoProperties.getBucketName()) .objects(deleteObjects) .build(); Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs); results.forEach(r -> { try { DeleteError deleteError = r.get(); if (deleteError != null) { log.error("清除分块文件失败,objectname:{}", deleteError.objectName()); } } catch (Exception e) { log.error("遍历分块删除结果失败", e); } }); log.info("分块文件清理完成,共处理{}个分块", chunkTotal); } catch (Exception e) { log.error("清除分块文件失败,chunkFileFolderPath:{}", chunkFileFolderPath, e); } } // ------------------- 私有工具方法 ------------------- /** * 构建分块文件存储目录路径 * 规则:md5前两位拆分目录 + md5 + chunk/ * 示例:md5=abc123 → a/b/abc123/chunk/ */ private String getChunkFileFolderPath(String fileMd5) { return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/chunk/"; } /** * 构建完整文件存储路径(不带文件名) */ private String getFullFilePath(String fileMd5) { return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/"; } /** * 构建完整文件存储路径(带文件名) */ private String getFullFilePath(String fileMd5, String fileName) { return getFullFilePath(fileMd5) + fileName; } /** * 参考代码的getFilePathByMd5:构建合并后文件的完整路径 * @param fileMd5 文件MD5 * @param fileExt 文件扩展名(含.) * @return 完整路径 */ private String getFilePathByMd5(String fileMd5, String fileExt) { return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + fileExt; } }
3.3 前端设计
3.3.1 整体设计
编辑
3.3.2 请求及工具类封装
参数类型:
// src/types/upload.d.ts /** 合并分片参数类型 */ export interface MergeChunkParams { fileMd5: string; // 文件MD5 fileName: string; // 原始文件名 chunkTotal: number; // 总分片数 } /** 上传进度回调类型 */ export type ProgressCallback = (percent: number) => void;
请求函数:
// src/api/bigFileApi.ts import httpInstance from '@/utils/http'; // 导入类型(仅编译时使用,运行时无影响) import type { MergeChunkParams } from '@/types/upload'; /** * 检查文件是否已上传(秒传校验) * @param fileMd5 文件MD5 */ export const checkFileExists = (fileMd5: string) => { return httpInstance({ url: '/web/vedio/upload/checkFile', method: 'POST', params: { fileMd5 }, }); }; /** * 检查分片是否已上传(断点续传校验) * @param fileMd5 文件MD5 * @param chunk 分片索引 */ export const checkChunkExists = (fileMd5: string, chunk: number) => { return httpInstance({ url: '/web/vedio/upload/checkChunk', method: 'POST', params: { fileMd5, chunk }, }); }; /** * 上传分片文件 * @param formData 分片表单数据 * @param onProgress 单分片上传进度回调 */ export const uploadChunk = (formData: FormData, onProgress?: (progress: number) => void) => { return httpInstance({ url: '/web/vedio/upload/uploadChunk', method: 'POST', data: formData, headers: { 'Content-Type': 'multipart/form-data', }, onUploadProgress: (e) => { if (onProgress && e.total) { const percent = (e.loaded / e.total) * 100; onProgress(percent); } }, }); }; /** * 合并分片文件 * @param params 合并参数 */ export const mergeChunk = (params: MergeChunkParams) => { return httpInstance({ url: '/web/vedio/upload/mergeChunk', method: 'POST', params: params, }); }; /** * 普通文件上传(非分片) * @param data FormData数据 */ export const upload = (data: FormData) => { return httpInstance({ url: '/web/commonFile/upload', method: 'POST', data, headers: { 'Content-Type': 'multipart/form-data', }, }); }; /** * 文件下载 * @param fileName 文件名 */ export const downloadFile = (fileName: string) => { return httpInstance({ url: '/web/commonFile/download', method: 'POST', params: { fileName }, responseType: 'blob', }); };
大文件处理工具:
// src/utils/bigFileUpload.ts import SparkMD5 from 'spark-md5'; // 导入请求函数 + 类型(注意:type 关键字仅导入类型) import { checkFileExists, checkChunkExists, uploadChunk, mergeChunk } from '@/api/bigFileApi'; import type { MergeChunkParams, ProgressCallback } from '@/types/upload'; /** * 大文件分片上传核心工具类 * 依赖 bigFileApi 中的请求函数,专注于上传逻辑封装 */ export class BigFileUploader { // 分片大小(5MB,适配MinIO合并要求) private chunkSize: number = 5 * 1024 * 1024; // 待上传文件 private file: File | null = null; // 文件MD5 private fileMd5: string = ''; // 总分片数 private chunkTotal: number = 0; // 上传进度回调 private progressCallback: ProgressCallback | null = null; // 已上传分片索引 private uploadedChunks: number[] = []; /** * 构造函数 * @param file 待上传文件 * @param progressCallback 进度回调函数 */ constructor(file: File, progressCallback?: ProgressCallback) { this.file = file; this.progressCallback = progressCallback; // 初始化总分片数(向上取整) this.chunkTotal = Math.ceil(file.size / this.chunkSize); } /** * 私有方法:计算文件MD5(分片计算,避免大文件卡顿) */ private async calculateFileMd5(): Promise<string> { return new Promise((resolve) => { if (!this.file) return resolve(''); const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); const chunkSize = this.chunkSize; const fileSize = this.file.size; let offset = 0; // 逐片读取文件计算MD5 const loadNextChunk = () => { const end = Math.min(offset + chunkSize, fileSize); const chunk = this.file!.slice(offset, end); reader.readAsArrayBuffer(chunk); offset = end; }; reader.onload = (e) => { spark.append(e.target!.result as ArrayBuffer); // 触发MD5计算进度 this.progressCallback?.((offset / fileSize) * 100); if (offset < fileSize) { loadNextChunk(); } else { const md5 = spark.end(); this.fileMd5 = md5; resolve(md5); } }; loadNextChunk(); }); } /** * 私有方法:检查已上传的分片(断点续传) */ private async checkUploadedChunks(): Promise<void> { if (!this.fileMd5) return; this.uploadedChunks = []; // 遍历所有分片,检查是否已上传 for (let i = 0; i < this.chunkTotal; i++) { const res = await checkChunkExists(this.fileMd5, i); if (res.data) { // 后端返回true表示已上传 this.uploadedChunks.push(i); } } // 计算已上传进度并回调 const uploadedPercent = (this.uploadedChunks.length / this.chunkTotal) * 100; this.progressCallback?.(uploadedPercent); console.log(`[断点续传] 已上传分片数:${this.uploadedChunks.length}/${this.chunkTotal}`); } /** * 私有方法:上传单个分片 * @param chunkIndex 分片索引 */ private async uploadSingleChunk(chunkIndex: number): Promise<boolean> { if (!this.file || !this.fileMd5) return false; // 1. 切割分片文件 const start = chunkIndex * this.chunkSize; const end = Math.min(start + this.chunkSize, this.file.size); const chunk = this.file.slice(start, end); // 2. 构建FormData const formData = new FormData(); formData.append('file', chunk); formData.append('fileMd5', this.fileMd5); formData.append('chunk', chunkIndex.toString()); try { // 3. 上传分片(监听单分片进度) await uploadChunk(formData, (chunkProgress) => { // 计算整体进度 = 已上传分片占比 + 当前分片上传进度占比 const basePercent = (this.uploadedChunks.length / this.chunkTotal) * 100; const currentChunkPercent = (chunkProgress / 100) * (1 / this.chunkTotal) * 100; this.progressCallback?.(basePercent + currentChunkPercent); }); return true; } catch (error) { console.error(`[分片上传失败] 索引:${chunkIndex}`, error); return false; } } /** * 私有方法:批量上传分片(并发控制) * @param concurrency 并发数(默认3) */ private async uploadChunks(concurrency: number = 3): Promise<void> { if (!this.fileMd5) return; // 筛选需要上传的分片(排除已上传的) const needUploadChunks = Array.from({ length: this.chunkTotal }, (_, i) => i) .filter(i => !this.uploadedChunks.includes(i)); if (needUploadChunks.length === 0) { this.progressCallback?.(100); return; } // 并发上传控制(避免请求过多) let currentIndex = 0; let completedChunks = 0; const uploadNext = async () => { if (currentIndex >= needUploadChunks.length) return; const chunkIndex = needUploadChunks[currentIndex]; currentIndex++; // 上传单个分片(失败重试) const uploadSuccess = await this.uploadSingleChunk(chunkIndex); if (uploadSuccess) { completedChunks++; // 更新整体进度 const totalPercent = ((this.uploadedChunks.length + completedChunks) / this.chunkTotal) * 100; this.progressCallback?.(totalPercent); } else { // 失败后重新入队(可限制重试次数) currentIndex--; console.log(`[分片重试] 索引:${chunkIndex}`); } // 继续上传下一个 await uploadNext(); }; // 启动指定数量的并发任务 const tasks = Array.from({ length: concurrency }, uploadNext); await Promise.all(tasks); } /** * 私有方法:合并分片文件 */ private async mergeFileChunks(): Promise<boolean> { if (!this.fileMd5 || !this.file) return false; const mergeParams: MergeChunkParams = { fileMd5: this.fileMd5, fileName: this.file.name, chunkTotal: this.chunkTotal, }; try { const res = await mergeChunk(mergeParams); return res.data; } catch (error) { console.error('[分片合并失败]', error); return false; } } /** * 核心方法:执行完整上传流程 * 流程:计算MD5 → 秒传校验 → 断点续传校验 → 上传分片 → 合并分片 */ public async upload(): Promise<boolean> { if (!this.file) { console.error('[上传失败] 文件为空'); return false; } try { // 1. 初始化进度 this.progressCallback?.(0); // 2. 计算文件MD5 console.log('[开始计算MD5] 大文件MD5计算中...'); await this.calculateFileMd5(); console.log(`[MD5计算完成] ${this.fileMd5}`); // 3. 秒传校验(文件已存在则直接返回成功) const fileCheckRes = await checkFileExists(this.fileMd5); if (fileCheckRes.data) { this.progressCallback?.(100); console.log('[秒传成功] 文件已存在,无需上传'); return true; } // 4. 断点续传校验(检查已上传分片) await this.checkUploadedChunks(); // 5. 上传剩余分片 console.log('[开始上传分片] 共需上传:', this.chunkTotal - this.uploadedChunks.length, '个'); await this.uploadChunks(3); // 6. 合并分片 console.log('[开始合并分片]'); const mergeSuccess = await this.mergeFileChunks(); if (mergeSuccess) { this.progressCallback?.(100); console.log('[上传完成] 文件上传并合并成功'); return true; } else { console.error('[上传失败] 分片合并失败'); return false; } } catch (error) { console.error('[上传异常]', error); return false; } } }
3.3.3 测试页面
<template> <div class="upload-container"> <h3>大文件分片上传(Vue3 + TS)</h3> <!-- 文件选择 --> <input type="file" ref="fileInput" @change="handleFileSelect" class="file-input" /> <!-- 上传按钮 --> <button @click="handleUpload" :disabled="!selectedFile || isUploading" class="upload-btn" > {{ isUploading ? '上传中...' : '开始上传' }} </button> <!-- 进度条 --> <div class="progress-container" v-if="selectedFile"> <div class="progress-bar" :style="{ width: `${uploadPercent}%` }"></div> <span class="progress-text">{{ uploadPercent.toFixed(2) }}%</span> </div> </div> </template> <script setup lang="ts"> import { ref } from 'vue'; import { BigFileUploader } from '@/utils/bigFileUpload'; // 响应式数据 const fileInput = ref<HTMLInputElement | null>(null); const selectedFile = ref<File | null>(null); const isUploading = ref<boolean>(false); const uploadPercent = ref<number>(0); /** * 选择文件回调 */ const handleFileSelect = (e: Event) => { const target = e.target as HTMLInputElement; if (target.files && target.files[0]) { selectedFile.value = target.files[0]; uploadPercent.value = 0; // 重置进度 } }; /** * 执行上传 */ const handleUpload = async () => { if (!selectedFile.value) return; isUploading.value = true; try { // 实例化上传工具,传入进度回调 const uploader = new BigFileUploader( selectedFile.value, (percent) => { uploadPercent.value = percent; } ); // 执行上传 const success = await uploader.upload(); if (success) { alert('文件上传成功!'); } else { alert('文件上传失败!'); } } catch (error) { console.error('[页面上传异常]', error); alert('上传出错,请重试!'); } finally { isUploading.value = false; // 清空文件选择 if (fileInput.value) fileInput.value.value = ''; selectedFile.value = null; } }; </script> <style scoped> .upload-container { width: 600px; margin: 50px auto; padding: 20px; border: 1px solid #eee; border-radius: 8px; } .file-input { margin-bottom: 20px; padding: 8px; width: 100%; } .upload-btn { padding: 10px 24px; background: #409eff; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .upload-btn:disabled { background: #ccc; cursor: not-allowed; } .progress-container { margin-top: 20px; height: 24px; border: 1px solid #e6e6e6; border-radius: 12px; overflow: hidden; position: relative; } .progress-bar { height: 100%; background: #409eff; transition: width 0.3s ease; } .progress-text { position: absolute; top: 0; left: 50%; transform: translateX(-50%); font-size: 12px; color: #333; line-height: 24px; } </style>
编辑
编辑
4.大文件下载案例演示
分片下载核心是 「前端拆范围、后端读片段、前端合文件」,完全复用上传阶段的 MD5 / 文件路径规则,解决大文件单次下载超时、内存溢出、中断后重传的问题。
4.1 整体思路
后端设计:
编辑
前端设计:
编辑
4.2 后端实现
controller:
/** * 大文件分片下载接口(支持Range字节范围请求) * @param fileMd5 文件MD5(定位MinIO文件) * @param fileName 文件名(含扩展名,用于拼接路径) * @param request 获取Range请求头 * @param response 返回分片流+响应头 */ @PostMapping("/download/largeFile") @Operation(summary = "大文件分片下载") public void downloadLargeFile(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName, HttpServletRequest request, HttpServletResponse response) { try { // 调用服务层分片下载逻辑 vedioService.downloadLargeFile(fileMd5, fileName, request, response); } catch (Exception e) { log.error("大文件分片下载失败,fileMd5:{}", fileMd5, e); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } /** * 辅助接口:获取文件总大小(前端初始化分片下载时调用) */ @PostMapping("/download/getFileSize") @Operation(summary = "获取文件总大小") public Result<Long> getFileSize(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { Long res=vedioService.getFileSize(fileMd5, fileName); return Result.success(res); }
Service:
/** * 下载大文件 * @param fileMd5 * @param fileName * @param request * @param response */ void downloadLargeFile(String fileMd5, String fileName, HttpServletRequest request, HttpServletResponse response) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException; /** * 获取文件大小 * @param fileMd5 * @param fileName */ Long getFileSize(String fileMd5, String fileName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException;
/** * 大文件分片下载核心逻辑 * 支持Range请求,返回指定字节范围的文件流 */ public void downloadLargeFile(String fileMd5, String fileName, HttpServletRequest request, HttpServletResponse response) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { // 1. 构建MinIO中完整文件路径(复用现有路径规则) String extName = fileName.substring(fileName.lastIndexOf(".")); String mergeFilePath = getFilePathByMd5(fileMd5, extName); String bucket = minIoProperties.getBucketName(); // 2. 获取文件元信息(总大小、Content-Type) StatObjectArgs statArgs = StatObjectArgs.builder() .bucket(bucket) .object(mergeFilePath) .build(); StatObjectResponse statResponse = minioClient.statObject(statArgs); long fileTotalSize = statResponse.size(); String contentType = statResponse.contentType(); // 3. 解析前端Range请求头(格式:Range: bytes=0-4999999) String rangeHeader = request.getHeader("Range"); long start = 0; long end = fileTotalSize - 1; if (rangeHeader != null && rangeHeader.startsWith("bytes=")) { // 拆分Range参数 String[] rangeParts = rangeHeader.replace("bytes=", "").split("-"); start = Long.parseLong(rangeParts[0]); // 处理结束字节:前端传了则用前端值,否则取文件末尾 if (rangeParts.length > 1 && !rangeParts[1].isEmpty()) { end = Long.parseLong(rangeParts[1]); } // 校验Range有效性 if (start < 0 || end >= fileTotalSize || start > end) { response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); response.setHeader("Content-Range", "bytes */" + fileTotalSize); return; } // 响应部分内容(206) response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileTotalSize); } // 4. 设置下载响应头 response.setHeader("Content-Type", contentType); response.setHeader("Content-Length", String.valueOf(end - start + 1)); // 当前分片大小 response.setHeader("Accept-Ranges", "bytes"); // 告知前端支持分片下载 // 触发浏览器下载(指定文件名,解决中文乱码) response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()) + "\""); response.setHeader("Cache-Control", "no-cache"); // 5. 从MinIO读取指定字节范围的文件流并返回(核心修正:兼容所有SDK版本) InputStream stream = null; InputStream fullStream = null; try { // 第一步:获取完整文件流(放弃SDK的extraHeader,手动处理Range) GetObjectArgs getArgs = GetObjectArgs.builder() .bucket(bucket) .object(mergeFilePath) .build(); fullStream = minioClient.getObject(getArgs); // 第二步:手动截取指定字节范围的流(跳过start字节,读取end-start+1字节) // 方式1:适合中小文件(<1GB),简单直接 byte[] fullBytes = IOUtils.toByteArray(fullStream); byte[] rangeBytes = new byte[(int) (end - start + 1)]; System.arraycopy(fullBytes, (int) start, rangeBytes, 0, rangeBytes.length); stream = new ByteArrayInputStream(rangeBytes); // 6. 流式返回(避免内存溢出) IOUtils.copy(stream, response.getOutputStream()); response.getOutputStream().flush(); } finally { // 兜底关闭流,防止资源泄漏 if (stream != null) { stream.close(); } if (fullStream != null) { fullStream.close(); } } } /** *获取文件大小 * @throws InternalException */ public Long getFileSize(String fileMd5, String fileName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException { String extName = fileName.substring(fileName.lastIndexOf(".")); String mergeFilePath = getFilePathByMd5(fileMd5, extName); StatObjectResponse statResponse = minioClient.statObject(StatObjectArgs.builder() .bucket(minIoProperties.getBucketName()) .object(mergeFilePath) .build()); return statResponse.size(); }
4.3 前端实现
请求函数:
/** * 获取文件总大小(分片下载前置) * @param fileMd5 文件MD5 * @param fileName 文件名 */ export const getFileSize = (fileMd5: string, fileName: string) => { return httpInstance({ url: '/web/vedio/download/getFileSize', method: 'POST', params: { fileMd5, fileName }, }); }; /** * 分片下载文件片段 * @param fileMd5 文件MD5 * @param fileName 文件名 * @param start 起始字节 * @param end 结束字节 * @param onProgress 分片下载进度回调 */ export const downloadFileChunk = ( fileMd5: string, fileName: string, start: number, end: number, onProgress?: (progress: number) => void ) => { return httpInstance({ url: '/web/vedio/download/largeFile', method: 'POST', params: { fileMd5, fileName }, headers: { 'Range': `bytes=${start}-${end}`, // 核心:指定字节范围 }, responseType: 'blob', // 接收二进制流 onDownloadProgress: (e) => { if (onProgress && e.total) { const percent = (e.loaded / e.total) * 100; onProgress(percent); } }, }); };
大文件下载工具:
import { getFileSize, downloadFileChunk } from '@/api/bigFileApi'; /** * 大文件分片下载工具类 * 适配现有上传逻辑的MD5/文件名规则 */ export class BigFileDownloader { // 分片大小(5MB,与上传分片大小一致) private chunkSize: number = 5 * 1024 * 1024; // 文件MD5 private fileMd5: string = ''; // 文件名(含扩展名) private fileName: string = ''; // 文件总大小 private fileTotalSize: number = 0; // 总分片数 private chunkTotal: number = 0; // 下载进度回调 private progressCallback: ((percent: number) => void) | null = null; // 已下载的分片二进制数据 private downloadedChunks: Blob[] = []; /** * 构造函数 * @param fileMd5 文件MD5 * @param fileName 文件名 * @param progressCallback 进度回调 */ constructor(fileMd5: string, fileName: string, progressCallback?: (percent: number) => void) { this.fileMd5 = fileMd5; this.fileName = fileName; this.progressCallback = progressCallback; } /** * 初始化:获取文件总大小,计算总分片数 */ private async init(): Promise<boolean> { try { const res = await getFileSize(this.fileMd5, this.fileName); this.fileTotalSize = res.data; this.chunkTotal = Math.ceil(this.fileTotalSize / this.chunkSize); this.progressCallback?.(0); return true; } catch (error) { console.error('[下载初始化失败] 获取文件大小失败', error); return false; } } /** * 下载单个分片 * @param chunkIndex 分片索引 */ private async downloadSingleChunk(chunkIndex: number): Promise<boolean> { const start = chunkIndex * this.chunkSize; const end = Math.min(start + this.chunkSize - 1, this.fileTotalSize - 1); try { const res = await downloadFileChunk( this.fileMd5, this.fileName, start, end, (chunkProgress) => { // 计算整体进度:已下载分片占比 + 当前分片下载进度占比 const basePercent = (this.downloadedChunks.length / this.chunkTotal) * 100; const currentChunkPercent = (chunkProgress / 100) * (1 / this.chunkTotal) * 100; this.progressCallback?.(basePercent + currentChunkPercent); } ); // 保存分片二进制数据 this.downloadedChunks[chunkIndex] = res.data; return true; } catch (error) { console.error(`[分片下载失败] 索引:${chunkIndex}`, error); return false; } } /** * 并发下载分片(控制并发数) * @param concurrency 并发数(默认3) */ private async downloadChunks(concurrency: number = 3): Promise<boolean> { // 生成所有分片索引 const chunkIndexes = Array.from({ length: this.chunkTotal }, (_, i) => i); let currentIndex = 0; let completedCount = 0; // 递归下载单个分片(失败重试) const downloadNext = async () => { if (currentIndex >= chunkIndexes.length) return; const index = chunkIndexes[currentIndex]; currentIndex++; // 重试3次 let retryCount = 3; let success = false; while (retryCount > 0 && !success) { success = await this.downloadSingleChunk(index); if (!success) { retryCount--; console.log(`[分片重试] 索引:${index},剩余重试次数:${retryCount}`); } } if (success) { completedCount++; // 更新整体进度 const totalPercent = (completedCount / this.chunkTotal) * 100; this.progressCallback?.(totalPercent); } else { throw new Error(`分片${index}下载失败,已达最大重试次数`); } await downloadNext(); }; // 启动并发任务 const tasks = Array.from({ length: concurrency }, downloadNext); try { await Promise.all(tasks); return true; } catch (error) { console.error('[分片下载异常]', error); return false; } } /** * 合并分片并触发下载 */ private mergeAndDownload(): void { // 合并所有分片为完整Blob const completeBlob = new Blob(this.downloadedChunks, { type: this.getFileType(this.fileName), }); // 创建下载链接 const downloadUrl = URL.createObjectURL(completeBlob); const a = document.createElement('a'); a.href = downloadUrl; a.download = this.fileName; // 指定下载文件名 a.click(); // 释放内存 URL.revokeObjectURL(downloadUrl); this.progressCallback?.(100); console.log('[下载完成] 文件已合并并触发下载'); } /** * 辅助:根据文件名获取文件类型 */ private getFileType(fileName: string): string { const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase(); const typeMap: Record<string, string> = { '.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.pdf': 'application/pdf', '.zip': 'application/zip', '.txt': 'text/plain', '.jpg': 'image/jpeg', '.png': 'image/png', }; return typeMap[ext] || 'application/octet-stream'; } /** * 核心方法:执行完整分片下载流程 */ public async download(): Promise<boolean> { try { // 1. 初始化(获取文件大小) const initSuccess = await this.init(); if (!initSuccess) return false; // 2. 下载所有分片 const downloadSuccess = await this.downloadChunks(3); if (!downloadSuccess) return false; // 3. 合并并触发下载 this.mergeAndDownload(); return true; } catch (error) { console.error('[下载流程异常]', error); return false; } } }
测试页面:
<!--大文件下载--> <template> <div class="download-container"> <h3>大文件分片下载</h3> <!-- 假设已获取文件MD5和文件名 --> <button @click="handleDownload" :disabled="isDownloading"> {{ isDownloading ? '下载中...' : '开始下载' }} </button> <div class="progress-container" v-if="isDownloading"> <div class="progress-bar" :style="{ width: `${downloadPercent}%` }"></div> <span class="progress-text">{{ downloadPercent.toFixed(2) }}%</span> </div> </div> </template> <script setup lang="ts"> import { ref } from 'vue'; import { BigFileDownloader } from '@/utils/BigFileDownloader.ts'; // 模拟已上传文件的MD5和文件名(实际从后端获取) const fileMd5 = '6a8141f0af53be5d54610499e7c696d1'; // 替换为实际MD5 const fileName = '6a8141f0af53be5d54610499e7c696d1.zip'; // 替换为实际文件名 // 响应式数据 const isDownloading = ref(false); const downloadPercent = ref(0); /** * 执行分片下载 */ const handleDownload = async () => { isDownloading.value = true; downloadPercent.value = 0; // 实例化下载工具类 const downloader = new BigFileDownloader( fileMd5, fileName, (percent) => { downloadPercent.value = percent; } ); try { const success = await downloader.download(); if (!success) { alert('文件下载失败!'); } else { alert('文件下载成功!'); } } catch (error) { console.error('[页面下载异常]', error); alert('下载出错,请重试!'); } finally { isDownloading.value = false; } }; </script> <style scoped> .download-container { width: 600px; margin: 50px auto; padding: 20px; border: 1px solid #eee; border-radius: 8px; } button { padding: 10px 24px; background: #409eff; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } button:disabled { background: #ccc; cursor: not-allowed; } .progress-container { margin-top: 20px; height: 24px; border: 1px solid #e6e6e6; border-radius: 12px; overflow: hidden; position: relative; } .progress-bar { height: 100%; background: #409eff; transition: width 0.3s ease; } .progress-text { position: absolute; top: 0; left: 50%; transform: translateX(-50%); font-size: 12px; color: #333; line-height: 24px; } </style>
编辑