简介:UniEdit电子病历编辑器是一款面向医疗信息化场景的ActiveX组件,可快速集成到C/S或B/S架构的电子病历系统中,帮助开发者实现病历录入、编辑、模板套用、数据保存等核心功能,有效替代传统纸质病历的繁琐流程。压缩包共38个文件,大小约9.65MB,主要包含xml配置、html演示页面、bat注册脚本、doc接口说明以及dll控件等;xml文件用于定义病历模板、ICD编码及字典选项,html示例展示网页端调用方式,doc文档则为二次开发提供必要的接口参考。目前已有1172人学习下载。借助包内示例项目与SDK文档,读者能够掌握网页端编辑器的注册、调用与传参流程,理解多页眉页脚、ICD编码字典等特性的配置方法,从而降低电子病历系统的开发门槛,提升医疗文书处理的规范性与效率,为医院信息化建设提供可靠支撑。 做医疗信息化的朋友应该都有印象,电子病历编辑器是整个HIS系统里最难啃的一块骨头。我第一次接触UniEdit这个电子病历编辑器时,就意识到它不是普通富文本编辑器加几个按钮那么简单,而是一个从数据模型、模板引擎、质控规则到签名留痕全覆盖的深水区。这篇文章我会以实际集成开发和使用的视角,把UniEdit的核心原理、关键功能、部署踩坑和二次开发思路一次讲清楚,适合正在选型或准备接手电子病历编辑器开发的同学参考。
1. 医院为什么要单独的编辑器:通用富文本满足不了病历
1.1 病历的本质是一份带语义的法律文书
很多人第一次听说"电子病历编辑器"时,第一反应是"不就是个文本框吗?" 但实际上,病历的真正身份是法律文书。它不只是记录医生想法的一段文字,而是纠纷发生时医学鉴定的核心依据,也是医保控费、医疗质控的数据源头。
一份完整住院病历里有哪些东西?主诉、现病史、既往史、体格检查、辅助检查、初步诊断、治疗意见——这些不是随意排版的普通文章,每个段落背后都有明确的医学语义。比如"主诉"必须简洁、聚焦、符合"症状+时间"的表述规范;"现病史"必须按时间顺序展开。如果这些内容是纯文本,系统根本没法判断医生有没有漏项、写没写全、诊断编码是否对得上。
所以电子病历编辑器必须做到把文字内容和结构语义绑定在一起,而不只是保存一段HTML或Word内容。这也是UniEdit这类专业编辑器存在的根本原因。
1.2 通用编辑器的三个致命缺陷
我早期做过一个项目,为了省事,直接在系统里嵌了流行的富文本编辑器,结果上线三个月就被临床科室骂回来了。踩过的坑可以总结成三条:
格式自由但数据失控:医生可以用换行、空格硬生生把版面搞得像手写病历,加粗、下划线满天飞,但系统完全不知道哪些内容属于哪个字段。病历质控部门要统计"主诉是否超过50字",根本无从下手。
模板形同虚设:虽然富文本编辑器也支持插入模板,但模板嵌套、占位符替换、动态数据拉取这些需求,靠通用编辑器里的"预制HTML片段"是做不扎实的。最常见的场景是把患者姓名、年龄、入院时间做成占位符,通用编辑器替换一次可以,但要做到"每次打开不同病人都自动替换",就得自己套大量后处理代码,非常容易错。
无法满足医疗合规要求:修改留痕、上级医师签名、数据不可篡改、审计日志,这些强制要求通用富文本编辑器一个都没有。说白了,通用编辑器解决的是"人怎么打字",而电子病历编辑器要解决的是"病历怎么写才合规、数据怎么存才能用"。
1.3 电子病历编辑器该有的样子
真正能落地的电子病历编辑器,至少需要具备四个能力:
- 结构化模板能力:模板不是写死的HTML,而是可以嵌套、可配置数据元、支持条件显隐的文档骨架。
- 数据与展示分离:同一份病历,页面看到的是一版样式,底层保存的是结构化数据。同一份数据可以渲染成打印版、移动端预览版或归档XML。
- 业务规则嵌入:必填校验、值域约束、评分卡规则、逻辑判断都要在编辑器内部跑起来。
- 审计与签名通道:每一次修改都有留痕,签名位与CA体系对接。
UniEdit正是按照这个思路设计的。接下来我从数据和内核两头拆解。
2. UniEdit的数据模型与核心架构
2.1 文档树:每个节点都绑定数据元
如果你打开UniEdit的文档存储结构,会发现它托管的内容不是一段单纯的HTML字符串,而是一棵文档树。树的每个节点都对应一个医学数据元,比如"主诉"是一个节点,"现病史"是一个节点,患者基本信息是另一个节点。
每个节点上挂载的属性大致包括:
- 节点类型:文本、段落、表格、选择框、复合块等
- 数据元标识:如
chief_complaint、present_illness - 必填标记:为true时,文档保存前会校验是否为空
- 值域规则:可选的医学枚举值或格式约束
- 显示样式:字体、缩进、是否只读
这种"节点即字段"的设计,带来的直接利好是:保存病历的时候,系统可以精确拿到每一个字段的值,然后直接映射到数据库表的对应列。做检索、统计、质控就很轻松,不需要再去解析一大段文本找规律。
在实际集成中,我们会在编辑器初始化时传入一份数据元字典,它告诉UniEdit这套模板里有哪些字段、每个字段的类型和约束是什么。UniEdit负责把字段渲染成可编辑区域,再把用户输入的内容同步回数据元字典里。
2.2 存储格式:XML还是JSON
很多人会问UniEdit底层存的是什么格式。从卫生行业规范来看,电子病历共享文档通常采用基于HL7 CDA的XML结构;但从Web前端处理效率来说,JSON比XML更顺手。UniEdit的做法是外部交换用XML格式,内部运行用JSON格式。
这里有一个很关键的"为什么":病历归档时需要符合行业交换标准,所以对外导出用XML;但浏览器里处理JSON树,增删改节点、绑定事件、动态渲染都方便得多。因此UniEdit内核维护一份JSON树,在保存或归档时再序列化成XML文档。
如果你要跟他对接,需要知道有哪些常用转换接口。比如获取当前编辑器中的结构化数据,接口会返回JSON数组,每个元素都带有meta_oid(数据元标识)和value(当前值)。提交到后端后,由后端服务把JSON转成XML存档,这一层逻辑在网关里做比较合理。
2.3 编辑内核:DOM渲染与数据层如何协同
UniEdit在内核上采用的是"数据层与渲染层双向绑定"的架构。编辑区域依然基于浏览器的contenteditable能力,但用户每次输入都会先经过一个拦截层,把输入事件转换为针对文档树节点的变更操作,然后由渲染引擎重新绘制对应区块。
这样做的好处是,无论用户怎么操作,文档树始终是可靠的数据源。渲染层再花哨也不会污染数据结构。有一段时间我担心这种架构会不会导致输入延迟,实测下来只要控制好节点粒度,也就是不要把一个几百行的大表格当做一个节点,而是拆成行级、单元格级节点,输入响应还是很跟手的。
对了,UniEdit还内置了一套"节点类型注册机制"。文本节点、数值节点、日期节点、单选节点、复合段落节点……每种节点有自己的编辑器组件和校验逻辑。你要增加一个自定义节点类型,只需要实现统一的接口,往注册表里注册即可。后面第5章我会给一个简单的扩展示例。
3. 结构化录入、留痕与质控的实现路径
3.1 模板引擎:从Word病历到页面模板
医院里的大量病历模板最初都是Word文档,UniEdit的模板引擎支持把Word内容导入后,通过标记工具把需要结构化的文本位置圈选出来,绑定数据元。这一步在实际部署中非常重要,因为要求临床科室从零开始用空页面写病历,几乎不可能。必须做模板迁移。
模板文件本身也是一棵文档树。模板树和实例文档树的区别是:模板里的节点值可以是空值、占位符或默认值,实例文档则是模板的填充结果。每次医生新建病历时,UniEdit会根据当前病历类型加载对应模板,再通过数据填充模块把HIS里的患者基本信息自动填入。
模板里的条件显隐,比如"性别为女才显示月经史段落",是在模板节点上配置规则表达式实现的。医生打开编辑器的瞬间,UniEdit会先运行一条规则链,把不需要的节点直接隐藏,避免医生被无关字段干扰。
3.2 数据自动填充:HIS数据怎么进文档
病历里大量内容是重复劳动,比如患者姓名、床位号、入院时间、诊断列表。UniEdit的数据填充模块通过占位符映射来搞定这件事。模板节点上定义了一个data_source属性,比如:
{ "oid": "patient_name", "data_source": "patient.name" }集成方需要注册一个数据提供函数。医生打开病历时,UniEdit会调用这个函数,把从HIS取回的病历头数据包传进来,然后按data_source路径自动把值填到对应节点。
这里有一个容易踩坑的点:不要把数据提供函数做成阻塞同步接口。医院网络环境下,HIS接口响应经常几百毫秒甚至更久,如果编辑器的初始化和数据填充都等接口返回,页面白屏时间会非常明显。我们集成时的做法是:编辑器先渲染模板骨架,患者数据异步回填,回填过程中字段显示为灰色加载状态,数据到了再高亮刷新。医生感知到的就是"打开病历很快,数据稍微等一下跳出来",体验比整体卡住好得多。
3.3 修改留痕与电子签名的交互逻辑
修改留痕是电子病历编辑器区别于普通编辑器的硬性能力。UniEdit里的留痕不是简单记录"谁在什么时间改了什么",而是在文档树节点层面维护一个变更历史列表。
当上级医师修改下级医师写的病历时,具体流程是这样的:
- 下级医师保存的版本成为"基础版本",该版本对应的节点值被标记为"原始值"。
- 上级医师在编辑器里修改内容,变更监听器捕获到节点值变化,生成一条变更记录,包含数据元标识、原始值、新值、操作者、操作时间。
- 变更记录会以"修订气泡"或"批注样式"的形式渲染在原文中,新值用高亮颜色显示,原始值用删除线或浅色背景显示。
- 上级医师完成修改后,需要对本次修改进行签名确认。UniEdit提供了签名面板,可对接CA或手写签名设备,签名动作会触发整份文档的哈希锁定。
哈希锁定的逻辑是:把当前文档树所有节点的有序值拼接,计算HASH值后与签名信息一起存储。之后如果有人绕过编辑器直接改数据库里的字段,重新计算HASH就会发现对不上。这个设计虽然不是国家级防篡改标准,但对医院内审计来说已经够用。
我做集成时额外补了一步:把留痕记录单独同步一份到ES或日志系统,方便医务科做统计分析。原因很简单,编辑器里的留痕记录一旦被管理员误删或清理,还能从日志系统追溯回来。
4. 部署接入时踩过的坑和解决方案
4.1 老浏览器与旧内核的兼容问题
医疗行业最让人头疼的不是业务逻辑,而是终端环境。很多基层医院的办公电脑还停留在Windows 7 + IE11,有的甚至用国产浏览器老版本内核。UniEdit虽然整体兼容性不错,但以下几个点必须提前确认。
- 拖拽上传图片:老内核不支持或不完全支持拖拽API,需要考虑降级为文件选择框。
- MutationObserver:部分老内核对这个API支持不稳定,会影响节点变更监听。如果现场浏览器版本太低,建议引导医院信息科做浏览器升级,或者启用UniEdit兼容模式,改用定时轮询差异来兜底。
- CSS Grid布局:打印和编辑器样式用了大量Grid布局,老内核解析会崩。解决办法是在打印视图切换到Table布局的兼容样式。
我们实际交付时,给医院提供的"标准终端配置"里明确写了推荐浏览器版本,同时沟通了"最低可用版本"的边界。这一步最好在项目启动时就谈清楚,否则后期全是运维工单。
4.2 大病历文档的性能瓶颈
一份重危病人的病历如果整租了历史病程记录,可能会有几百个节点。如果全部一起渲染,打开和输入时都会明显变慢。UniEdit对这种情况有懒渲染机制:初期只渲染当前视窗范围内的节点,滚动时动态挂载和销毁节点组件。
但懒渲染解决不了另一个问题:文档树全量同步带来的内存占用。特别是包含大量图片的文档,每张图片base64字符串会吃不少内存。我们在实际使用中发现,把图片单独传到文件服务,节点里只保留图片ID和URL,内存占用能降一半以上。
还有一个性能细节,UniEdit在保存时会全量计算文档树的HASH和必填校验。如果文档确实很大,保存按钮可能卡一下。建议前端在保存时禁用按钮并显示进度提示,不要异步重复点击。
4.3 打印分页与版式问题
医院病历打印有严格版式要求:A4纸、固定页边距、页码、页眉页脚。UniEdit的打印部分是通过单独的打印模板来控制的,编辑视图和打印视图是两个样式体系。
我踩过最大的坑是表格跨页断行。病历里的检验结果表经常一行跨到下一页,打印出来中缝处会截断表格边框。UniEdit里需要给表格节点配置"重复表头"和"禁止行内分页"两个属性,同时打印样式中用break-inside: avoid来避免行内容被切开。
另一个坑是页眉页脚在不同科室有不同要求。比如手术科室需要打印"手术同意书",页眉要带医院名称和"医疗文书"水印。UniEdit支持页眉模板变量,在打印模板中可以绑定文档元数据,比如医院名称、病历类型、患者ID,这样一套打印程序可以应对多个模板。
4.4 与HIS系统对接的数据往返
UniEdit并不是独立存在的产品,它一定要嵌到HIS或EMR系统里。常见的集成方式有三种:
- iframe嵌入:UniEdit作为独立Web应用,通过postMessage与宿主页面通信。
- 前端组件集成:把UniEdit的npm包直接引入HIS前端工程。
- 后端接口对接:通过REST API完成病历数据的创建、读取、更新、归档。
我们的项目最终用的是第一种+第三种混合:页面里iframe嵌入编辑器,通过postMessage通信控制加载哪份病历、保存;同时后端通过接口做数据归档和签名锁定。
这里必须提醒一句:跨域环境下,postMessage的权限校验一定要做。不要只看event.origin的前缀,直接startsWith('https://example.com')这种写法人一多就会漏洞。要精确匹配完整origin,并且对消息里的指令做白名单校验,不然有心人可以在浏览器控制台伪造消息调用保存接口。
5. 二次开发实操:从配置到上线
5.1 初始化一个可用的UniEdit实例
先说明一下,UniEdit的发行包会区分前端SDK和服务端组件。前端SDK负责渲染编辑器,服务端组件负责模板管理、文档归档和签名验证。初始化一个前端编辑器的核心代码大概长这样:
import UniEdit from 'uniedit-sdk'; const editor = new UniEdit({ container: document.getElementById('emr-editor'), config: { // 编辑器主题 theme: 'material', // 是否开启修订留痕 trackChanges: true, // 是否开启自动保存 autoSave: false, // 数据元字典,定义各字段的约束 metaDictionary: '/api/meta/dictionary', // 数据提供者,用于自动填充患者信息 dataProvider: window.fetchPatientData, // 打印模板地址 printTemplate: '/templates/print/admission.html' } }); editor.on('ready', () => { console.log('UniEdit 初始化完成'); });config里的每一项都值得细看。尤其是metaDictionary,它指定了数据元字典的获取地址,编辑器启动时会请求这个接口,把当前病历类型对应的字段约束拉下来。这相当于告诉UniEdit"这次要编辑哪些字段、每个字段有什么规则"。
5.2 加载病历模板与数据
初始化完成后,要做两件事:加载模板、回填数据。假设我们进入的是"入院记录"编辑界面:
// 根据病历类型获取模板ID const templateId = await fetchTemplateIdByType('ADMISSION'); // 加载模板 const template = await editor.loadTemplate(templateId); // 请求患者数据 const patientData = await fetchPatientData({ patientId: 'P001234' }); // 将患者数据注入文档 editor.fillData(patientData); // 渲染文档 editor.render();fillData这个接口不是简单的属性赋值,它会根据节点上的data_source配置,把患者数据包中的对应字段写入文档树,同时触发一次校验,把不符合值域规则的数据用红色问号标记出来。比如患者年龄是"未知"但字段约束要求整数,就会在界面上给出提示,医生可以手动更正。
5.3 保存与提取结构化数据
保存病历的时候,我们一般不会直接把编辑器里的HTML回传后端,而是先调用UniEdit的结构化提取接口拿到JSON数据。
async function saveDocument() { // 校验文档完整性 const validation = editor.validate(); if (!validation.passed) { alert('存在必填项未填写:' + validation.relatedMetaOidList.join(', ')); return; } if (editor.isDirty()) { // 提取结构化数据 const structData = editor.getStructuredData(); // 请求签名(可选) const signInfo = await requestSignature(); // 提交保存 await saveToHis({ templateId: currentTemplateId, data: structData, signInfo }); // 标记编辑器已保存 editor.markSaved(); } }getStructuredData()返回的结构,是按文档树的层次组织的嵌套JSON。比如"现病史"节点下如果有"症状列表",会表现为一个数组。后端拿到这个JSON后,可以按需将其转换成归档XML或插入关系表。
这里有一个踩坑提醒:保存时不要连同编辑器内部状态一起存,比如trackChanges的临时标记、光标位置、撤销栈等。这些状态只属于前端会话,一旦重新打开病历,撤销栈应该清空,不然医生会莫名其妙按一次撤销把别人的字段回滚了。UniEdit的接口设计上其实已经做了隔离,但如果你自己扩展了一些临时状态,务必在getStructuredData()时排除掉。
5.4 性能调优与扩展自定义控件
最后聊两个二次开发中最实用的点。
第一个是性能调优。前面说过懒渲染,但如果你还是觉得在低配电脑上卡,可以试试调整UniEdit的渲染节流阈值:
const editor = new UniEdit({ container: el, config: { performance: { // 设置视窗外预渲染缓冲区比例 renderBufferRatio: 0.5, // 启用变更合并,每200ms合并一次高频输入 changeMergeInterval: 200, // 文档节点数超过2000时自动进入轻量模式 lightModeThreshold: 2000 } } });第二个是自定义控件扩展。比如医院需要一个"疼痛评分尺",点击弹出一个0-10分滑竿,选择后把结果写进对应字段。这个控件本质上是一个新的节点编辑器:
import { NodeKindRegistry } from 'uniedit-sdk'; // 注册一个自定义节点类型 NodeKindRegistry.register('pain_score', { // 默认值 defaultValue: null, // 创建DOM render(value, props) { const el = document.createElement('div'); el.innerHTML = `<input type="range" min="0" max="10" step="1" value="${value ?? 0}">`; el.querySelector('input').addEventListener('change', (e) => { props.onChange(Number(e.target.value)); }); return el; }, // 校验 validate(value) { return typeof value === 'number' && value >= 0 && value <= 10; } });注册之后,模板节点里只要把type设为pain_score,就自动使用这个滑竿控件了。通过这种方式,UniEdit可以接入体表面积计算、危重度评分、过敏史标签选择等医院个性化的输入需求。
最后再分享一个小技巧,也算是我集成UniEdit时最省心的一步:把编辑器的校验规则和数据元字典在后端也维护一份,前端校验失败了能良好提示,后端在归档前再校验一次。两边的规则版本可能不完全同步,但至少要保证"后端是最新版本"。因为前端被绕过或出现Bug时,后端这层校验就是守住病历数据质量的最后一道门,绝对不能省。
本文还有配套的精品资源,点击获取