HCView-H5电子病历编辑器源码解析:Web端结构化病历实现

发布时间:2026/8/31 17:54:21
HCView-H5电子病历编辑器源码解析:Web端结构化病历实现 简介这是一套面向医疗信息化开发者与前端工程师的HCView-H5电子病历编辑器控件源码聚焦于解决临床场景下浏览器端专业文本编辑需求尤其适配电子病历书写、结构化模板填充及医学术语高效录入等高频任务。资源包共58个文件含30张界面图标PNG、21个核心JS模块、3个演示HTML页面、2个ICO图标及LICENSE等总大小仅645KB轻量易集成其中JS文件覆盖编辑器核心逻辑如HCViewDevData.js、Graphics.js、Forms.js、UI组件StdCtrls.js、ExtCtrls.js及系统服务Application.js、System.jsHTML与图片则支撑快速启动与可视化交互。已有300人学习下载可直接运行demo工程获取完整可调试的H5编辑器实现——包括Word级排版能力字体/段落/表格/图片、快捷键支持、分页打印、模板管理及与医院信息系统对接所需的权限与加密扩展点。 如果你正在做HIS或电子病历EMR系统的前端一定对“病历编辑器”不陌生。这个项目基于JavaScript和HTML实现了一个叫HCView-H5的电子病历编辑器控件核心定位就是Web端结构化病历录入与渲染。它能解决什么问题不是写一段Word那样的富文本而是要让医生按照模板录入主诉、现病史、体格检查、医嘱等内容同时保留结构化数据用于存储、质控和打印而且所有功能都跑在浏览器里不依赖桌面插件。这篇内容把这套控件的设计源码拆开讲一遍对我自己是一次代码复盘对正要接触同类系统的同学应该也有参考价值。1. 项目整体设计病历编辑器不能直接套普通富文本1.1 需求决定架构结构、质控、打印三个硬要求常规网页富文本编辑器解决的是“把文字变好看”但电子病历要解决的是“按规范写出能存档、能打印、能质控的病历”。可以简单类比普通的 contenteditable 像一张白纸想怎么写怎么写电子病历则像带红框线的病历笺哪里写主诉、哪里写既往史、一格写多少字都是规定好的。因此 HCView-H5 从一开始就把“版式”和“数据”同时管理起来这是区别于普通编辑器的关键。说实话我最初也试过偷懒方案直接div.contentEditable true再配一堆document.execCommand(bold)。写着写着就发现两个问题没有任何结构约束医生可以随意删掉模板段落最后保存的文书形同虚设保存时只能拿到一坨innerHTML数据根本没有办法按字段回传、解析、做质控。后来我参考桌面端 HCView 的做法把整个控件包成了一个“可编辑区域 自定义文档模型”。表面继续用 contenteditable 让浏览器帮我们处理光标和键盘输入内部却维护一套自己的块结构这样既拿到浏览器的编辑能力又能按块做约束。1.2 三层数据模型Document、Block、InlineHCView-H5 的内核一共分成三层文档层Document管理整个病历的块节点列表、全局样式、页面尺寸、打印参数块层Block每个块对应一个不可拆分的文档单元比如一个段落、一个标题、一个表格、一张图片、一个表单控件行内层Inline承载块内部的文字、样式区间Run、特殊符号、输入占位符。这个模型最大的好处是保留 HTML 天然渲染能力的同时又能对块做精细的增删改查。比如删除一个表格块时只需要从块数组里移出该节点再重建对应DOM区域不需要像纯文本选区那样小心翼翼地处理边界。块的id从一开始就生成好模板和保存数据的对应关系因此变得非常清晰。站在源码层面看整个文档就是一个 JSON 结构类似下面这种设计{ version: 1.0, docType: emr, blocks: [ { id: b1, type: paragraph, attrs: { align: left, fontSize: 14px, fontFamily: SimSun }, content: [ { type: text, text: 主诉, bold: true }, { type: text, text: 咳嗽伴发热3天 } ] }, { id: b2, type: table, attrs: { rows: 3, cols: 4 }, content: [] } ] }这个 JSON 就是文档层的数据源。DOM 只是它的渲染视图两者保持同步。你说它是“源码设计”也好说它是“数据中间件”也行实际效果就是不管 UI 变成什么样保存结构都不会乱。1.3 为什么不用现成富文本库而是自研控件很多朋友会问Quill、Slate、ProseMirror 都很好为什么还要自己写一套我的判断依据是“定制成本”和“病历规则复杂度”之间的权衡。电子病历有一些诡异的需求处方笺要严格按格子对齐体温单要插入特殊图表既往史区域经常要套嵌多个子表某些字段要带必填校验和埋点。这些需求在通用富文本编辑器里往往需要写大量自定义 module改到后面比从零写还痛苦。HCView-H5 只做了三件核心事文档块管理、选区样式、结构序列化。复杂业务全部由上层模板引擎和业务方自己控制。这样控件的边界清晰Bug 率也低。换句话说我把“编辑器”做薄把“病历”做厚整套源码在架构上才立得住。2. 核心机制光标、选区、样式与撤销重做2.1 光标与选区编辑器体验的命门凡是做过 contenteditable 的人都知道最难的不是写入内容而是把光标放到“该在的位置”。HCView-H5 封装了一组选区工具函数其中最常用的就是获取当前 Range 和设置光标位置。正常浏览器操作下光标位置可以通过window.getSelection()拿到function getCurrentRange() { const sel window.getSelection(); if (!sel.rangeCount) return null; return sel.getRangeAt(0); }这个 Range 对象里包含了startContainer、startOffset、endContainer、endOffset四个关键属性。我们要做的所有样式操作本质都是把 Range 变成一段可计算的“文本坐标”然后用 DOM API 去包一层 span。给一个非常常见的“在指定块末尾设置光标”的工具方法function setCaretToEnd(blockEl) { const range document.createRange(); range.selectNodeContents(blockEl); range.collapse(false); const sel window.getSelection(); sel.removeAllRanges(); sel.addRange(range); }这里有一个坑如果在输入法组合文字过程中强行设置 range很容易导致候选词丢失。所以我在源码里对所有compositionstart/compositionend事件做了统一监听在中文输入法活动期间不触发光标重算逻辑。实测下来对医生用户打字连贯性提升非常明显。至于选区边界我建议把“块级边界”看作硬边界。普通文本可以任意跨块选择但病历里表格单元格、签名区、固定模板区域都不应该允许跨块选中。实现方式是在块节点上设置>function applyInlineStyle(styleKey, styleValue) { const range getCurrentRange(); if (!range || range.collapsed) return; const span document.createElement(span); span.style[styleKey] styleValue; // 先提取选区内容放入新span span.appendChild(range.extractContents()); range.insertNode(span); // 重新选中这段 const newRange document.createRange(); newRange.selectNodeContents(span); const sel window.getSelection(); sel.removeAllRanges(); sel.addRange(newRange); }这不是一个完整生产级实现但它是整套路线的起点。生产版本里我做了两层增强样式归一化如果选区里已有相同样式值的 span就把当前操作转化为“移除样式”避免无限套娃样式合并相邻两个 span 的样式完全一致时自动 merge减少 DOM 碎片。工具栏就是一组按钮绑定这样的事件。每次执行完样式操作编辑器会重新计算文档 JSON 并触发onChange回调业务系统就可以实时拿到最新结构数据。2.3 撤销重做自己实现一个快照栈浏览器自带的document.execCommand(undo)只对原生编辑操作有效对我们自定义的applyInlineStyle、块插入、块删除完全无感。所以撤销重做必须自己写。我采用的是一个带指针的快照栈class HistoryStack { constructor(limit 50) { this.stack []; this.index -1; this.limit limit; } push(snapshot) { // 先截掉当前index之后的记录避免产生分支 this.stack this.stack.slice(0, this.index 1); this.stack.push(snapshot); if (this.stack.length this.limit) this.stack.shift(); this.index this.stack.length - 1; } undo() { if (this.index 0) { this.index--; return this.stack[this.index]; } return null; } redo() { if (this.index this.stack.length - 1) { this.index; return this.stack[this.index]; } return null; } }快照内容不需要存整个 HTML存的是文档 JSON。每次有结构化操作输入法敲字可以用 debounce 合并时把当前doc.model深拷贝一份压栈。深拷贝我用了JSON.parse(JSON.stringify(doc.model))因为模型里全是纯 JSON 数据没有任何函数和循环引用。这里有个性能细节病历内容动辄几十KB每次输入都全量快照会卡。我按“操作类型”区分快照频率纯文本输入300ms debounce 后压栈一次块插入/删除/表格操作立即压栈样式操作一个操作周期内只压栈一次。这样既保证可撤销粒度又不会让手机端内存爆炸。3. 关键源码实现块操作与结构化数据3.1 块节点的创建、插入和删除块是整个文档的基本操作单位。每个块类型对应一种构造函数源码里维护了一个BLOCK_REGISTRY注册表const BLOCK_REGISTRY { paragraph: ParagraphBlock, heading: HeadingBlock, table: TableBlock, image: ImageBlock, control: ControlBlock };创建块实例时统一调用createBlock(type, attrs)function createBlock(type, attrs) { const Ctor BLOCK_REGISTRY[type]; if (!Ctor) throw new Error(unknown block type: ${type}); const block new Ctor(attrs); block.id generateId(); return block; }每个块类至少实现三个方法render()返回 DOM 节点toJSON()返回结构化数据fromJSON(json)从数据恢复。这里就不展开所有类了拿ParagraphBlock举例class ParagraphBlock { constructor(attrs {}) { this.id generateId(); this.type paragraph; this.attrs Object.assign({ align: left, indent: 0 }, attrs); this.content []; } render() { const div document.createElement(div); div.contentEditable true; div.className block paragraph-block; div.dataset.blockId this.id; div.setAttribute(data-block-type, paragraph); return div; } }删除块的时候如果块里还有内容我先触发一次确认回写再把它从文档模型和 DOM 中同时移除。这里有一个很容易犯的错删除块后没有把光标移动到相邻块用户会发现按删除键没反应。所以每次删除后必须显式setCaretToEnd(previousBlock)。3.2 表格与图片嵌入对象的特殊处理病历表格跟普通文档表格不同它经常要做合并单元格、固定行高、跨页打印。HCView-H5 里表格块没有用 iframe而是直接生成原生table结构每个td都设置为可编辑区域。生成表格的简化源码function createTable(rows, cols) { const table document.createElement(table); table.className block-table; const tbody document.createElement(tbody); for (let i 0; i rows; i) { const tr document.createElement(tr); for (let j 0; j cols; j) { const td document.createElement(td); td.contentEditable true; td.dataset.cell ${i}-${j}; tr.appendChild(td); } tbody.appendChild(tr); } table.appendChild(tbody); return table; }注意单元格里的内容也参与了文档块模型。每次onChange扫描表格时我会把每个单元格里的 HTML 序列化成文本节点数组这样保存后还能恢复单元格边框宽度、合并信息等属性。图片处理相对麻烦。病历里最常见的场景是插入检查报告截图、手绘伤口照片。图片插入后要记录宽高、位置、缩放比例同时还要在 JSON 里保留一个base64或者上传后的文件 URL。我是这样处理的编辑器只存图片块的src、width、height和alt文本图片文件本体通过统一的上传接口异步传到服务器拿到 URL 后再回填。3.3 保存与回显HTML、JSON、XML 三种格式互换电子病历系统对外通常要求支持结构化数据接口有的还要输出符合卫生信息标准的 XML 文档。HCView-H5 在保存模块里做了三层格式转换当前实时数据是 DOM内部自动同步成 JSON保存到后端用 JSON因为字段清晰、结构易解析对外交换或打印预览时转成 HTML必要时再转 XML。JSON 转 HTML 的方法就是递归遍历 blocksfunction blocksToHtml(blocks) { return blocks.map((block) { if (block.type paragraph) { const inlineHtml block.content .map((node) { const style node.bold ? font-weight:bold; : ; return span style${style}${escapeHtml(node.text)}/span; }) .join(); return div>div ideditorContainer classemr-editor/div button>import HCView from hcview-h5; const editor HCView.create({ container: document.getElementById(editorContainer), mode: edit, theme: print-a4, // 模拟A4纸 onChange: (model) { console.log(文档变化, model); } }); document.querySelectorAll([data-cmd]).forEach((btn) { btn.addEventListener(click, () { const cmd btn.dataset.cmd; if (cmd insertTable) editor.exec(insertTable, { rows: 4, cols: 5 }); else editor.exec(cmd); }); });这里有个经验容器不要直接用body或者带有大量布局的环境HCView-H5 会在容器内建立自己的滚动上下文。建议外层给一个明确高度否则不同浏览器对 contenteditable 区域高度的判断不一致打印尺寸也会乱。4.2 模板加载和表单控件混排电子病历是先有模板后填内容。模板本身也是一份病历文档只是里面用占位符标记了可输入区域。我在加载模板时走同一套loadModel方法只是把部分块设置为readonly而把输入控件块单独渲染成输入框、下拉选择、单选按钮。表单控件混排是这套源码里比较出彩的部分。比如“体温36.5℃”里的“36.5”是一个可编辑数字输入而“℃”是固定文本。实现时我会在块内部插入一个真实的input或select节点并且给它加contenteditablefalse避免光标误入。span classemr-control 体温 input classemr-input style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />