上个月,一个做在线问诊的客户找我搭复诊随访模块,需求列表里有一条让团队争论了很久:患者和医生要能在网页上打开报告图片,完成截图标注——比如画个箭头、圈出可疑病灶、加一句文字说明,再把成品传回系统。更关键的是,这个功能必须在PC、平板和手机上表现一致。后台又是品牌方早就选定的WordPress。刚听到需求时我也觉得有点拧巴,WordPress并不是做图片编辑器的料,但真正拆完需求后发现,只要选对实现路径,完全可以在不脱离现有后台体系的前提下,落地一套稳定的跨平台截图编辑方案。这篇文章就是那次实操的记录,从需求分析、技术选型,到开发、避坑经验全写出来,适合正在用WordPress做医疗类门户或在线服务、又需要轻量图片编辑能力的开发者参考。
1. 互联网医疗的截图编辑需求,和普通图片编辑差在哪
1.1 三类典型用户,三种完全不同的“编辑”诉求
互联网医疗里的“截图编辑”,不是给公众号文章配个箭头那么简单。当时我们把用户粗分为三类,每一类的操作习惯和敏感点都不一样。
第一类是患者。患者从医院的App或小程序里看到检查报告,有时候想保存一张裁剪干净的图,或者给院方上传报销材料。这类用户的操作很轻,最多旋转一下、裁掉多余边框,如果报告上带个人隐私信息,他们还会主动要求打码。他们才不会关心图层、画笔粗细这类概念,界面越直白越好。
第二类是医生。医生在电脑端处理患者上传的影像或检验单,需要圈出疑似病灶、在图上画测量线、写下临床备注,最后把标注后的图像存回病历。医生对坐标准确性非常敏感,画线要落在像素的准确位置,颜色要醒目,最好还能多画几笔不互相干扰。
第三类是运营和客服。他们经常要处理用户上传的证件、票据,一旦发现照片里有身份证号、手机号,需要快速遮盖后再做后续流程。对他们来说,编辑工具要稳定、能撤销、能批量处理。
这三类需求如果塞进同一个编辑器界面,交互会非常混乱。但好消息是,底层渲染层完全可以共用一套Canvas引擎,只是默认打开的工具集不同。所以产品设计上建议拆成三种“模式”,技术上共用一段核心库,这样后续维护成本能压下来。
1.2 医疗图片的“可追溯性”才是隐藏需求
普通图片编辑流程是:打开图、改一下、导出、收工,没人关心原图去哪了。但医疗场景完全不同。医生在图上画的每一笔,本质上都属于诊疗信息的一部分,一旦产生纠纷或复查,需要知道是谁在什么时间、基于哪张原图做了哪些操作。
所以“截图编辑”这个功能如果做成纯前端导出,用户点保存后只往服务器传一张合成后的JPEG,后续根本没法追溯。正确做法是把原始图和标注过程分开存。也就是说,原图单独保存一份,每次画笔、箭头、矩形框、文字都以JSON操作记录的形式存下来,合成图只是给别人预览和下载用的“投影”。
这块需求在早期是最容易被忽略的。很多开发会觉得“反正画完了存储一张图就行”,等到真要核查时才发现图里没有操作者ID、没有坐标标注、没有时间轴,整个流程就是一笔糊涂账。我们在设计数据模型时,专门建了一个“图片操作记录表”,把每一步编辑都追加进去,后面做审计和复盘就轻松多了。
1.3 为什么在WordPress体系内自研,而不是另做一套H5
不少团队遇到这种需求,第一反应是“用Vue或者React单另写一个H5页面,再和主站做跳转”。但当时项目的医生排班、健康科普、在线预约全都是WordPress管理,单独做一个工具意味着还得处理账号打通、菜单集成、样式统一、双端部署等一系列问题。单算跨域登录这个环节,就够折腾好几天。
WordPress本身提供了完整的内容模型、用户体系和REST API,完全可以作为后端底座。编辑器的入口做成短代码,挂在医生个人页、问诊详情页甚至患者上传区,权限直接从当前登录用户读取,文件上传走标准媒体库流程,数据记录用自定义帖子类型承载。这套方案的开发量比独立H5小很多,还不用额外维护一个部署环境。
至于前端编辑器,确实可以用现成库,但选型时要克制。当时团队试过一些功能很全的插件,安装之后把整个编辑器布局和后台主题冲突了,禁用了好几个默认样式。最后我的建议是只用轻巧的Canvas绘图库,UI层自己写,不装“全家桶”。这样既能保证和主题视觉一致,也方便按医疗场景定制。
2. 跨平台适配:医疗编辑器最容易被忽略的四个技术细节
2.1 设备像素比(DPR)直接决定标注精度
很多人做Canvas编辑器时,习惯直接按CSS像素设置canvas的宽高,比如CSS宽度是800px,canvas.width就写800。这在普通笔记本上问题不大,但一上Retina屏、iPad或者高分屏安卓,整个图会变得模糊,标注线和文字边缘像锯齿一样。原因很简单,CSS像素和设备物理像素不是一比一,高分屏需要更多物理像素来显示同一块区域。
正确做法是先拿到window.devicePixelRatio,然后用它把canvas的实际尺寸放大,再通过scale()放大绘图上下文。比如CSS宽高是800乘600,DPR是2,那canvas.width就是1600,canvas.height是1200,绘图前执行ctx.scale(2, 2),这样后续绘制所有坐标都还能按CSS像素算,不用自己逐个乘,导出图片也会清晰很多。这个坑不填好,医生做精细标注时会非常痛苦,因为线始终是虚的,看起来就像没对准。
2.2 用Pointer Events统一处理鼠标、触控笔和手指
老一套做法是mouseup和touchend分开监听,坐标计算要写两份逻辑。PC上用鼠标,手机上又得处理touch事件,很容易出现一边动了另一边没反应。现在更推荐使用Pointer Events,它把鼠标、触摸笔、手指都统一成了Pointer事件,一套代码全搞定。
实际开发中要注意两个点。第一,部分安卓WebView对Pointer Events支持不完整,最好在初始化时做特性检测,不支持就回退到touch事件。第二,手机上用双指缩放图片时,如果canvas没有禁用浏览器的默认手势,整个页面会跟着滚动或缩放,操作根本没法做。一定要在canvas的CSS里设置touch-action: none,告诉浏览器不要处理默认手势,这样才能让编辑器自己的缩放逻辑接管。
2.3 响应式布局:工具栏要变成能收起的浮动面板
PC端空间大,工具栏放左侧很顺手;但到了手机或者平板这种窄屏,左侧固定栏会占掉大量可编辑区域。当时在移动端测试时发现,医生在手机上查看图像时,左边一条工具条把图片压得很小,箭头都点到别的字段上去了。
后来我们改成断点布局:屏幕宽度超过900px时,工具栏固定在左侧;低于900px时,工具栏变成一个可上下拖动的浮动条,默认收进底部,需要用的时候点一下展开。高频操作按键,比如画笔、撤销、保存,放在拇指最容易覆盖到的底部区域,次高频操作放进“更多”里。这个改动对移动端体验提升非常明显,虽然前端代码要额外写一套布局逻辑,但相比用户操作时疯狂误触,这点成本完全值得。
2.4 手机相册文件输入的几个坑
WordPress默认的媒体上传框针对管理后台设计,直接搬到前端给患者用会很糟心。最典型的问题是把文件选择框做成按钮,手机上点了没反应,或者只能选拍照不能选相册照片。
自定义文件输入框时,要显式处理capture和accept属性。iOS上,input的accept设为image/*,capture留空,用户可以选择拍照或从相册选;安卓某些机型,如果写了capture=camera,会强制打开相机,反而不方便。此外,患者上传的图片可能来自微信发送过的压缩图片,体积虽然小,分辨率却参差不齐,编辑器加载后要自动判断图片的物理尺寸,避免把模糊图片当高清原图处理。
3. WordPress侧的核心实现:短代码、REST接口与存储
3.1 用短代码把编辑器挂到医生个人页或问诊详情页
前端编辑器不能全局加载,不然每个页面都带一大段Canvas脚本,性能会很难看。我的做法是在主题的functions.php里注册一个短代码,页面模板只要输出[med_image_editor],就会自动加载编辑器对应的JS和CSS。
add_shortcode('med_image_editor', function () { if (!is_user_logged_in()) { return '<p class="med-error">请先登录后再使用图片编辑功能。</p>'; } wp_enqueue_script( 'med-editor-js', get_template_directory_uri() . '/dist/med-editor.js', [], '1.0.0', true ); wp_localize_script('med-editor-js', 'MED_EDITOR', [ 'restUrl' => esc_url_raw(rest_url('med/v1/images')), 'nonce' => wp_create_nonce('wp_rest'), 'postId' => get_the_ID(), ]); return '<div id="med-editor-root" class="med-editor-root"></div>'; });wp_localize_script把REST接口地址和nonce一次性传给JS,编辑器在初始化时就能利用当前登录用户身份直接对接后端接口。注意,nonce要限定为wp_rest,而不是普通的ajax nonce,否则REST请求会被WordPress拒绝。资源按需加载这件事看着简单,但能省掉全站访客下载无用的脚本量,尤其医疗门户的首页访问量不低,这一步必须做。
3.2 自定义帖子类型保存医疗图片记录
我推荐不要把所有数据都堆在附件记录里。WordPress的attachment本身有作者信息,但承载不了“这张图属于哪个就诊单”“是哪位医生在什么时候标注的”。更好的做法是建一个自定义帖子类型,比如说叫med_image_record,把原图ID、合成图ID、关联用户ID、关联订单ID、标注JSON、操作人ID、操作时间全部存进去。
register_post_type('med_image_record', [ 'public' => false, 'show_ui' => true, 'supports' => ['title', 'author'], 'capabilities' => [ 'create_posts' => 'read', ], 'map_meta_cap' => true, ]);这个帖子类型设置成不公开,前端无法直接浏览,只能通过REST接口按权限读取。因为它本质上不是一篇“文章”,而是一条业务记录,所以如果有条件,数据表完全也可以放自定义表,但用WordPress帖子结构的好处是天然带有创建时间、作者ID、修订历史,和后台管理UI直接打通,不用单独做管理界面。对于中小型医疗平台,这种方案足够用了。
3.3 REST接口的鉴权与校验
上传图片不能直接暴露原生media_handle_upload接口,否则任何知道路径的人都可以往媒体库里塞垃圾文件。必须注册一个自定义REST路由来做统一的权限和内容校验。下面是精简后的服务端回调框架。
add_action('rest_api_init', function () { register_rest_route('med/v1', '/images', [ 'methods' => 'POST', 'callback' => function ($request) { if (!is_user_logged_in()) { return new WP_Error('not_login', '请先登录', ['status' => 401]); } $cap = current_user_can('upload_files') ? 'upload_files' : 'read'; if ('read' === $cap) { return new WP_Error('forbidden', '当前账号无上传权限', ['status' => 403]); } $files = $request->get_file_params(); if (empty($files['file'])) { return new WP_Error('no_file', '未找到文件', ['status' => 400]); } $allowed = ['image/jpeg', 'image/png', 'image/webp']; $file = $files['file']; if (!in_array($file['type'], $allowed, true)) { return new WP_Error('bad_type', '仅支持JPG、PNG或WebP', ['status' => 400]); } $attach_id = media_handle_upload('file', 0); if (is_wp_error($attach_id)) { return $attach_id; } $record_id = wp_insert_post([ 'post_type' => 'med_image_record', 'post_status' => 'private', 'post_title' => sanitize_text_field($request->get_param('title') ?: '未命名图片记录'), ]); update_post_meta($record_id, '_med_attachment_id', $attach_id); update_post_meta($record_id, '_med_source_user', get_current_user_id()); return [ 'record_id' => $record_id, 'attach_id' => $attach_id, 'image_url' => wp_get_attachment_url($attach_id), ]; }, 'permission_callback' => '__return_true', ]); });重点看permission_callback。我直接返回true,因为实际权限在callback里根据用户角色再细分。这样代码里可以区分“医生”“患者”“管理员”三种身份,分别给不同能力。REST路由里不要用admin_post,否则在WordPress现代前后端分离结构里还得额外处理跨域头,REST原生支持CORS,省心很多。
3.4 原图和标注JSON分离存储的意义
前文提到要保存操作JSON,这个设计真正落地时是这样的:前端Canvas每次绘制动作都会被记录下来,形成一个操作数组,例如“从坐标X1,Y1画一条线到X2,Y2,颜色是#ff0000,线宽是3”。用户点保存时,前端做两件事,一是把原图和目前的所有标注合成出一张预览图上传,二是把操作JSON作为请求字段一起提交。
后端收到请求后,原图附件和合成图附件分别保存,JSON写到med_image_record的meta字段里。将来如果医生想换一种标注颜色,或者重新测量数据,可以读取JSON在本地重放,不需要再让用户重新画一遍。对病案归档来说,这一层“可重放”能力比一张静态图片值钱得多。开发时不要把JSON字段设置得太长,限制在2MB以内,一个不做橡皮擦的操作历史也就是几十KB,够用了。
4. 踩坑实录:跨域、旋转、内存、图章丢失
4.1 canvas.toDataURL一直抛SecurityError
跨域图片是Canvas开发里最经典的问题。如果图片是从CDN或者云存储桶加载的,而存储桶没配CORS,浏览器会把canvas标记为“被污染”,此时调用toDataURL绘制导出必然抛SecurityError。
解决分两步。第一步,给你的云存储桶或图片服务器配置CORS规则,允许你的WordPress域名发起跨域请求。第二步,前端在创建图片对象时,务必加上img.crossOrigin = 'anonymous'。注意顺序不能反,如果图片已经在缓存里,设置crossOrigin可能不生效,要在加载前设置,必要时给图片URL加一个时间戳参数强制刷新。这个问题排查起来不容易,因为本地环境经常没有真实跨域,线上才复现。
4.2 手机上传图片在Canvas里转了90度
iPhone拍照后默认把orientation信息写在EXIF里,图片本身是横向像素,但预览时根据Orientation字段自动转成竖构图。Canvas加载图片时不会自动读取EXIF,所以经常出现“上传后图片横过来”的问题。
我用exif-js读取图片的Orientation,然后根据值是3、6、8分别做旋转180度、顺时针90度、逆时针90度处理。处理完旋转后,要把新的图片数据重新转成canvas或Blob,并清掉原EXIF中的Orientation字段。否则下载后再次上传,就会“转两次”,越转越乱。这个坑在安卓上也存在,但不是全部机型,建议统一处理,省的以后有人截图再截图时出现玄学问题。
4.3 低端安卓机白屏和“保存后很慢”的问题
移动端跑Canvas,最大的限制是内存。如果用户直接上传一张单反拍出来的4000乘3000原图,加载到Canvas里可能直接白屏,尤其是低端安卓机,浏览器进程崩溃都没提示。
我们的处理是分两套渲染。编辑过程中,先用离屏Canvas把原图降采样到最长边不超过2000px的版本,保证界面流畅。保存时,如果判断当前设备内存足够大,就用原图重新加载,并把操作JSON按坐标比例映射回原图,再合成完整分辨率图;如果设备内存不够,就用降采样后的图合成,并在返回信息里告诉用户这是“预览版”。医学生成式诊断可能要求精度,建议这种降采样策略只在前端预览阶段使用,最终归档时尽量用原图重放。
4.4 箭头画上去保存后却丢了的经典Bug
我自己也踩过这个坑:画完箭头,界面显示一切正常,但上传到服务器后用户看到的图片里没有箭头。原因通常是前端保存时,执行顺序搞错了。比如用户点“保存”,JS只把当前显示的Canvas直接toDataURL,但由于某些状态变量已经重置,绘制上下文的坐标系统或者透明背景层没有正确叠加,导致导出的画布是空的或残缺的。
后来我把所有编辑操作都收拢成“操作栈”,导出时不直接读取当前Canvas像素,而是重新创建一个离屏Canvas,加载原图,然后遍历操作栈把所有动作重新绘制一遍。这样导出的合成图永远和界面预览图一致,只要操作栈没有丢,图就不会丢。这个改动虽然多消耗一点导出时间,但稳定性成倍提升。
4.5 撤销栈过深导致浏览器卡顿
不要无限保存每一步操作的历史快照。医疗图片编辑里,一张高分辨率图上画几十笔是常态,如果每画一笔都存一份base64快照,内存很快溢出。我们实现时限制最多存50步,超过之后把前49步合并压缩为一张离屏画布的“当前状态”快照,新的操作再从这快照接着存。这样撤销可以回退到50步以内的任意状态,后面还能继续重新操作,但内存占用会稳定很多。
前端细节补完,整套方案在WordPress上就能跑起来了。个人体会最深的一点是,在医疗这类对敏感度要求很高的业务里,代码写得漂亮不如数据模型设计得干净。原始图、合成图、标注JSON三个文件一定要各留一份,缺了哪一个,后续追踪和扩展都会变得很被动。如果你手头也正在做类似需求,不妨先从小范围原型试起,找一个真实的医生配合测试交互手感,比闷头写几个月再上线的效果要好得多。