医院里的HIS系统用了十年八年的都有,前端界面基本还是老一套,最典型的就是CKEditor这种老牌富文本编辑器还在扛大梁。医生写病历时,经常需要把影像报告、检验单、外部系统里的截图直接粘到病历正文里,结果一粘——要么图片丢了,要么变成一片空白,要么弹出一堆看不懂的英文报错。这个问题我前前后后帮好几家医院处理过,今天就专门聊聊:CKEditor粘贴病历截图,后端是PHP,到底怎么把图片稳稳当当传上去。
先说结论:核心思路是拦截剪贴板事件,拿到图片数据,转成base64或者二进制流,走Ajax异步传给PHP接口,PHP端完成校验、存储、回传URL,再把URL插入编辑器内容。听起来不复杂,但真正落地时,坑主要集中在三块:一是剪贴板数据在不同浏览器里的兼容性差异,二是CKEditor版本不同导致的事件处理方式不同,三是医院内网环境下PHP上传配置和文件存储路径的细节。这三块不处理好,线上就会各种幺蛾子。
1. 需求拆解与方案选型
1.1 医院HIS场景的特殊性
医院HIS系统有一个很大的特点:环境封闭、浏览器版本老旧、网络受限。一线科室用的电脑可能还是Win7,浏览器是IE11或者某个老版本Chrome内核的国产浏览器。这意味着你不能一上来就上那些太前卫的API,比如navigator.clipboard.read()这种现代接口,在老旧浏览器里根本不存在,得用底层的paste事件加clipboardData对象去取数据。这是整个方案的第一个约束。
另外,HIS系统通常跑在内网,无法访问外网CDN资源,CKEditor往往还是本地部署的,版本也比较老,常见的CKEditor 3.x、4.x都有,甚至还有改过源码的定制版。这就决定了你在编码时要考虑兼容性,尽量用原生事件去拦截,而不是依赖CKEditor某版本特有的API。通用性,是这个场景里最重要的设计原则。
还有一个隐形需求:病历是医疗文书,涉及患者隐私,图片上传后不能随便放在谁都能访问的目录下,必须有权限控制,目录路径也不能暴露患者姓名。有些医院甚至会要求图片文件做脱敏处理、限制访问来源IP。这些属于合规层面的隐性需求,架构设计时就要考虑进去。
1.2 为什么选择“拦截粘贴事件 + Ajax上传”路线
可能有人会说,这不就是让医生自己先把截图存到本地,再点编辑器里的图片上传按钮选文件嘛?这条路不是不行,但体验太差。医生每天接诊几十个病人,粘贴截图是高频操作,如果每次都要“截图-保存-找到文件-选择上传-再调整路径”,效率直接砍半。而且很多医生根本不愿意多这一步,最后就不传了,病历记录不完整,这是医务科最头疼的事。
所以,正确的交互逻辑就是:医生复制一张图(不管是截图工具截的,还是其他系统界面上右键复制的),直接切到HIS病历编辑器里,按Ctrl+V,图片自动上传,编辑器里直接显示出图片。整个过程,医生不需要离开病历书写界面,也不需要选择任何文件路径。这个交互升级,一天下来能给每个医生省下不少琐碎时间。
技术路线上有两种主流实现:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| base64直插 | 把图片转base64直接写入编辑器的img src | 实现简单、无异步请求 | 图片数据量大时病历体积膨胀、数据库存储压力大 |
| Ajax异步上传 | 截取图片后上传服务器,服务器回传URL,再把URL写入img src | 服务器存储可控、病历体积小、方便统一管理图片文件 | 需要前后端配合、复杂度略高 |
我优先推荐第二种,异步上传。原因很简单:案例多、可维护、后续好扩展。比如后续会诊时要批量导出病历,或者病历归档到电子病历系统时,图片URL比base64数据好处理得多。你在做的时候也能感受到,把图片交给PHP接口处理,后续加水印、压缩、脱敏都很方便。
1.3 技术栈与总体流程图解
整体架构大概是这样的:
医生复制截图 -> 编辑器所在页面监听paste事件 -> 从剪贴板取图片数据 -> 转成Blob对象 -> 构造FormData -> 异步POST到PHP接口 -> PHP端校验并存储图片 -> 返回JSON(含图片URL) -> 前端拿到URL,在光标处插入 <img src="...">每一步都依赖前后端的紧密配合,下面我按模块详细拆开讲,每一步的代码都能直接参考着改。
2. 前端核心实现:从剪贴板到编辑器
2.1 拦截paste事件,兼容不同浏览器
核心的思路是这样的:监听编辑器所在DOM(或document)的paste事件,在事件回调里检查event.clipboardData里的数据格式,如果包含图片类型,就阻止默认行为,取出来自己处理。这个逻辑,在CKEditor 4和CKEditor 3时代都适用,因为拦截层面在编辑器之外,跟CKEditor自身的事件不冲突。
看具体代码:
document.addEventListener('paste', function(event) { // 旧版Chrome/IE11 用 window.clipboardData var clipboardData = event.clipboardData || window.clipboardData; if (!clipboardData) { return; } var items = clipboardData.items; if (!items) { return; } for (var i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { // 拿到的就是图片文件对象(Blob) var file = items[i].getAsFile(); // 阻止编辑器默认插入行为,交给自定义逻辑 event.preventDefault(); // 上传处理 handlePastedImage(file); break; } } }, false);这段代码有几个细节值得说说:
第一,window.clipboardData是IE的写法,在老版本Edge、IE11里用到,现代浏览器都是event.clipboardData,所以保险起见,两个都取。HIS环境里尤其要加这层兼容,否则有些老内核浏览器直接报错。
第二,items[i].getAsFile()取到的就是一个合法的File对象,可以直接进FormData,不需要额外转码,这点非常舒服。
第三,只取第一张图片。有些场景下医生可能一次复制了多张图,但病历正文里一张一张插入更清晰,所以循环里命中第一张图就返回,这是有意的选择。
2.2 封装上传函数:FormData + AJAX
拿到File对象后的下一步,就是把它交给后端。这里需要注意的是:不能用同步Ajax,否则浏览器会卡死;不能用表单提交,否则会刷新页面,病历没保存全丢了。正确做法是异步FormData提交。
我封装了一个通用函数,支持回调,生产环境里可以直接改一改拿来用:
function handlePastedImage(file) { // 简单校验,不是图片就直接放弃 if (!file || file.type.indexOf('image') === -1) { return; } // 限制大小:默认10MB,超过直接提示(细节后面说) var maxSize = 10 * 1024 * 1024; if (file.size > maxSize) { alert('图片太大,请压缩后再粘贴,最大支持10MB'); return; } var formData = new FormData(); formData.append('file', file, 'paste_' + Date.now() + '.png'); // 额外带一个参数,标识来源是粘贴,方便后端区分 formData.append('source', 'ckeditor_paste'); // 这里做Ajax请求,用XMLHttpRequest更通用 var xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload_img.php', true); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { if (xhr.status === 200) { try { var resp = JSON.parse(xhr.responseText); if (resp.code === 0) { // 通知业务方:上传成功,获得到图片地址 insertImageToEditor(resp.data.url); } else { alert(resp.msg || '上传失败'); } } catch (e) { alert('上传返回数据异常'); } } else { alert('上传失败,HTTP状态码:' + xhr.status); } } }; // 可以加超时处理,15秒超时 xhr.timeout = 15000; xhr.ontimeout = function() { alert('上传超时,请检查网络后重试'); }; xhr.send(formData); }有个容易踩的坑:对象URL创建方式。我上面用的是FormData直接传File,这种方式最稳。但有些老文章会让你用URL.createObjectURL(file)或者FileReader.readAsDataURL转base64再传,这在现代浏览器里能用,但在老浏览器上内存消耗大,图片一大就容易崩,而且base64字符串体积比原图大三分之一,传输效率也不高。所以,能用FormData就直接用FormData。
2.3 把图片插入编辑器光标处
上传成功拿到URL后,第二步是把图片插到编辑器正文的光标位置。这里的坑在于:不同版本的CKEditor,插入图片的方式不一样。
CKEditor 4的做法:
function insertImageToEditor(url) { var editor = CKEDITOR.instances['content']; // 你自己的编辑器实例名 if (!editor) { return; } // 恢复到焦点 editor.focus(); // 插入HTML editor.insertHtml('<img src="' + url + '" alt="病历截图" style="max-width:100%;" />'); }CKEditor 5的做法略有不同,它推荐用insertContent或者view接口,但因为HIS系统里CKEditor 5都很少见,这里不强求,主要是4代和3代。3代也支持insertHtml,所以上面这一段代码是能通用的。
还有个小技巧:如果图片是旋转过的(比如手机拍摄的照片),建议在插入时给img加一个onload检查,必要时做CSS旋转处理。不过病历截图大多是电脑屏幕截图,默认方向正确,这个属于可选优化。
3. PHP后端接收与存储实现
3.1 接收文件并严格校验
前端那边Post过来一个字段名为file的文件,PHP这边接收处理,首先要做的是安全校验。很多医院HIS被人挂马,大部分原因就是上传接口没做校验,什么文件都往服务器上放,这不等于给攻击者递刀。
我写了一个相对完整的PHP接收逻辑,直接看:
<?php // api/upload_img.php session_start(); // 1. 简单登录态校验(医院内网也必须做) if (empty($_SESSION['doctor_id'])) { die(json_encode(['code' => -1, 'msg' => '登录失效,请重新登录'])); } // 2. 请求方式校验,只接受POST if ($_SERVER['REQUEST_METHOD'] !== 'POST') { die(json_encode(['code' => -1, 'msg' => '请求方式不允许'])); } // 3. 检查是否有文件 if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { $errMsg = '上传失败'; if ($_FILES['file']['error'] === UPLOAD_ERR_INI_SIZE || $_FILES['file']['error'] === UPLOAD_ERR_FORM_SIZE) { $errMsg = '文件超出大小限制'; } die(json_encode(['code' => -1, 'msg' => $errMsg])); } // 4. 用finfo检查真实文件类型,不要信任$_FILES['type'](这是客户端给的,可伪造) $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $_FILES['file']['tmp_name']); finfo_close($finfo); $allowedMimes = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp' ]; if (!isset($allowedMimes[$mimeType])) { die(json_encode(['code' => -1, 'msg' => '仅支持JPG、PNG、GIF、WEBP图片格式'])); }这里重点解释一下为什么用finfo去检查MIME类型,而不是直接信任$_FILES['file']['type']。
$_FILES['file']['type']是浏览器客户端带过来的,完全可以被人用工具篡改。也就是说,你以为收到的是图片,但实际上可能是人家传了个PHP脚本伪装成图片。如果后端不做二次验证,直接拿$_FILES['file']['type']去判断,再把文件存到可执行的web目录里,那等于把整个内网送人了。finfo_file是PHP内置的基于文件内容头部字节的检测,生成一个假图片头部非常困难,可以过滤绝大多数恶意文件。
3.2 存储路径设计与防重命名
医院HIS系统的文件存储,路径设计不能太随意。这类系统大多数跑在Windows Server或者Linux服务器上,两种环境的目录处理不太一样,但设计原则一致:按日期分目录、文件名随机化、不在文件名里暴露患者信息。
我常用的目录结构是:
$baseDir = __DIR__ . '/../../uploads/medical_record/'; // 示例子目录:2025/06/17/ $dateDir = date('Y/m/d'); $saveDir = $baseDir . $dateDir; if (!is_dir($saveDir)) { mkdir($saveDir, 0755, true); // 递归创建 }文件名这块,不要用原文件名,也不要带时间戳就叫20250617xxxx.jpg这种容易猜的顺序。医院电子病历的图片,最好用随机串做文件名,防止别人按日期遍历下载:
$ext = $allowedMimes[$mimeType]; $newFilename = date('His') . '_' . strtoupper(bin2hex(random_bytes(8))) . '.' . $ext; $savePath = $saveDir . '/' . $newFilename;random_bytes是PHP 7之后推荐的安全随机数生成函数,以前的rand()、mt_rand()都不要用做文件名生成,太容易预测了。一个看起来不起眼的文件名随机化,实际上挡住了很多低级的未授权访问风险。
然后就是移动文件:
if (!move_uploaded_file($_FILES['file']['tmp_name'], $savePath)) { die(json_encode(['code' => -1, 'msg' => '存储失败,请检查目录权限'])); }move_uploaded_file是PHP专门处理上传文件移动的函数,它会检查文件是不是POST上传上来的,避免用本地文件路径伪造。这一步也是常规操作,但很多新手会直接写file_put_contents,那就不对了。
3.3 可选增强:生成缩略图与水印
不少医院病历系统还要求图片水印,内容通常是“患者姓名+病历号+上传时间”,防止病历截图被二次传播。这个需求用GD库就能实现:
// 仅给上传成功后的图片加水印(以jpg为例) function addWatermark($path, $text) { $imgInfo = getimagesize($path); if ($imgInfo === false) { return false; } $mime = $imgInfo['mime']; switch ($mime) { case 'image/jpeg': $im = imagecreatefromjpeg($path); break; case 'image/png': $im = imagecreatefrompng($path); break; default: return false; } $font = __DIR__ . '/fonts/simhei.ttf'; // 服务器上要放字体文件 $fontSize = 14; $white = imagecolorallocate($im, 255, 255, 255); $black = imagecolorallocate($im, 0, 0, 0); $x = 10; $y = $imgInfo[1] - 20; // 左下角 // 加个黑色描边效果,白色文字更清晰 imagettftext($im, $fontSize, 0, $x+1, $y+1, $black, $font, $text); imagettftext($im, $fontSize, 0, $x, $y, $white, $font, $text); // 覆盖保存 switch ($mime) { case 'image/jpeg': imagejpeg($im, $path, 85); break; case 'image/png': imagepng($im, $path); break; } imagedestroy($im); }水印这个功能不一定每个医院都要求,但如果有要求,上面这段可以直接用。字体文件要用服务器上真实存在的路径,Windows服务器常用simhei.ttf(黑体),Linux服务器如果没有中文字体,可以去下载一个开源的文泉驿或者思源黑体。
3.4 PHP配置要点:大小限制与超时
还有一个容易忽略的环节:php.ini的配置。HIS系统如果部署很久了,php.ini里的upload_max_filesize可能只有2MB,医生贴一张高清屏幕截图轻松超过,直接上传失败,前端却报一堆看不懂的错误。
跟图片上传相关的配置项至少有这几个:
| 配置项 | 建议值 | 说明 |
|---|---|---|
upload_max_filesize | 10M | 限制单个上传文件大小 |
post_max_size | 20M | 表单整体提交大小,必须大于单个文件限制 |
max_file_uploads | 20 | 单次最多上传文件数 |
max_execution_time | 300 | 脚本最大执行时间,图片较大时防超时 |
修改php.ini后记得重启PHP-FPM或者Apache,不然不生效。如果是Windows环境,很多人用的是phpStudy或者其他集成环境,修改后重启服务就行。
另外提醒一句:判断上传失败时,$_FILES['file']['error']里有很多种错误码,对应的含义不同,前端最好都映射成中文提示,不要直接吐一串数字给医生看。
4. 常见问题与排查技巧实录
4.1 粘贴后没反应,事件根本没触发
这是最常遇到的问题。排查时先打开浏览器开发者工具,在Console里看看document.addEventListener('paste', ...)是否报错。如果控制台有红色报错,绝大多数是JS语法错误或者依赖没加载全。
还有一种情况:编辑器的contenteditable区域是iframe,事件没有绑定到正确的document。CKEditor 3和4的编辑区其实在一个iframe里,document.addEventListener('paste')绑在主页面document上,但粘贴动作发生在iframe文档里,事件可能传不过来。解决办法是改用以下写法:
// 监听CKEditor内部iframe的paste事件 editor.on('contentDom', function() { var editable = editor.editable(); editable.attachListener(editable, 'paste', function(e) { var eventData = e.data.$; var clipboardData = eventData.clipboardData || window.clipboardData; if (clipboardData && clipboardData.items) { for (var i = 0; i < clipboardData.items.length; i++) { if (clipboardData.items[i].type.indexOf('image') !== -1) { eventData.preventDefault(); handlePastedImage(clipboardData.items[i].getAsFile()); break; } } } }); });这个写法的好处是直接挂在编辑器内容区域的事件上,不依赖主document的事件冒泡,兼容性更好,推荐在CKEditor集成时优先采用这种方式。
4.2 上传成功后图片不显示,只有占位符
这种问题通常是返回的URL不正确,比如返回了相对路径,但编辑器里解析时的基础路径不对。排查时,右键点击占位图“查看图片地址”,看看地址是什么。
更稳妥的做法是,后端返回绝对URL:
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://'; $url = $protocol . $_SERVER['HTTP_HOST'] . '/uploads/medical_record/' . $dateDir . '/' . $newFilename;不要返回那种/uploads/xxx.jpg的相对地址,虽然前端能拼,但一旦HIS系统部署在子目录下,路径就乱了。直接返回全路径,省心。
4.3 图片上传成功,但刷新页面后图片裂了
这个问题有一个非常经典的坑:文件确实上传上去了,但PHP获取的$_SERVER['HTTP_HOST']和实际访问域名不一致。常见于反向代理环境下,比如nginx转发时没带上Host头,PHP拿到的是内网IP或者内网域名。
解决办法是,让服务器返回一个存储相对路径,前端用固定前缀拼接。比如后端返回/uploads/medical_record/2025/06/17/xxxx.jpg,前端在插图片时统一加一个全局配置的基础URL:
var imageBaseUrl = window.HIS_CONFIG.imageBaseUrl || ''; insertImageToEditor(imageBaseUrl + resp.data.url);这招在真实的内网HIS环境里非常管用,排查起来也快。
4.4 老浏览器兼容性问题:IE11无法拿到items
前面提过,IE11对剪贴板数据格式的支持不如现代浏览器。如果clipboardData.items拿不到图片,退而求其次可以试试clipboardData.getData('text'),但图片数据很难取。
较稳妥的兜底方案是:降级使用文件上传。也就是说,如果浏览器不支持粘贴截图,就在编辑器工具栏加一个“上传图片”按钮,医生可以手动选图。很多医院实际是两种方式并存,兼容好的浏览器走粘贴路线,老浏览器走文件选择路线。
4.5 超大图片导致前端卡顿
这个问题容易被忽视。有的医生贴的不是屏幕截图,而是直接复制了一张CT影像的原始图片,单张可能20MB以上。这样的图片即使上传成功,浏览器渲染时也会卡,病历加载时更卡。
我给出三个建议:
第一,前端限制上传大小,超过10MB直接给出友好提示:图片过大请压缩后再粘贴。 第二,后端在存储时主动压缩,比如用GD库把超过2500px宽的图片等比缩放。 第三,前端插入编辑器时给img加max-width:100%,至少保证预览不撑爆界面。
压缩可以加在3.3的水印逻辑里,一并处理:
// 如果图片过宽,等比缩放 $maxWidth = 2500; $width = $imgInfo[0]; if ($width > $maxWidth) { $scale = $maxWidth / $width; $newWidth = $maxWidth; $newHeight = intval($imgInfo[1] * $scale); $thumb = imagecreatetruecolor($newWidth, $newHeight); imagecopyresampled($thumb, $im, 0, 0, 0, 0, $newWidth, $newHeight, $imgInfo[0], $imgInfo[1]); // 用$thumb替换$im继续做水印 $im = $thumb; }经过缩放再加水印,图片文件大小能砍掉一半还多,后续病历存储压力大大降低。
4.6 目录权限问题:图片写不进去
Linux服务器上如果保存目录的owner不是运行PHP的用户(比如nginx用户是www-data),就会出现写不进去的情况。mkdir和move_uploaded_file都会失败。
排查方法很简单,在PHP代码里加两步:
// 确保目录存在,且有写权限 if (!is_dir($saveDir)) { @mkdir($saveDir, 0755, true); } if (!is_writable($saveDir)) { die(json_encode(['code' => -1, 'msg' => '目录不可写,请联系管理员'])); }生产环境里正确做法是:上传目录的属主设为PHP运行用户,权限用755(目录)和644(文件),不要图省事直接chmod 777,那等于给所有用户开了一扇门。
4.7 断网、超时、并发上传的偶发问题
医生操作频率高,有时候同一个医生连续贴好几张图,或者多个医生同时上传,对服务器是个考验。遇到并发上传时,PHP自带的文件处理够用,但要注意:
第一,服务器临时目录sys_get_temp_dir()可用空间要充足,默认的upload_tmp_dir空间不足时,文件会上传失败。 第二,给Ajax加超时处理,前端15秒无响应要给用户明显提示,不然医生会以为卡死了,反复点粘贴,造成重复上传。
如果服务器是Windows环境,临时目录一般没问题,但Linux服务器上/tmp分区可能很小,需要改upload_tmp_dir到数据盘的一个专门目录。
4.8 常见问题速查表
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 粘贴后无任何反应 | paste事件未触发,或事件绑错document | 改用CKEditor的事件监听方式 |
| 控制台报CORS错误 | 前后端不同域 | 后端加跨域头,或改成同域部署 |
| 上传接口返回500 | PHP配置或目录权限问题 | 查错误日志、检查upload配置、目录权限 |
| 上传成功但图片裂开 | URL拼错或访问路径不对 | 返回绝对URL或统一加前缀 |
| 图片被拉伸变形 | 未加max-width限制 | img标签加style |
| 大图上传失败 | upload_max_filesize过小 | 改php.ini并重启服务 |
| 图片带乱码 | 文件类型校验不严格,文件名带入特殊字符 | 用finfo校验、随机文件名 |
| 滚动加载病历非常卡 | 原图过大未压缩 | 后端加缩放处理 |
5. 生产环境部署的几点补充
最后补充几个生产环境相关的经验,都是实际项目里挨个踩出来的。
第一,建议在PHP接口里增加操作日志。哪怕是简单记一下医生ID、上传时间、文件名、文件大小,排查问题时能救命。出了医疗纠纷要追溯病历内容时,这一步也能证明系统操作留痕。
第二,图片存储目录不要把web可访问和web根目录放一起。像/uploads/medical_record/这个目录,建议放到Web根目录之外,通过PHP转发读取,加登录校验后才能访问。这样能防止有人猜测图片URL直接扒病历图。不过这个方案对开发量有要求,如果实在做不到,至少要在uploads目录下放一个index.html空文件禁用目录列表,同时给文件起随机名。
第三,考虑做定期的图片清理。病历图片一般不能随意删除,但临时上传失败残留的临时文件可以定期清理,避免服务器磁盘爆掉。
第四,如果医院后续要上电子病历归档系统,图片这部分的数据格式很重要。建议上传时同步记录文件的MD5值,归档时能校验文件是否被篡改:
$fileMd5 = md5_file($savePath); // 可以在数据库里存一条记录,关联病历ID、图片路径、MD56. 最后再分享一个小技巧
粘贴截图上传这个功能,我前前后后做了不少次,最想跟你说的一点是:单独做一个后台调试页,把前端JS、后端PHP都放上去,用HTML页面直接测,不要一上来就嵌到HIS系统大工程里调试。因为HIS系统本身代码量大,报错了你分不清是集成问题还是上传问题,调试周期会拉得很长。
先弄一个最简单的HTML页面,里面放一个CKEditor实例,再把上面粘贴上传的所有JS逻辑跑通,后端PHP接口单独调试,确定没问题了再往HIS主系统里集成。这个思路听起来基础,但真能帮你省下大半天的时间。
另外,上线前务必在科室的常用浏览器上各测一轮,包括那个还没升级的IE模式浏览器。医院环境跟互联网产品不一样,用户不会自己换浏览器,代码必须去适应用户的浏览器。这种线下环境的兼容性测试,往往比功能开发本身更花时间,但也更能体现一个HIS开发者的功力。
如果你正在被医院的CKEditor粘贴上传问题折磨,按上面这几个环节排查一遍,大概率能找到问题所在。项目做完了记得回来告诉我你踩了哪个坑。