
做过工程建筑行业信息系统的朋友应该都懂一个痛点施工日志从来不只是几行文字它背后跟着一整个文件夹。现场工程师拍的照片、隐蔽工程的验收扫描件、浇筑混凝土的视频全都按日期、楼栋、分部分项工程整理成目录层级。以前用普通上传组件处理这种场景文件夹结构直接被打平大视频传一半断掉就白传同一份日志改了三版结果附件被覆盖得干干净净。这篇文章就聊我最近的一次实践在Vue3项目里基于WebUploader做施工日志附件的目录结构分片上传顺带把版本控制一起解决掉。方案适合正在做工程管理系统、工地数字化平台或者任何需要“文件夹级上传大文件分片历史版本追溯”的前后端开发者参考内容会按需求拆解、方案设计、Vue3实操、后端版本模型、问题排查五块来讲。1. 施工现场的文件难题施工日志为什么要“目录级上传”1.1 现场附件的真实结构施工日志的附件目录一般长这样2025-04-03_地下室负一层顶板/ ├─ 01_现场照片/ │ ├─ 钢筋间距检查_001.jpg │ ├─ 钢筋间距检查_002.jpg │ └─ 模板支撑全景.jpg ├─ 02_验收记录/ │ ├─ 隐蔽工程验收记录.pdf │ └─ 混凝土浇筑令.pdf └─ 03_视频/ └─ 负一层顶板浇筑.mp4一个目录里混着图片、PDF、视频子目录的层级虽然不深但语义很明确。照片归照片、验收归验收验收记录里又按验收单类型继续分。如果用传统input typefile multiple多选文件上传后端拿到的是平铺数组哪张照片属于哪个验收环节完全丢失。哪怕前端在文件名上加前缀做标记也只能撑过两层结构层级一深就全乱套。1.2 三个硬要求保结构、扛大文件、能追溯我在做需求调研时项目上的人提了三个要求上传后目录结构必须原样还原前端能按树形展示点开某天日志就能看到当天的完整文件夹。视频体积动辄几百MB甚至几个GB上传中断不能从头再来。同一份日志会反复修改监理要求留存每一次现场附件快照改错了要能找回上一个版本。基于这三点最初有人提议“整个文件夹打压缩包上传、服务端解压”。这个方案技术上可行但对使用者和后端都很不友好使用者要装压缩软件预览单张图片还要先解压整个包服务端解压远程上传的压缩包又有路径穿越和超大文件风险。所以我排除掉压缩包方案选择了客户端遍历文件夹、把结构信息随着每个文件分片一起提交的路线。1.3 为什么选 WebUploader当时对比了几个方案原生axios手写一个分片上传、开源组件WebUploader、以及基于现代框架的Upload组件。Upload组件虽然好看但对目录结构和分片细节的定制能力弱手写分片固然灵活但要处理切片、排序、重试、并发、续传工作量不小。WebUploader最大的价值在于它把分片、并发、重试、Md5校验这些底层逻辑都封装好了还自带文件队列和事件体系我只需要把精力放在目录序列化和版本控制上。这个组件由百度开源已经有年月官方文档不算新但它胜在稳定、不依赖现代框架、没有绑定UI风格特别适合塞进Vue3这种命令式框架里用。它也不依赖jQuery官方示例里习惯用$简写实际上核心库是独立的引入方式很干净。2. 方案设计目录结构、分片策略与版本模型怎么定2.1 目录结构的三种方案对比让文件夹结构在上传后不丢失业内常见三种做法方案原理优点缺点打压缩包上传服务端解压客户端压缩文件夹服务端解压还原结构结构无损用户体验差服务端有解压安全风险预览必须解压完读取webkitRelativePath逐文件上传浏览器遍历文件夹时自动给每个File对象附加相对路径上传时把路径写入formData服务端按路径重建目录实现简单兼容性好依赖浏览器目录选择能力路径字段需要前后端约定好拖拽文件夹FileSystem API递归读取用DataTransferItem.webkitGetAsEntry递归读目录树交互流畅递归逻辑复杂需要额外处理空目录和权限问题我最终选择第二种webkitRelativePath方案。它其实是三个方案里最“笨”但最稳的办法Chrome和Edge对目录选择原生支持File对象自带相对路径前端只需要把这个字段原样传给后端。操作上给input加上webkitdirectory属性用户选择文件夹后每个File对象就会带上类似2025-04-03_地下室负一层顶板/01_现场照片/钢筋间距检查_001.jpg的路径后端拿到路径后按/切分建目录即可。2.2 分片参数设计不是越碎越好分片上传的核心参数有三个分片大小、并发线程数、如何标识分片归属。WebUploader里面对应配置是chunkSize、threads和formData。我这边实测下来2MB是一个比较平衡的值。工地现场网络环境复杂4G信号不好的时候1MB更稳千兆内网环境下5MB也扛得住。不能把分片设得太小分片越小请求数量越多服务端的临时文件数量会爆炸增长也不能设得太大否则失去分片的意义。threads建议控制在3到5之间浏览器对同一域名的TCP连接数是有限的开太多线程并不能线性提升速度反而容易把服务端打挂。标识分片归属这块WebUploader在上传每个chunk时会自动带上chunk、chunks、size等隐藏字段我再在formData里补几个业务参数formData: { logId: 2025-04-03-001, relativePath: 2025-04-03_地下室负一层顶板/01_现场照片/钢筋间距检查_001.jpg, fileMd5: uploader.md5, versionId: currentVersionId }fileMd5和relativePath组合起来可以唯一确定某个文件服务端据此判断该文件对应的分片目录放在哪里。logId用来把附件归属到具体日志。2.3 版本控制的三层模型版本控制不是简单地把目录树快照存下来就完事需要考虑存储成本和回溯效率。我的设计分三层第一层文件存储去重。同样一张照片可能被重复传到多个日期日志里如果每个版本都复制一份物理文件NAS不出三个月就爆了。所以文件上传时不直接以日志为单位存而是以文件的MD5值为存储键。服务端收到一个文件的分片并合并完成后先算一次MD5查file_object表如果MD5已存在就直接复用物理文件只新增一条引用记录。第二层目录树快照。每保存一次日志版本后端把“当前这套附件里的完整目录结构”序列化成一棵JSON树树的叶子节点记录相对路径、文件MD5、文件大小和修改时间。这棵快照树约等于某个时间点的完整现场附件清单。第三层变更记录。目录树快照不能只存“全量”否则两个版本之间到底变了什么要把两棵大JSON树整个对比一遍成本高。所以每次版本保存时后端同时计算与上一版本的差异生成一份file_diff_json记录新增了哪些文件、删除了哪些文件、修改了哪些文件。前端展示历史版本列表时直接读这份diff就能告诉用户“这个版本相比上个版本新增了3张照片、删除了1个视频”。三层模型的关系可以理解为文件存储去重是底层的物理存储策略目录树快照是每个版本的全貌变更记录是版本之间快速对比的索引。3. Vue3 实操集成 WebUploader 实现完整上传链路3.1 引入与基础封装WebUploader本身是一个面向DOM的命令式JS库不能直接当成Vue组件来用硬套Vue的响应式反而会把回调机制搞乱。正确做法是在Vue3的onMounted里初始化Uploader实例把组件回调用ref状态暴露给模板。我用useWebUploader组合式函数把整个初始化逻辑封装起来这样组件里只保留业务相关的东西。// useWebUploader.js import { ref, onMounted, onBeforeUnmount, getCurrentInstance } from vue; export function useWebUploader(options) { const uploaderRef ref(null); const fileList ref([]); const totalProgress ref(0); const instance getCurrentInstance(); onMounted(() { const WebUploader window.WebUploader; // 从静态资源挂载的全局对象 const uploader WebUploader.create({ pick: options.pick || #filePicker, dnd: options.dnd || #dropArea, paste: undefined, accept: options.accept, swf: undefined, chunked: true, chunkSize: options.chunkSize || 2 * 1024 * 1024, threads: options.threads || 3, duplicate: true, auto: false, formData: options.formData || {}, fileVal: file, // 新版本不依赖flashhtml5模式下这些配置都不需要 runtimeOrder: html5 }); uploader.on(fileQueued, (file) { fileList.value.push(file); }); uploader.on(uploadProgress, (file, percentage) { file.percent percentage * 100; let total 0; fileList.value.forEach(f total (f.percent || 0)); totalProgress.value Math.floor(total / fileList.value.length); }); uploader.on(uploadFinished, () { options.onFinished options.onFinished(); }); uploaderRef.value uploader; }); onBeforeUnmount(() { if (uploaderRef.value) { uploaderRef.value.destroy(); uploaderRef.value null; } }); return { uploaderRef, fileList, totalProgress }; }这里有一个很关键的实践细节初始化时机必须在onMounted之后。Vue3里DOM渲染是异步的如果在setup里同步调用WebUploader.create选择器对应的DOM节点还没挂到页面上初始化会直接报错。我在真实项目里踩过这个坑后面改成onMounted就好很多了。引入方式上我建议把WebUploader的dist文件放在项目public目录下而不是走npm。原因是npm里的webuploader包版本比较旧维护频率低直接引静态文件反而版本可控也不会被构建工具搞得路径乱七八糟。3.2 文件夹遍历与上传队列构建文件夹上传的入口是带webkitdirectory的input元素。用户选择文件夹后我们要做两件事把文件夹里所有File对象加进WebUploader队列并且给每个文件绑定好对应的相对路径。template div input typefile webkitdirectory directory multiple changehandleDirectoryChange / div idfilePicker选择文件夹/div div iddropArea拖拽文件夹到此处/div /div /templatehandleDirectoryChange里拿到event.target.files之后遍历一遍为每个文件构造一个上传任务对象再把这些对象批量交给uploader.add。注意不要一次性把几千个文件全塞进去浏览器会卡顿。分批次处理每批100个左右中间用requestAnimationFrame或setTimeout(0)让出主线程界面才不会冻结。async function handleDirectoryChange(event) { const files event.target.files; const tasks Array.from(files).map((file) { const relativePath file.webkitRelativePath || file.name; return { file, // 把相对路径暂存到自定义属性上 relativePath, logId: props.logId }; }); // 分批发入队列 const BATCH_SIZE 100; for (let i 0; i tasks.length; i BATCH_SIZE) { const batch tasks.slice(i, i BATCH_SIZE); // 先暂停自动上传等批次加入完成后再启动避免上传请求和队列构建抢时间 if (!uploaderRef.value) return; uploaderRef.value.stop(); batch.forEach((task) { uploaderRef.value.add(task.file); // 通过md5键值与真实文件关联后续在beforeFileQueued里查relativePath task.file._relativePath task.relativePath; }); await nextTick(); } uploaderRef.value.start(); }这里有个容易犯的错误uploader.add()之后原来的File对象会被WebUploader包装成自己的File对象我们自定义属性直接挂在原File对象上是拿不到的。我后来用的做法是维护一个Map键为WebUploader生成的file.id值为relativePath在事件回调里反查。const pathMap new Map(); uploader.on(fileQueued, (file) { const originalFile file.source; pathMap.set(file.id, originalFile._relativePath); }); uploader.on(uploadStart, (file) { const relativePath pathMap.get(file.id); uploader.options.formData.relativePath relativePath; });3.3 上传流程与进度同步WebUploader的默认行为是文件入队后不自动上传要手动uploader.start()。我建议在用户点击“开始上传”按钮后再统一启动而不是文件入队后立即上传因为现场工程师往往会在选完文件夹后再勾选某个子目录不需要传给一个确认时机更符合实际工作流程。上传中要处理两类进度单文件进度和整体进度。单文件进度直接从uploadProgress回调里取整体进度则是所有文件进度的平均。uploader.on(uploadProgress, (file, percentage) { file.percent percentage * 100; const sum fileList.value.reduce((acc, f) acc (f.percent || 0), 0); totalProgress.value Math.floor(sum / fileList.value.length); });分片状态的变化也要展示给用户。WebUploader自带uploadStart、chunkUploaded、uploadFinished等事件chunkUploaded这个事件很有用它会在每个分片上传完成后触发我们可以在这里做服务端的分片状态同步校验分片是否已经存在如果存在服务端直接返回{ status: true }前端就可以跳过这个分片实现天然的断点续传。3.4 提交版本前的一致性校验文件全部上传完不等于整个流程结束还要做一次“本次提交”的校验确认相对路径、文件MD5、文件大小三者一致对得上再让用户填上版本备注最后调用后端接口保存版本快照。我在前端维护一个pendingCommit数组uploadFinished之后遍历uploader.on(uploadFinished, async () { const snapshot fileList.value.map((file) { const relativePath pathMap.get(file.id); return { relativePath, fileMd5: file.md5, size: file.size }; }); // 调用版本保存接口 const res await saveLogVersion({ logId: props.logId, fileList: snapshot, versionNote: versionNote.value }); if (res.code 0) { emit(version-saved, res.data); } });这个pendingCommit快照的生成时机很关键。不能在fileQueued时就生成因为那时MD5可能还没算完也不能在uploadFinished后再从服务端拉全量文件列表那样网络往返一轮就慢了。我这边实测下来在uploadFinished事件里直接遍历WebUploader队列里的文件对象此时file.md5已经可用了是最合适的时机。4. 版本控制的后端设计与核心数据表4.1 分片接收与合并逻辑分片上传的后端逻辑说穿了不复杂但细节多。核心流程是三个步骤接收分片、等待分片齐了以后合并、合并后计算MD5并去重。// Node/Express风格示例 const path require(path); const fs require(fs/promises); async function handleChunk(req, res) { const { logId, relativePath, fileMd5, chunk, chunks } req.body; // 分片专属临时目录用fileMd5做目录名避免不同文件混在一起 const chunkDir path.join(TMP_UPLOAD_DIR, fileMd5); await fs.mkdir(chunkDir, { recursive: true }); // 分片按索引命名方便最后按序号合并 const chunkPath path.join(chunkDir, chunk_${chunk}); await fs.writeFile(chunkPath, req.file.buffer); // 检查是否所有分片都已上传 const existedChunks await fs.readdir(chunkDir); if (existedChunks.length Number(chunks)) { // 按index从小到大排序合并 existedChunks.sort((a, b) parseInt(a.split(_)[1]) - parseInt(b.split(_)[1])); const finalDir path.join(LOG_FILE_DIR, logId); await fs.mkdir(finalDir, { recursive: true }); const ext path.extname(relativePath); const finalFilePath path.join(finalDir, ${fileMd5}${ext}); const writeStream fs.createWriteStream(finalFilePath); for (const chunkFile of existedChunks) { const data await fs.readFile(path.join(chunkDir, chunkFile)); writeStream.write(data); } writeStream.end(); // 合并完成后计算完整MD5与前端上报的fileMd5比对 const finalMd5 await calcFileMd5(finalFilePath); if (finalMd5 ! fileMd5) { // 分片在传输中损坏删除临时目录并返回错误前端会重新上传该文件 await fs.rm(chunkDir, { recursive: true, force: true }); return res.status(500).json({ code: 500, message: 分片合并校验失败 }); } // 注册fileObject实现存储去重 await registerFileObject(fileMd5, finalFilePath); } res.json({ code: 0, status: true }); }合并时有个很容易踩的坑WebUploader是多线程并发上传分片到达服务端的顺序是不确定的所以合并文件之前必须把所有分片按索引排序否则视频文件会花屏、图片会打不开。4.2 版本快照表设计版本数据表我设计成三张核心表各自负责不同的事。-- 施工日志主表 CREATE TABLE construction_log ( log_id BIGINT PRIMARY KEY, project_id BIGINT, log_date DATE, title VARCHAR(255), content TEXT, current_version INT DEFAULT 0, created_at DATETIME ); -- 版本快照表 CREATE TABLE log_version ( version_id BIGINT PRIMARY KEY AUTO_INCREMENT, log_id BIGINT NOT NULL, version_no INT NOT NULL, tree_json JSON COMMENT 完整目录树快照, diff_json JSON COMMENT 与上一版本的差异, version_note VARCHAR(500), operator VARCHAR(50), created_at DATETIME ); -- 文件对象表用于跨版本、跨日志的物理文件去重 CREATE TABLE file_object ( file_md5 VARCHAR(32) PRIMARY KEY, storage_key VARCHAR(300) NOT NULL, file_size BIGINT, first_log_id BIGINT, created_at DATETIME );tree_json的存储结构大概长这样{ name: 2025-04-03_地下室负一层顶板, type: directory, children: [ { name: 01_现场照片, type: directory, children: [ { name: 钢筋间距检查_001.jpg, type: file, fileMd5: a7b23c..., size: 3456789, modifiedAt: 2025-04-03T10:00:00 } ] }, { name: 03_视频, type: directory, children: [ { name: 负一层顶板浇筑.mp4, type: file, fileMd5: e4f12a..., size: 523456789, modifiedAt: 2025-04-03T09:30:00 } ] } ] }叶子节点用fileMd5而不是文件路径来做唯一标识因为同一个物理文件可能因为重命名产生两个不同路径但MD5不变可以直接复用存储且不用重新上传。4.3 版本对比与回滚实现版本对比不搞复杂的树差异算法而是用MD5集合做差集。后端加载两个版本的tree_json把所有叶子节点的fileMd5和相对路径拉平成两个数组然后按相对路径做笛卡尔集合比较。具体规则是相对路径相同且MD5相同视为未变更相对路径相同但MD5不同视为修改在上个版本存在但当前版本不存在视为删除当前版本新增且上个版本没有视为新增。这个逻辑用一个Map就能实现没必要引入复杂的diff库。回滚操作的本质是“把指定版本的tree_json还原成当前版本树的参考内容”。文件物理存储已经按MD5去重所以回滚不需要复制文件只需要更新construction_log.current_version字段并基于选中的版本重新生成log_version_file关联记录。前端读这份关联记录就能把目录树还原显示出来。5. 常见问题与排查技巧实录5.1 分片文件无法正确合并或损坏这个是最常见的问题。表现是上传进度显示100%但服务器保存的文件打开报错视频播放到一半花屏或者PDF只有前半页。排查思路先看合并顺序。WebUploader多线程上传服务端收到的分片顺序是乱的必须在合并时按chunk索引排序不能按接收顺序拼接。再看MD5校验。给每个分片单独记录一个MD5值合并前逐一比对任何一个分片不一致都要触发该分片的重传而不是等到最后合并完成后才发现文件是坏的。5.2 大目录上传时页面卡顿甚至崩溃上万张照片的目录不是没遇到过。原因往往是一次性把所有文件都add进了WebUploader队列浏览器渲染队列列表时同步执行把主线程占死了。解决方法是分批入队每批100个用requestAnimationFrame或setTimeout(0)让主线程喘息。另外不要让WebUploader的UI队列渲染太多DOM节点前端列表用虚拟滚动或者干脆显示“已选择xx个文件”的纯数字统计列表只在需要时展开。5.3 断点续传失效重新选择文件夹后要全部重传WebUploader的断点续传依赖两个信息分片标识和唯一文件标识。如果重新选择文件夹时文件名还是原来的但MD5计算策略变了或者服务端没有记录已上传的分片信息续传就失效。我这边实测可靠的做法是前端在上传开始前先调一个queryUploadedChunks接口传入fileMd5服务端返回已存在的分片索引列表WebUploader在chunkUploaded事件里检查当前分片是否已经在列表里已在就直接标记完成。这样断点续传不再依赖浏览器内存即使页面刷新也有效。5.4 文件名包含特殊字符导致路径错位工地上传的文件名往往带中文、空格、括号、井号。直接把这些拼进URL或formData容易出问题。统一做法是前端对relativePath做encodeURIComponent编码服务端收到后再decodeURIComponent解码。目录分层的分隔符用斜杠/统一Windows环境下还要处理反斜杠因为某些系统会把路径写成2025-04-03\01_现场照片\xxx.jpg。5.5 服务端临时分片目录垃圾堆积分片上传过程中如果用户中途取消、浏览器崩溃、网络断掉超过一天服务端临时目录里就会留下一堆没合并的分片。需要有一个定时清理任务删除创建时间超过24小时且尚未合并的chunkDir目录。这个清理逻辑不能漏硬盘被垃圾分片塞满的事故我见过不止一次。我把常见问题整理成一张速查表方便现场对照问题现象优先排查点解决方案要点合并后文件损坏分片顺序、分片MD5校验按索引排序合并逐分片校验大目录上传卡死一次性入队数量分批入队每批100个断点续传失效服务端分片记录缺失独立查询接口维护已传分片索引文件名乱码/路径错位编码不一致统一encodeURIComponent处理分片临时文件堆积无清理任务定时清理超过24小时未合并的目录同一文件重复上传占用空间未按MD5去重文件对象表按MD5复用物理文件进度条卡在99%最后一批分片提交失败查看服务端日志检查并发上限5.6 上传速度优化的实测建议分片大小和并发线程不是一成不变的。我在现场网络差的环境里实测过分片大小设为1MB、并发线程设为2反而比默认的2MB/3线程更稳因为网络抖动时单个分片重传的代价更小。内网环境下则相反5MB分片配合5线程能把千兆带宽吃满。建议在设置页留一个“网络环境调优”的开关用户根据实际情况选择“稳定模式”和“极速模式”分别对应不同的分片参数。另外要注意浏览器对同源地址的并发连接数是有限制的服务端不需要开太多线程等连接。如果发现上传速度上不去先排除一下是不是TLS握手和反向代理的瓶颈而不是一味调大前端并发。写在最后的一点体会这套方案落地以后现场工程师反馈最好用的反而不是分片和续传而是“历史版本对比”这个功能。以前监理要查“昨天拍的钢筋照片到底和今天有什么差别”只能靠人肉对比文件夹。现在打开日志详情左边的版本树里点两个节点新增、删除、修改一目了然。最后再分享一个细节分片上传其实也是一把双刃剑它把一个大文件请求切碎成很多小请求确实降低了单请求失败的影响但也意味着服务端要做好幂等处理。同一个分片可能因为前端重试被提交两次接收逻辑必须判重后再落盘。如果能把“分片幂等”这一点做好整个上传链路就不会有太大的稳定性问题。整个方案从需求梳理到上线大概花了一个半月的时间其中分片合并校验和版本差异对比这两个环节最耗精力。如果你也正在做工程建筑相关的管理系统希望这篇实践笔记能给你省掉一些重复踩坑的时间。