全国真做注塑的工厂只有 8173 家:B2B 销售名单的 4 道反常识过滤
2026/5/7 6:27:05
最近接了个"地狱级"外包:客户要我用原生JS实现20G文件夹上传/下载,还要兼容IE9!我摸着所剩无几的头发,看着100元预算,陷入了沉思…
客户核心需求:
// 前端AES加密(兼容IE9)functionencryptAES(data,key){// 兼容IE9的crypto-js降级方案if(typeofCryptoJS==='undefined'){alert('请安装crypto-js插件!');returndata;}returnCryptoJS.AES.encrypt(data,key).toString();}// 递归扫描文件夹(IE9兼容版)functionscanFolder(entry,pathMap){if(entry.isFile){entry.file(file=>{constrelativePath=pathMap.join('/')+'/'+file.name;uploadFile(file,relativePath);// 调用分片上传});}elseif(entry.isDirectory){constdirReader=entry.createReader();dirReader.readEntries(entries=>{constnewPath=[...pathMap,entry.name];entries.forEach(e=>scanFolder(e,newPath));});}}// 初始化文件夹选择(IE9兼容)document.getElementById('folderInput').addEventListener('change',e=>{constfiles=e.target.files;if(files.length===0)return;// IE9的特殊处理if(window.FileReader&&!window.FileEntry){alert('请使用Chrome/Firefox上传文件夹!');return;}// 现代浏览器if(files[0].webkitRelativePath){constrootPath=files[0].webkitRelativePath.split('/')[0];Array.from(files).forEach(file=>{constpath=file.webkitRelativePath.replace(rootPath,'');uploadFile(file,path);});}// 通过input[webkitdirectory]选择elseif(e.target.webkitEntries){Array.from(e.target.webkitEntries).forEach(entry=>{scanFolder(entry,[]);});}});// 上传文件分片asyncfunctionuploadChunk(file,chunkIndex,totalChunks,filePath,fileId){constchunkSize=5*1024*1024;// 5MBconststart=chunkIndex*chunkSize;constend=Math.min(file.size,start+chunkSize);constchunk=file.slice(start,end);// 加密分片(SM4)constencrypted=awaitencryptSM4(chunk,'1234567890abcdef');// 实际应从后端获取密钥returnfetch('/api/upload',{method:'POST',body:encrypted,headers:{'X-File-ID':fileId,'X-Chunk-Index':chunkIndex,'X-Total-Chunks':totalChunks,'X-File-Path':filePath}}).then(res=>res.json());}// 进度持久化(localStorage+IndexedDB双备份)functionsaveProgress(fileId,progress){try{// IE9兼容方案if(window.localStorage){localStorage.setItem(`progress_${fileId}`,JSON.stringify(progress));}// 现代浏览器用IndexedDBif(window.indexedDB){constrequest=indexedDB.open('FileProgressDB',1);request.onsuccess=()=>{constdb=request.result;consttx=db.transaction('progress','readwrite');conststore=tx.objectStore('progress');store.put(progress,fileId);};}}catch(e){console.error('进度保存失败:',e);}}@RestController@RequestMapping("/api")publicclassFileController{@Value("${file.storage.path}")privateStringstoragePath;// 分片上传接口@PostMapping("/upload")publicResponseEntityuploadChunk(@RequestParam("file")MultipartFilefile,@RequestHeader("X-File-ID")StringfileId,@RequestHeader("X-Chunk-Index")intchunkIndex,@RequestHeader("X-Total-Chunks")inttotalChunks,@RequestHeader("X-File-Path")StringfilePath){try{// 解密文件(SM4)byte[]decrypted=decryptSM4(file.getBytes(),"1234567890abcdef");// 保存分片StringchunkPath=storagePath+"/"+fileId+"/chunks/"+chunkIndex;Files.createDirectories(Paths.get(chunkPath).getParent());Files.write(Paths.get(chunkPath),decrypted);// 更新进度到MySQLupdateProgress(fileId,chunkIndex,totalChunks,filePath);returnResponseEntity.ok().body(Map.of("success",true));}catch(Exceptione){returnResponseEntity.status(500).body(Map.of("error",e.getMessage()));}}// 合并文件(伪代码)@GetMapping("/merge")publicResponseEntitymergeFile(@RequestParamStringfileId){// 1. 从MySQL查询所有分片信息// 2. 按顺序合并到最终文件// 3. 删除分片目录// 4. 返回下载URLreturnResponseEntity.ok().body(Map.of("url","/download/"+fileId));}}// 如果浏览器不支持文件夹上传,提示用户压缩成ZIPfunctioncheckFolderSupport(){if(!window.File&&!window.FileReader&&!window.FileList&&!window.Blob){alert('您的浏览器太古老了!请:\n1. 使用Chrome/Firefox\n2. 或把文件夹压缩成ZIP上传');}}服务器配置
编译打包
# 前端打包npmrun build# 后端打包mvn clean package# 手动复制dist目录到Tomcat的webappscp-r dist/* /var/lib/tomcat9/webapps/ROOT/MySQL初始化
CREATETABLEfile_progress(idVARCHAR(64)PRIMARYKEY,file_pathVARCHAR(512)NOTNULL,total_chunksINTNOTNULL,received_chunksINTDEFAULT0,last_updateTIMESTAMPDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP);最终解决方案:
温馨提示:本项目仅供学习交流,如需商用请自行购买商业授权(虽然我根本没卖…)
导入到Eclipse:点南查看教程
导入到IDEA:点击查看教程
springboot统一配置:点击查看教程
NOSQL示例不需要任何配置,可以直接访问测试
选择对应的数据表脚本,这里以SQL为例
up6/upload/年/月/日/guid/filename
支持离线保存文件进度,在关闭浏览器,刷新浏览器后进行不丢失,仍然能够继续上传
支持上传文件夹并保留层级结构,同样支持进度信息离线保存,刷新页面,关闭页面,重启系统不丢失上传进度。
点击下载完整示例