
昨天实施群里又有人喊医生从Word里复制一大段带图片的病历粘贴到医院HIS系统的CKEditor编辑器里图片全变红叉问“示例在哪”。这个问题我前前后后回答过很多遍每次都能从提问者的语气里听出同一个困惑网上翻遍了找到的要么是官方文档翻译要么是只贴半段代码的博客拿回来根本跑不通。表面看是在找“CKEditor粘贴Word图片的示例”但真正缺的不是某个函数而是搞明白从Word到剪贴板、再从剪贴板到CKEditor中间到底发生了什么以及图片从编辑器里冒出来之后后台怎么接住它、怎么存进病历。这篇就按我实际改HIS前端时的一套流程来写先讲清楚原理再给一段可以直接抄的CKEditor 4示例然后把医院环境里的鉴权、附件表、打印兼容这些坑全摆出来。HIS实施工程师、驻场开发、做医疗文档系统的程序员应该都能在里面捞到点东西。1. Word粘贴病历图片为什么总要“变红叉”很多人第一反应是CKEditor配置问题改几下就能好。但把问题拆开看真正的原因通常藏在剪贴板的内容构成里。1.1 剪贴板里到底塞了什么当医生在Word里选中一段带图片的病历并按CtrlC时Windows剪贴板并不是只放了一份数据。它同时塞进了多种格式纯文本、带Word样式的HTML片段、可能还有一张位图版本。浏览器拿到手之后优先用HTML片段来渲染因为只有它才能保留图片位置和大部分排版。于是问题来了这个HTML片段里的图片引用在复制粘贴时会有几种不同的表现形态。第一种是file:///开头指向本地临时目录比如C:/Users/Administrator/AppData/Local/Temp/xxx.png。这种引用在网页里就是典型的“红叉”因为浏览器出于安全限制不允许网页脚本去读取本地文件内容即便这个文件是刚刚由Office生成的临时文件也不行。第二种是data:image/png;base64,xxxxx。这种形式把图片二进制直接编码成了字符串只要不出格就能在网页上显示出来但它也有自己的麻烦——字符串长度巨大一张几MB的CT图片转成base64后会让HTML膨胀到原文的四五倍直接塞进数据库会把记录撑得很难看检索、备份、迁移都受影响。第三种是老版本Word产物常见于VML的v:shape和v:imagedata标签里。这些标签是十几年前微软为了兼容IE定义的矢量标记语言现代浏览器根本不把它当正常图片渲染。CKEditor虽然提供了pastefromword插件去清理这类标签但清理完之后图片到底能不能显示还是取决于底层的src引用是不是真实可读。1.2 官方示例在哪它到底解决了什么这个问题本身没有标准答案因为“示例”分好几类。CKEditor官方发行包里确实带了Paste from Word的示例页面解压后在samples目录下能找到展示的是把Word过来的HTML样式过滤成相对干净的内容。不少实施工程师找到这个示例兴奋地搬过去试结果发现Word表格边框能用了图片却还是破的于是继续困惑。原因在于官方示例解决的问题是“从Word粘贴的样式清理”不是“图片上传”。CKEditor作为一个编辑器它只负责在你编辑内容时把图显示出来至于图最终存到哪个服务器、以什么URL引用那是CMS、OA、HIS这类业务系统自己的事情。所以官方文档里只会教你怎么配filebrowserUploadUrl让你手动通过工具栏图片按钮上传不会给你做“粘贴即自动上传”。这个差异一定要先认清。网上确实能搜到不少“CKEditor粘贴图片自动上传”的第三方示例但它们大多是为个人博客或后台管理系统写的套路通常是监听paste事件把base64变成BlobPOST到一个通用上传接口然后返回URL替换。这类示例最大的问题是什么是它们默认后端接口不需要鉴权、默认上传的文件不需要和病历文书关联、默认图片存哪里都无所谓。把这些代码直接丢进HIS环境轻则接口报401重则出现任意账号都能往病历附件目录塞文件的严重漏洞。1.3 你到底需要的是哪一类“示例”想清楚需求再动手能省一半时间。如果只是想“把文字粘进来图片不显示也行”那官方示例都不用看关掉粘贴图片功能即可。如果要求“医生粘贴的图片能变成服务器文件并在编辑器里显示”最简单的示例就是网上一堆通用后台的写法改一下接口路径就能用。如果要求“图片上传后写入病历附件表、和当前文书绑定、打印时还能带得出来”那就不只是示例问题还需要设计上传接口的入参、返回结构、权限校验和存储约定。医院场景下绝大多数真实需求属于这一类。接下来的内容我按第二类和第三类的交集来展开核心粘贴逻辑可以独立复现附件表和鉴权做成最小可用方案方便你接进自己的HIS。2. 三条实现路线选哪条不后悔CKEditor 4做粘贴图片处理大方向上无非三条路。每条路都有适用场景也都有各自的坑我分别拆开讲。2.1 路线A拦截粘贴内容改造HTML后再插入核心思路是监听编辑器的paste事件在CKEditor把HTML插入页面之前先拦下来做一次“手术”用正则或DOM解析找出所有base64图片异步上传等所有文件URL回来后把编辑后的HTML重新交给编辑器插入。这条路线最大的好处是你对最终插入的内容有完全控制权插入到编辑器里的HTML干净、可预测后端存库时不用额外清洗一大坨base64。缺点是异步处理需要处理好时序如果一张粘贴内容里有五六张图片每一张都要发一个请求当全部请求返回后才能插入用户会感觉有一小段等待时间而且中途如果用户切走了光标插入位置可能不太对。代码骨架大致长这样editor.on(paste, function (evt) { var html evt.data.dataValue; // 没有base64图片就不干预 if (html.indexOf(data:image) -1) { return; } evt.cancel(); // 提取所有base64内容逐张上传后替换 // ... uploadImages(images).then(function (urlMap) { var newHtml replaceBase64WithUrl(html, urlMap); editor.insertHtml(newHtml); }); });这条路线也是我下面完整示例采用的主方案。它不需要等编辑器DOM渲染完再去摸节点逻辑上更直白排查问题也容易。唯一的代价是要理解paste事件里evt.data.dataValue的含义以及evt.cancel()会让默认插入失效这件事。2.2 路线B粘贴后遍历编辑器DOM把本地图换掉有人不喜欢拦paste事件怕把剪贴板里的复杂格式搞坏于是选了另一条路先让CKEditor按照默认方式把内容粘进去然后监听afterPaste等编辑器渲染完用editor.document.find(img)遍历所有图片节点凡是src以data:或file:开头的都视为待上传图片上传成功后再把src替换成服务器URL。这条路线对用户的交互干扰最小粘贴内容先展示出来图片后面慢慢换。但坑也很明显file://引用的图片在渲染阶段就已经破图了afterPaste里你拿到的img节点虽然存在但图片内容读不到只能删掉或者放个占位提示而data:的图虽然能显示但如果图片数量多、体积大页面会先渲染出一大段base64用户感官上会觉得编辑器卡一下。另外这条路线还有一个隐蔽问题遍历DOM时会把用户之前手动插入的普通图片也扫进去。虽然可以靠src前缀区分但总归多了一层判断逻辑代码没有路线A那么纯粹。2.3 路线C不做拦截让医生手动传一次如果项目工期紧、后端接口没法动那可以先退一步粘贴时只保留文字图片在粘贴后统一变成空位然后让医生点击工具栏的图片上传按钮一张一张自己传。实现上只需要配置好filebrowserUploadUrl让CKEditor的图片对话框指向你的上传接口。这条路线实现成本最低但使用体验最差。医生从Word复制一份多页病历里面可能有五六张检验报告截图如果每一张都要重新另存为文件再点上传实际使用中医生宁愿不贴了。我觉得它可以作为临时过渡方案但不建议作为正式交付。2.4 版本差异先确认别套错代码动手前先看一眼项目里用的是CKEditor哪个版本这比写代码更重要。老HIS系统里存量最多的是CKEditor 4写法就是上面说的editor.on(paste)加上对evt.data.dataValue做HTML字符串处理这也是本文示例默认的版本。如果你遇到的是CKEditor 5那上面的代码基本不能直接抄。CKEditor 5的重写发生在clipboardInput事件并且可以通过evt.data.dataTransfer.files直接拿到剪贴板里的图片文件对象不再是从HTML字符串里抠base64。示例需要重构成读取File、调用上传、再插入图片模型节点的方式。CKEditor 6目前在医院存量项目里很少见遇到的可能性极低这里不展开。总之先认清版本再决定去哪找示例。3. CKEditor 4粘贴图片自动上传示例这一节给出一段可以直接运行的完整示例。示例覆盖了监听粘贴、提取base64、转Blob、上传、回填URL、插入编辑器这一整条链路。后端接口不依赖任何框架你能看懂字段结构就可以自己实现。3.1 直接可用的JS示例假设页面里已经有一个名为editor1的CKEditor实例下面这段脚本放到初始化之后执行。// 把dataURL转成Blob对象便于放入FormData function dataURLToBlob(dataURL) { var arr dataURL.split(,); var mime arr[0].match(/:(.*?);/)[1]; var bstr atob(arr[1]); var n bstr.length; var u8arr new Uint8Array(n); while (n--) { u8arr[n] bstr.charCodeAt(n); } return new Blob([u8arr], { type: mime }); } // 上传Blob返回服务器图片URL function uploadBlob(blob) { return new Promise(function (resolve, reject) { var fd new FormData(); fd.append(file, blob, clipboard_ Date.now() .png); var xhr new XMLHttpRequest(); xhr.open(POST, /his/attach/upload, true); xhr.onload function () { if (xhr.status 200 xhr.status 300) { var json; try { json JSON.parse(xhr.responseText); } catch (e) { reject(new Error(上传接口返回的不是JSON)); return; } if (json.code 0 json.data json.data.url) { resolve(json.data.url); } else { reject(new Error(json.msg || 上传失败)); } } else { reject(new Error(HTTP xhr.status)); } }; xhr.onerror function () { reject(new Error(网络错误)); }; xhr.send(fd); }); } var editor CKEDITOR.instances.editor1; editor.on(paste, function (evt) { var html evt.data.dataValue; if (!html || html.indexOf(data:image) -1) { return; } // 阻止CKEditor默认插入等异步处理完成后再自己插入 evt.cancel(); // 匹配所有data:image开头的字符串无论它在img还是v:imagedata上 var dataRegex /data:image\/(png|jpe?g|gif|bmp|webp);base64,[^\s]/gi; var dataUrls html.match(dataRegex); if (!dataUrls || dataUrls.length 0) { return; } var tasks dataUrls.map(function (dataUrl) { var blob dataURLToBlob(dataUrl); return uploadBlob(blob).then(function (fileUrl) { return { oldUrl: dataUrl, newUrl: fileUrl }; }); }); Promise.all(tasks).then(function (items) { var newHtml html.replace(dataRegex, function (matched) { var found items.filter(function (item) { return item.oldUrl matched; })[0]; return found ? found.newUrl : ; }); editor.insertHtml(newHtml); }).catch(function (err) { console.error(粘贴图片上传失败保留原始内容, err); editor.insertHtml(html); }); });这段代码有几个关键点需要展开。第一evt.cancel()的作用是阻止CKEditor默认把原始HTML插入编辑区域。如果你不调用它编辑器会先插入一段包含大量base64的内容然后再由你自己的逻辑插入一份新内容最终页面里会出现两份粘贴内容这是很多人第一次写类似功能时最容易踩的坑。第二正则里把jpe?g写成了jpe?g目的是同时兼容jpg和jpeg两种后缀。[^\s]保证了base64字符串不会误吞掉后面的HTML引号或空格。如果你碰到过base64里有换行符导致匹配不上的情况可以在后面加一个\s*的容错但我建议后端在上传前统一做一次换行清理前端不需要过度容忍脏数据。第三Promise.all的方式会等所有图片全部上传完成后再一次性插入。如果某个文件上传失败会走catch分支把原始HTML原样插回去。这样图片虽然会变成base64显示但至少内容没有丢医生可以截图后再手动替换。这个兜底策略比“上传失败就什么都不给”要友好得多。第四上传函数里用了XMLHttpRequest而不是fetch主要是考虑老HIS项目里可能存在IE兼容需求。虽然现在主流环境基本都支持fetch但医院内网里总会有几台旧电脑用XHR能省掉不少兼容问题。3.2 后端最小接口约定前端代码里的/his/attach/upload接口后端需要做哪些事才能接住给一个最小约定照着实现就行。项目要求说明请求方式POSTmultipart/form-data字段名file对应前端FormData里append的file鉴权必须校验当前登录医生的session没有登录直接返回401大小限制建议单张10MB以内超出返回友好提示不要抛出500返回格式JSON{code:0,msg:,data:{fileId:,url:}}URL形式相对路径示例/his/attach/download?fileIdxxx其中最重要的是URL形式。我见过不少HIS项目在附件上传接口里直接返回http://192.168.1.100:8080/his/attach/download?fileIdxxx这种带IP和端口的绝对路径开发环境测试时一切正常等到医院网络调整、服务器迁移或上了负载均衡之后历史病历里的图片全变死链。更合理的做法是后端只返回相对路径前端在编辑器HTML里也存相对路径等最终渲染时由浏览器自动拼当前域名。这样无论服务器IP怎么变只要同一个域名能访问到下载接口历史病历就不会出问题。3.3 旧项目里怎么嵌进去不同技术栈的老HIS项目嵌入这段逻辑的方式略有差别。如果是传统JSP页面直接在页面底部写一个script块把上面的JS代码复制进去再在页面初始化时调用CKEDITOR.replace(editor1, {...})即可。关键是确保脚本在CKEditor实例创建后再绑事件否则CKEDITOR.instances.editor1还是个undefined。如果是Vue项目建议在mounted钩子里或者编辑器组件初始化的回调里完成绑定。CKEditor 4本身没有官方Vue组件很多项目是引入原生脚本后用this.$nextTick去replace的。这时要注意事件绑定不要写在组件的created阶段因为编辑器DOM还没有渲染出来。如果是.NET老项目后端上传Action需要注意IIS默认请求长度限制。看到接口返回404或413这类状态先检查web.config里的maxRequestLength必要时调大否则图片粘贴上传会被IIS前端直接挡掉。3.4 初始化配置的三个要点示例代码能跑起来还有一个前提条件CKEditor的初始化配置不能把允许的内容范围卡得太死。第一配置extraAllowedContent: v:shape[*];v:imagedata[*]。如果编辑器不允许VML相关标签粘贴过来的老式Word图片在进入编辑器之前就会被过滤掉后面再怎么处理都来不及。第二不建议把allowedContent设成false或莽开全部内容。虽然这样能让各种标签都放进来但同时也意味着Word带过来的大量垃圾样式、甚至潜在的脚本标签都能进入编辑器对病历系统的安全性是威胁。正确的做法是保留pastefromword插件让它先清理一遍再通过上面的粘贴逻辑处理图片。第三pasteFromWordRemoveStyles这个参数要按实际需求设置。如果病历排版要求严格就保留Word的部分样式如果只要求正文内容可以去掉字体颜色等冗余样式。医院通常比较在意表格边框和标题样式所以默认值别乱改先测一版再决定。4. 从“能上传”到“能存病历”的四个关键设计示例写完了、后端接口也通了图片能在编辑器里显示了这算“能上传”了。但放到HIS环境里离“能存病历”还差几步。这四步是很多网上示例完全不会提到的也是现场最容易翻车的地方。4.1 上传接口必须鉴权不能裸奔我见过有开发图省事把上传接口写成任何人都能POST的公开接口理由是“医院HIS是内网系统没人攻击”。这想法很危险。医院内网里同样有扫描器、有测试脚本甚至会有外包人员连进来而病历图片里往往带着患者姓名、诊断、检查报告一旦这个接口被滥用或者被用来上传脚本文件后果是整个附件目录都可能被拖下来。最小限度要做三件事接口校验当前登录用户是否具备医生或护士角色没有有效session直接拒绝上传文件扩展名做白名单只允许png、jpg、jpeg、gif、bmp、webp文件存储目录禁止执行脚本医院用的Windows Server IIS或Linux Nginx都要显式配置。再进一步建议把上传人和上传时间记录进日志。病历属于医疗文书后期一旦需要审计、追溯这些信息就是线索。这个要求不小但真出事时能救命。4.2 图片必须和病历文书关联而不是散落一地最典型的错误设计是上一篇博文里说“返回URL存进HTML字段就完事”。短期内看起来能用但长期问题很多——同一张图片被医生重复粘贴到两份不同病历时会存两份一模一样的文件如果某天需要调阅某位患者的全部影像附件没有任何索引可以查如果病历发生修订旧版图片文件没有保留策略磁盘会被撑爆。建议至少建一张附件表create table emr_attach ( file_id varchar(32) primary key, biz_id varchar(32) not null comment 业务ID例如病历文书ID, biz_type varchar(20) not null comment 业务类型例如emr、exam, file_name varchar(255) not null, file_path varchar(500) not null, file_size int not null, mime_type varchar(100) not null, upload_user varchar(50) not null, upload_time datetime not null );编辑器HTML中只需要存/his/attach/download?fileIdxxx这样的引用即可。这也是为什么后端返回结构里要同时给fileId和urlurl用于编辑器和最终展示fileId用于关联表、打印、审计等后续环节。4.3 打印、归档和电子签章的兼容问题病历最终要打印归档还要过电子签名。这两个环节经常把图片全外链的系统坑一遍。先说打印。如果打印走的是浏览器直接打印当前session还在同源的相对URL能正常加载问题不大。但如果医院用的是独立的打印服务、报表服务那个服务通常是无状态的HTTP调用不携带用户Cookie那么图片URL里的session校验就会失败打印出来的病历上全是空框。我给这类项目的建议是下载接口支持短期token比如/his/attach/download?fileIdxxxtokenxxxxxtoken由打印服务调用时向认证中心换取有效期10分钟用完即弃。这样既不影响浏览器端使用也不会破坏无状态架构。归档和电子签章类似。签章系统要取文书的“原文”做哈希如果原文HTML里嵌的是满屏base64哈希值不稳定而且计算量大如果改成相对URL引用则签章系统需要能访问到附件下载接口。这一点在方案设计的时候就要和签章厂商确认清楚别等到上线联调才发现两边对不上。4.4 批量图片和体积的性能优化医生从Word粘贴一份出院小结里面可能带了十几张报告截图每张2MB到5MB不等。如果前端直接把这些base64转Blob然后逐一POST页面大概率会卡住上传耗时也会让医生不耐烦。我踩过的最有效的方法是前端压缩后再上传。用canvas把图片宽度压缩到1200像素以内质量设为0.8一张原图5MB的检验报告压完之后一般在200KB左右完全能满足病历存档的阅读需求。压缩之后的图片体积明显减小上传速度提升好几个量级编辑器也不会因为超大base64卡顿。唯一要提醒的是不要压缩病理切片这类对细节极其敏感的原图HIS里那种普通的报告截图、伤口照片、体征照片都适合压缩。如果单次粘贴的图片非常多可以限制一次最多处理10张超出部分提示分批粘贴避免一个请求携带太多文件导致后端超时。5. 现场排查与避坑速查表示例改了、接口通了真正上线时还会有一堆现场问题。我按实际踩坑频率列一张速查表照着排查就能省下不少时间。5.1 粘贴后图片是红叉别急着改代码先用控制台看一下这段红叉图片的src到底是什么。如果是file:///开头说明图片引用的是本地路径浏览器出于安全限制拿不到内容这属于剪贴板机制本身的限制不是你代码能解决的。遇到这种情况最务实的应对是告诉医生不要直接从Word复制图片改用截图工具把图片区域截下来再粘贴。截图会以位图格式进入剪贴板浏览器在粘贴时能拿到真正的图片文件上传逻辑才能捕获它。如果医院还在用老旧的IE模式浏览器这一步尤其重要——老版本Edge和IE从Word复制图片时大概率生成file://引用而现代Chrome内核浏览器在多数场景下会生成base64可操作性完全不同。5.2 图片上传成功但顺序乱掉异步上传时最容易犯的错误是每张图片上传完成后立刻替换HTML结果返回时间不一致图片顺序变得乱七八糟。解决办法就是示例里用的Promise.all所有请求全部完成后一起替换图片在HTML里保持原有顺序不变。如果你不想等所有图片上传完再插入也可以换成串行上传定义async循环逐个上传逐个替换。串行节省不了总时间但可以避免并发请求打爆小带宽的内网上传链路。5.3 排查流程三个地方看一眼遇到粘贴图片相关的问题我一般先看三处。第一处是浏览器控制台的Network标签确认粘贴时有没有发出/his/attach/upload请求。如果根本看不到请求说明事件绑定没生效或者数据里根本没有base64图片问题在编辑器配置或事件写法如果请求发出去但报401或404说明接口路径或者权限校验有问题如果请求500则要看后端日志。第二处是编辑器初始化配置。把extraAllowedContent和插件列表打印出来看一遍确认VML相关标签没有被提前过滤。第三处是浏览器版本。遇到奇怪现象先换Chrome内核浏览器试试很多时候所谓“兼容问题”只是因为医院内网默认浏览器太老而项目里用到的现代Web API不支持。5.4 常见问题速查表现象可能原因处理建议粘过来全是红叉file://本地路径引用改用截图粘贴或改用Chrome内核浏览器粘贴后没任何图片且没有网络请求事件没绑定或编辑器实例拿错确认CKEDITOR.instances.editor1存在上传后图片重复出现没有调用evt.cancel默认插入和手动插入叠了A方案中确保先cancel再insertHtml图片顺序错乱并发上传后按完成时间替换Promise.all处理完再统一替换接口400/413后端请求体限制调整IIS maxRequestLength或Nginx client_max_body_size接口401session校验失败确认上传请求携带了Cookie凭证编辑器里显示正常但打印为空框打印服务无状态不携带session下载接口改用临时token老电脑粘贴大图卡死base64太大或atob内存溢出前端压缩图片后再上传5.5 最后一个排查建议给实施工程师同事的最后一句话别在编辑器源码上死磕三天才发现是后端接口没通。所有粘贴上传的路径都是“前端拦截—发请求—返回URL—回填编辑器”这条线中间任何一个环节断了现象都会是“图片显示不出来”。先看Network再看后端日志一般十分钟之内能定位到问题出在哪一节。我自己在给医院做升级时踩过最狠的一次坑是测试环境把上传地址写成了开发机的IP端口当时一切正常上线后所有图片请求都打到了那台已经关掉的开发机上。后来把所有附件URL全部改成相对路径才算彻底解决。从那以后我养成了一个习惯凡是编辑器里的图片引用一律不在HTML里写死IP只存相对路径渲染时由浏览器决定域名。这个小习惯推荐给你也试试。