ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CKEditor内网环境下粘贴大图到质量追溯系统的实现与避坑指南

CKEditor内网环境下粘贴大图到质量追溯系统的实现与避坑指南 上周捣鼓质量追溯系统的时候碰到一个看起来特别小、实际折腾了一整天的需求工程师要在CKEditor富文本里粘贴一张设计图要求跟Word里一样所见即所得。难就难在汽车厂这套系统跑在内网浏览器是定版的Chrome图纸文件动辄就是几MB的4K截图直接粘进去不是不显示就是base64串撑爆数据库字段。这篇文章把我整理出来的整条实现链路和文档里查不到的坑都写明白直接给可落地的示例代码。汽车制造行业和互联网产品有个很大的区别所有东西讲流程闭环。一张图纸从设计端流到工艺端、再流到质量端中间经过的每个系统都可能是独立部署的编辑器只是其中承上启下的一个环节。所以“设计图粘贴”这个需求本质上不是“把图片插进文本框”而是要打通“截图工具 → 浏览器剪贴板 → CKEditor → 文件服务 → 前端回显”这条完整链路每一步都会出幺蛾子。1. 先想清楚汽车厂里的“粘贴设计图”到底卡在哪1.1 一张焊点图纸的真实流转场景我在汽车制造相关的项目里接触到的“设计图粘贴”绝大多数不是贴一张完整整车造型图而是贴各种带标注的局部分解图。比如工艺工程师在ECR工程变更请求流程里要把车身焊点分布图上某个部位的红圈标注截图粘贴到变更说明里或者质量工程师在处理Audit评审问题单时把缺陷位置的照片、局部剖视图粘贴到问题描述区域。这些场景有几个共同特征图片来自各种截图工具可能是QQ截图、微信截图、Snipaste也可能是CATIA或UG NX里直接导出后裁剪过的图图片普遍比较大分辨率经常是2K、4K单张几个MB很正常粘贴的目标系统是内网系统比如PLM、MES、QIS、BOM管理平台这些系统之前大多是老旧的富文本编辑器对现代剪贴板数据处理得很粗糙图片不能以base64形式存进业务表因为量大以后数据库性能和备份体积都扛不住。这些约束叠加在一起“粘贴设计图”就变成一个需要专门实现的功能而不是CKEditor默认能力能覆盖的。1.2 默认行为为什么不够用很多刚接触CKEditor的人会试一下直接复制一张图、CtrlV发现图能出来然后就觉得需求完成了。但这种默认行为背后有几个隐患第一CKEditor 4时代如果不做任何配置从剪贴板粘贴位图会触发浏览器的默认行为图片以base64字符串的形式直接塞进contenteditable区域保存表单时整个HTML里全是几十万字符的base64内容。如果一天有几百条质量单每条里面三五张图数据库直接爆掉不说以后每次列表加载都要拖上几百KB的HTML字符串页面卡到没法用。第二CKEditor 5版本里粘贴图片默认走ImageUpload插件的上传适配器但如果你根本没配置适配器或者后端接口格式不匹配图片会在粘贴成功后回显时出现404或者干脆被过滤掉。第三汽车制造系统的部署环境是内网前端页面和后端文件服务经常不在同一个域名下跨域请求、请求头鉴权、CSRF Token这些事情不处理好上传接口就调不通。所以要做“设计图粘贴”本质上是在CKEditor的剪贴板处理链路里把“设计图文件”这个输入准确识别出来转交给一个能落地的上传适配器再把它返回的URL变成编辑器里的img标签。这话说起来简单但各个环节的细节得一环一环抠。2. 动改之前先看CKEditor的粘贴链路2.1 剪贴板里的数据到底长什么样浏览器里按下CtrlV的时候剪贴板并不是只装了一张图片那么简单。实际上通过ClipboardEvent的clipboardData对象可以拿到四种主要数据text/plain纯文本内容如果是从网页复制的会带文本text/htmlHTML片段里面可能嵌套了img标签src可能是base64也可能是相对路径text/uri-list链接类型filesFile对象列表这是最关键的。从本地文件管理器复制文件、或者截图工具截完图直接CtrlC复制到剪贴板时图片是作为File对象存在的。CKEditor判断“粘贴了一张图片”的核心逻辑就是检查clipboardData.files里有没有type以image/开头的文件。有就走图片上传流程没有就继续走HTML和文本处理流程。绕开底层API直接看问题从截图工具复制到剪贴板的图片是PNG或JPEG格式的File对象其中有元信息文件名、大小、类型而从网页上复制的图片可能是一段HTML里面src带base64。这两条路径的处理方式完全不同。2.2 CKEditor 5的Clipboard Pipeline和图片上传入口CKEditor 5内置了一套叫Clipboard Pipeline的处理机制。粘贴触发时编辑器会通过监听clipboardInput事件来统一接管剪贴板内容然后做转换和插入。图片文件这条路径最终会走到ImageUploadEditing插件的逻辑里把文件交给FileRepository。FileRepository就是所有上传适配器的挂载点。它维护了一个createUploadAdapter工厂方法编辑器被粘贴进来一个文件时会调用这个方法传入一个loader对象期望返回一个符合上传适配器接口的实例。这个实例需要实现两个方法upload()返回一个Promise把文件通过XMLHttpRequest或fetch发到后端成功后resolve一个包含default字段的对象default就是图片在服务器上的URLabort()取消上传用户在界面上点删除或者取消时会被调用。理解了这一层就能明白实现“设计图粘贴”的关键点不是别的就是写对createUploadAdapter工厂方法以及写对upload()里面的所有细节。CKEditor官方文档里叫它CustomAdapter实战里汽车企业几乎都是走这条路没有谁直接用官方的SimpleUploadAdapter因为接口格式、鉴权方式、域名规则都对不上。3. 手写一版可直接落地的示例代码3.1 编辑器初始化和插件注入直接给一个完整可参考的初始化配置。假设场景是QIS质量信息系统里的一个问题描述编辑器要求粘贴设计图后自动上传到企业文件服务编辑器里回显缩略图提交表单时只保存图片URL。import ClassicEditor from ckeditor/ckeditor5-editor-classic/src/classiceditor; import Essentials from ckeditor/ckeditor5-essentials/src/essentials; import Paragraph from ckeditor/ckeditor5-paragraph/src/paragraph; import Bold from ckeditor/ckeditor5-basic-styles/src/bold; import Italic from ckeditor/ckeditor5-basic-styles/src/italic; import Heading from ckeditor/ckeditor5-heading/src/heading; import List from ckeditor/ckeditor5-list/src/list; import Image from ckeditor/ckeditor5-image/src/image; import ImageToolbar from ckeditor/ckeditor5-image/src/imagetoolbar; import ImageCaption from ckeditor/ckeditor5-image/src/imagecaption; import ImageUpload from ckeditor/ckeditor5-image/src/imageupload; import { FileRepository } from ckeditor/ckeditor5-upload; import DesignDrawingAdapter from ./design-drawing-adapter; class DesignDrawingUploadPlugin { constructor(editor) { editor.plugins.get(FileRepository).createUploadAdapter (loader) { return new DesignDrawingAdapter(loader, { uploadUrl: /api/design/drawing/upload, withCredentials: true, headers: { X-CSRF-Token: document.querySelector(meta[namecsrf-token])?.content || } }); }; } } ClassicEditor.create(document.querySelector(#drawingEditor), { plugins: [ Essentials, Paragraph, Bold, Italic, Heading, List, Image, ImageToolbar, ImageCaption, ImageUpload, DesignDrawingUploadPlugin ], toolbar: { items: [ heading, |, bold, italic, |, bulletedList, numberedList, |, undo, redo ] }, image: { toolbar: [ imageStyle:block, imageStyle:side, |, imageTextAlternative, toggleImageCaption ], upload: { types: [png, jpg, jpeg, gif, svg, webp] } }, language: zh-cn }).then(editor { window.drawingEditor editor; }).catch(error { console.error(编辑器初始化失败, error); });这里有两个细节要说明。第一image.upload.types白名单控制了哪些扩展名的文件会被当成图片处理这里根据汽车图纸的实际情况限定成PNG、JPG、GIF、SVG和WebP如果图纸偶尔有BMP格式建议别加BMPBMP文件体积大、浏览器兼容性也一般让工程师截图时转成PNG更合理。第二DesignDrawingUploadPlugin通过constructor(editor)接收编辑器实例在编辑器创建过程中把自己的适配器工厂挂到FileRepository上。这个时机很关键必须在粘贴事件发生之前完成挂载否则第一次粘贴走的是默认上传逻辑格式对不上就失败。3.2 自定义UploadAdapter完整实现这是整个方案最核心的一段代码。我把它拆开写注释也比较全方便改成你们内部的接口// design-drawing-adapter.js class DesignDrawingAdapter { constructor(loader, options) { this.loader loader; this.options options || {}; this.xhr null; } upload() { return this.loader.file.then( (file) new Promise((resolve, reject) { const validationError this._validate(file); if (validationError) { reject(new Error(validationError)); return; } // 组装表单数据字段名要和后端接口对齐 const formData new FormData(); formData.append(file, file); formData.append(bizType, drawing); formData.append(source, ckeditor-paste); const xhr new XMLHttpRequest(); const url this.options.uploadUrl || /api/design/drawing/upload; xhr.open(POST, url, true); if (this.options.withCredentials) { xhr.withCredentials true; } if (this.options.headers) { Object.keys(this.options.headers).forEach((key) { xhr.setRequestHeader(key, this.options.headers[key]); }); } // 上传进度回传给CKEditor编辑器才能显示进度条 xhr.upload.addEventListener(progress, (evt) { if (evt.lengthComputable) { this.loader.uploadTotal evt.total; this.loader.uploaded evt.loaded; } }); xhr.addEventListener(load, () { if (xhr.status 200 xhr.status 300) { try { const response JSON.parse(xhr.responseText); if (response.code 0) { resolve({ default: response.data.url }); } else { reject(new Error(response.message || 上传失败)); } } catch (e) { reject(new Error(上传接口返回格式异常不是合法JSON)); } } else { reject(new Error(上传失败HTTP状态码${xhr.status})); } }); xhr.addEventListener(error, () { reject(new Error(网络异常上传失败)); }); xhr.addEventListener(abort, () { reject(new Error(上传已取消)); }); this.xhr xhr; xhr.send(formData); }) ); } abort() { if (this.xhr) { this.xhr.abort(); } } _validate(file) { const allowedTypes [ image/png, image/jpeg, image/gif, image/svgxml, image/webp ]; if (!allowedTypes.includes(file.type)) { return 仅支持粘贴PNG/JPG/GIF/SVG/WebP格式的设计图; } const maxSize 10 * 1024 * 1024; if (file.size maxSize) { return 单张设计图不能超过10MB; } return null; } } export default DesignDrawingAdapter;这里最容易踩的坑是接口返回格式。CKEditor的适配器resolve时必须返回{ default: 图片URL }这个结构default是固定的键名。很多人写成{ url: xxx }结果图片一直插不进去编辑器内部报错找不到url属性。第二个容易踩的坑是loader.file。loader.file是一个Promise必须在upload()方法里先用then把文件拿回来再走异步上传流程。有的同学直接在upload()里同步返回Promise.resolve(...)但文件都还没拿到自然会出问题。第三个坑是类型校验。截图工具复制出来的File类型未必是标准的image/png。某些Windows环境下从画图里复制的图片类型是image/bmp如果不做拦截这类文件会进入上传流程后端又没做收集就会报500。所以前端校验要做后端校验也要做两边都别省。3.3 后端接口文件落地与URL返回前端适配器准备好了后端接口必须能处理multipart/form-data请求。汽车企业后端栈各有不同常见的是Java Spring Boot也有不少Node服务、.NET服务。给两个可以直接改用的示例。Java Spring Boot版本RestController RequestMapping(/api/design) public class DesignDrawingController { Value(${file.storage.base-dir:./uploads}) private String baseDir; Value(${file.storage.base-url:http://10.0.0.18:8080}) private String baseUrl; PostMapping(/drawing/upload) public ResultMapString, Object uploadDrawing( RequestParam(file) MultipartFile file, RequestParam(value bizType, defaultValue drawing) String bizType, RequestParam(value source, required false) String source) { if (file.isEmpty()) { return Result.fail(上传文件为空); } if (file.getSize() 10 * 1024 * 1024) { return Result.fail(设计图不能超过10MB); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.) 1).toLowerCase(); SetString allowedExt new HashSet(Arrays.asList(png, jpg, jpeg, gif, svg, webp)); if (!allowedExt.contains(ext)) { return Result.fail(不支持的设计图格式 ext); } String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); String objectKey design/ datePath / UUID.randomUUID() . ext; File dest new File(baseDir / objectKey); dest.getParentFile().mkdirs(); try { file.transferTo(dest); } catch (IOException e) { return Result.fail(文件保存失败); } MapString, Object data new HashMap(); data.put(url, baseUrl / objectKey); data.put(size, file.getSize()); return Result.ok(data); } }Node Express版本const multer require(multer); const path require(path); const fs require(fs); const storage multer.diskStorage({ destination(req, file, cb) { const datePath new Date().toISOString().slice(0, 10).replace(/-/g, /); const fullPath uploads/${datePath}; fs.mkdirSync(fullPath, { recursive: true }); cb(null, fullPath); }, filename(req, file, cb) { const ext path.extname(file.originalname).toLowerCase(); cb(null, design-${Date.now()}${ext}); } }); const upload multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter(req, file, cb) { const ext path.extname(file.originalname).toLowerCase(); if ([.png, .jpg, .jpeg, .gif, .svg, .webp].includes(ext)) { cb(null, true); } else { cb(new Error(不支持的文件类型)); } } }); router.post(/api/design/drawing/upload, upload.single(file), (req, res) { if (!req.file) { res.json({ code: 1, message: 未收到文件 }); return; } const url ${req.protocol}://${req.get(host)}/${req.file.path.replace(/\\/g, /)}; res.json({ code: 0, data: { url, size: req.file.size } }); });后端接口做三件事就够了校验文件类型和大小、把文件落到磁盘或对象存储、返回访问URL。真正企业级部署时文件服务通常是独立的MinIO或内部OSS只需要把落盘那一行替换成调用文件服务的SDK即可。返回URL最好是公司内部统一文件网关的地址而不是某个应用实例的IP否则后续扩容、迁移都会麻烦。4. 汽车行业场景下绕不开的坑4.1 大图和长图的体积问题前面代码里我把前端上传限制设为10MB这个数字在普通互联网产品里算很宽松但在汽车工厂场景下一张CATIA导出图的截图超过10MB很常见。千万别直接把上限拉到50MB或100MB那样会引出另一个隐患CKEditor会把所有上传中的图片渲染在同一个页面里编辑器内容一多浏览器内存直接飙升工控机配置又不高很容易卡死。更合理的做法是双通道设计粘贴的时候允许临时上传30MB以内的大图保存时后端对图片做一次无损压缩和缩略图生成编辑器里只回显适合阅读的压缩图点击缩略图再查看原图。我实际建议在后端加一个图片压缩步骤用Java的Thumbnails库或者Node的sharp库把超过2560px宽的图等比压缩到1920px同时保留原图文件路径用于追溯。这个细节在汽车行业很重要因为质量追溯要求留原始证据不能为了省空间把原图丢掉。操作路径是原图落一份压缩图落一份数据库里存两个字段origin_url和thumb_url编辑器里只回显thumb_url。4.2 统一截图工具与粘贴兼容性在工厂的实际环境里工程师的截图工具五花八门有人用QQ截图有人用微信截图有人用Snipaste还有人直接按键盘上的PrintScreen然后到画图里裁剪。不同工具复制到剪贴板里的数据格式有细微差别。我简单测过几类工具的粘贴表现来源剪贴板图片类型粘贴到CKEditor表现SnipastePNG带透明通道正常File类型是image/png微信截图PNG默认文件名带规则正常但文件名是wx_xxx.pngQQ截图PNG正常Windows画图复制BMP默认被过滤需提示转换格式浏览器网页右键复制图片视图片格式而定JPEG居多正常但可能走HTML内嵌路径而非File路径CAD软件直接复制图形多为EMF/WMF矢量格式失败CKEditor不识别折腾一圈后我的建议是质量管理相关系统统一给工程师推荐截图工具常见做法是把Snipaste的便携版放到内网软件分发平台并写一条规范截图后直接CtrlC、CtrlV不要先保存再上传。对于CAD软件直接复制图形的场景前端很难处理因为EMF/WMF这类矢量格式浏览器根本不支持在编辑器里回显。实际业务上工艺工程师通常会在CAD里把局部图导出成PNG再用截图工具处理这个流程调整可以在需求说明书里写清楚比在代码里死磕靠谱得多。4.3 内网部署与权限校验汽车厂内部系统普遍是内网部署前端应用在办公网的某个域名下文件服务可能在另一个网段。这里两个典型问题跨域携带Cookie、统一身份认证。如果你用withCredentials: true那后端接口必须响应Access-Control-Allow-Origin: https://你的前端域名并且不能用*通配符。同时需要配置Access-Control-Allow-Headers把前端请求头里自定义的字段比如X-CSRF-Token加入白名单否则浏览器会发起CORS预检并且直接拦截。统一身份认证方面汽车企业内部很多系统用单点登录CAS或OAuth2获取用户身份。上传接口里务必把当前用户信息一起传过去最简单的方式是后端通过会话读取登录用户或者前端在Header里传用户ID。千万不要把文件物理路径直接暴露给前端否则存在越权访问图纸的风险这在制造企业是很严肃的信息安全问题。听起来这些都偏运维向但实际项目里百分之八十的时间就是花在调通这些环境问题上。代码本身并不复杂复杂度全在集成环节。5. 从“能贴”到“好用”排查思路与扩展建议5.1 粘贴后图片不显示怎么快速定位不管初始化写得多么小心翼翼第一次联调大概率还是会有问题。我列一个简单的排查顺序按这个顺序走基本上能定位九成问题现象优先检查项常见原因粘贴无反应什么都不会发生浏览器控制台是否有报错image.upload.types白名单过滤掉了非图片文件或者DesignDrawingUploadPlugin没有正确注入到extraPlugins粘贴后图片出现但很快显示红框网络请求是否发出、接口状态码上传接口400/401/403一般是鉴权头缺失或跨域被拦截上传成功但图片一直转圈不显示适配器resolve的返回结构不是{ default: url }而是别的格式保存表单后图片丢失编辑器输出的HTML里img是否有src粘贴时还没来得及上传完就提交了表单需要等上传进度完成后再提交编辑器里显示正常但其他人打不开图片URL是不是内网可达地址文件服务挂了或URL拼接错误这里有一个容易被忽视的时机问题用户刚粘贴完大图图片还在上传中就立刻点了表单提交。CKEditor的getData()方法会把img的src拿成临时blob地址或者空地址结果保存后图片就丢了。解决方案是提交前遍历编辑器内容检测是否有>async function submitForm(editor) { const hasPendingUpload editor.ui.view.element.querySelector(.ck-upload-progress); if (hasPendingUpload) { alert(有设计图正在上传请等待完成后再提交); return; } const content editor.getData(); // 继续表单提交逻辑 }这个代码处理得比较粗实际更稳的是在编辑器层监听uploadFinished事件维护一个上传任务计数计数不为0就不允许提交做一个轻量级的“脏检查”。5.2 从“能贴”到“好用”值得扩展的细节基础功能跑通之后有几个我觉得性价比特别高的增强点粘贴时自动处理超宽图片。后端在生成缩略图时顺手检测图片宽度超过1280px的加一个CSS类编辑器里默认max-width: 100%避免一张4K大图直接把版面撑破。图片水印。汽车企业的设计图外发或归档时通常要打水印建议在做缩略图时由后端统一叠加水印不在前端处理因为前端水印可以被绕过。插入图片的命名规范化。如果接口接收bizType字段后端可以考虑根据业务类型归档到不同目录方便后续做文件生命周期管理比如质量单的归档策略是3年可以定时清理。日志埋点。上传接口记录操作人IP、用户ID、文件大小、来源页面别说这是小题大做图纸外泄排查的时候这套日志能救命。路过一次实施之后我的感受是不要指望开箱即用的富文本编辑器能直接满足制造业场景很多默认设置是从互联网产品那套便捷优先的思路来的到了内网系统和重场景终归要定制。但也不要一开始就把它想得多玄乎原理吃透、示例跑通后面所有问题都有章可循。最后分享一个实际操作里的小技巧联调阶段别用模拟的假接口直接把生产环境的文件服务地址配上权限要做好隔离因为很多问题只有真实环境才会暴露比如Nginx上传大小限制、会话超时时间、证书问题。用假接口联调过的东西上生产环境基本都要再返工一遍。
返回列表