当前位置: 首页 > news >正文

基于Vue 3与TipTap的电子病历编辑器架构设计实践

简介:本资源是一个基于Vue框架开发的医疗级电子病历编辑器完整前端项目,面向医疗信息化开发者、HIS系统集成工程师及前端进阶学习者,解决临床场景下病历录入效率低、格式不统一、数据难结构化、安全合规性不足等核心痛点。压缩包共939个文件,含165个JS逻辑模块、57个CSS样式文件、40个HTML页面模板、560张UI资源图(PNG/GIF/SVG),以及Vue组件、配置文件(.babelrc、Web.config)、后端接口处理脚本(.ashx/.cs)和数据库支持文件(sqlite),整体体积仅6.24MB,轻量但功能完备。已有106人下载学习,项目采用模块化架构设计,各功能解耦清晰——富文本编辑、模板引擎、权限控制、版本快照、PDF/Word导出等均以独立模块实现,配套完整目录结构与可运行示例,便于二次开发与医疗系统快速集成。 去年在医疗信息化项目里接了一个挺有挑战的活儿:给门诊系统做一套电子病历编辑器。一开始我心想这不就是个富文本编辑器嘛,封装个现成的库,把内容存进数据库,完事。结果真把需求拆完才发现,电子病历编辑器跟普通博客后台的富文本完全不是一个物种——它本质上是一套医疗数据生产系统,附带文本编辑能力。格式要规范、数据要结构化、模板要能按科室定制、编辑过程要可追溯、导出格式要兼容各种系统、权限要细到字段级、还要在不同终端上不崩。这篇文章就把我基于 Vue 3 从零搭起的这套方案完整拆开讲一遍,覆盖富文本编辑、病历模板定制、结构化存储、实时预览、多格式导出、权限管理、版本控制、数据加密这些模块的设计思路和落地细节。想搞医疗信息化,或者要在后台系统里做复杂内容编辑器的同学,这篇应该能帮你少踩不少坑。

1. 电子病历编辑器到底难在哪:普通富文本方案的五个缺口

1.1 病历不是"文章",是带语义的医疗数据

很多人的第一反应(包括一开始的我)都是:电子病历编辑器 = 富文本编辑器。医生输入一段病史,存起来,下次打开还能改,这就是全部了。但真正接触医疗业务之后会发现,一份病历里包含主诉、现病史、既往史、体格检查、诊断、医嘱等不同区块,它们不是一段连续文字,而是具有明确语义的独立数据单元。

比如"主诉:发热3天"和"现病史:患者3天前无明显诱因出现发热",这两条内容在病历里挨着,但对医院信息系统来说性质完全不同。主诉要求简短、抓重点,现病史要求按时间顺序详细描述,诊断要关联 ICD 疾病编码,医嘱甚至要对接药品库存系统。如果全部糊在一个 contenteditable 里当普通 HTML 存,后续想统计、想对接、想按质控规则做检查,全部抓瞎。

所以这个项目立项后的第一个目标就很明确:编辑器的数据模型从第一天就必须是结构化的,不能先做成一坨 HTML 再想办法找补。这个决定看着简单,实际上直接影响后面所有功能的设计走向。

1.2 普通富文本方案的五个致命短板

当时我认真对比过直接用 wangEditor 这类成熟方案的可行性,最后总结了五个绕不开的实际问题:

问题普通富文本的表现对病历系统的伤害
结构不可控医生随意换字号、缩进,格式五花八门病历质控没法做,样式混乱,打印出来不统一
模板复用困难只能整篇复制粘贴,变量替换靠手工科室模板落地难,写一份病历要半小时
版本与审计缺失改完就覆盖,没有历史记录医疗纠纷时无法追溯责任,合规不过关
权限操作粗糙要么能改要么不能改,做不到字段级控制实习医生的录入和主治医师的确认权限无法区分
数据安全薄弱加密、脱敏、审计能力全部缺失敏感医疗信息泄露风险高,无法过等保

这个表里第五点需要多说一句:普通富文本方案本身不只是"不能加密",而是它压根没有数据安全的设计语言。字段级脱敏、操作审计、敏感词拦截这些能力全都要另起炉灶自己写。

1.3 一句话定义项目边界

把所有需求拉通后,我给这个编辑器下的定义是:

一个在浏览器里运行的、以结构化医疗数据为核心的、支持分级权限的内容生产与管理系统,而不是一个"好看一点的 textarea"。

这个定义直接决定了后面的技术选型、架构设计和模块拆分。后面所有代码层面的决策,其实都是在跟这个定义对齐。

2. 技术选型与模块化架构:先定骨架再谈功能

2.1 为什么选了 Vue 3 而不是 Vue 2 或 React

项目团队主力栈本来就是 Vue,所以选 Vue 没有悬念,但选 Vue 3 而不是 Vue 2,有两个具体原因。

第一是 Composition API。电子病历编辑器的核心组件逻辑复杂到令人头大,要同时维护编辑器实例、模板状态、实时预览、版本快照、权限上下文,如果全部塞在 Options API 的 data、methods 里,几千行代码根本 Hold 不住。用 Composition API 可以把"模板逻辑"拆成useTemplate,把"版本快照"拆成useVersion,把"权限控制"拆成usePermission,每个模块边界清楚,测试也好写。

第二是 TypeScript 支持。这个项目的数据结构非常强调类型安全——病历结构体的字段从编辑器流转到存储层,中间要经过模板解析、校验、版本对比多道处理,没有类型约束,改一个字段名都可能搞出线上事故。

2.2 编辑器内核选型:为什么不是 wangEditor

这是技术决策里最核心的一步。电子病历编辑器需要的不是"能打字",而是"能承载自定义结构化节点"。

当时对比了三个方向:

Quill。Quill 的 Delta 数据格式非常漂亮,结构化程度高,后端也好存。但它的嵌套结构支持太弱,病历里常见的"表格-段落-表格"这种复杂嵌套,Quill 需要写大量 patch。而且 Quill 2.0 对 Vue 3 的官方支持不算直接,很多能力需要自己包一层,维护成本不低。

wangEditor。国内团队用的多,中文文档亲切,开箱即用。缺点也很明显:它的扩展机制围绕"工具栏按钮"而不是"文档结构",想加一个"诊断条目"这种语义化节点,要绕过它的数据模型去 hack,长期维护会很痛苦。另外它的大版本迭代时 API 兼容性不太稳定,生产项目里锁版本锁得头大。

TipTap(基于 ProseMirror)。这是最终选择。ProseMirror 的核心思想是"文档是一棵由 schema 约束的树",你可以自定义节点类型、属性、嵌套关系,这种强约束正好命中医疗场景。TipTap 在 ProseMirror 之上封装了 Vue 组件式的扩展体系,@tiptap/vue-3可以直接把编辑器渲染成 Vue 组件树,模板节点、表格、嵌套布局都能用 Vue 组件实现,开发体验比直接写 ProseMirror 顺畅太多。

当时也认真考虑过完全基于 ProseMirror 自研编辑器内核,但评估下来工作量太大,光是要处理光标、选区、IME 输入法这些问题就得专门投入两个人。TipTap 的封装已经足够成熟,最后就定了它。

2.3 模块划分与目录结构

编辑器虽然叫"编辑器",但实际上是一个完整的前端子系统。我按功能域把目录拆成了这样:

src/ components/ Editor/ # 编辑器主体封装 index.vue # 入口组件 extensions/ # TipTap 扩展(含医疗自定义节点) menus/ # 工具栏与右键菜单 TemplatePanel/ # 模板面板 PreviewPanel/ # 实时预览面板 VersionHistory/ # 版本历史抽屉 composables/ useEditor.ts # 编辑器实例管理 useTemplate.ts # 模板解析与填充 usePermission.ts # 权限控制 useVersion.ts # 版本快照与对比 useEncryption.ts # 敏感字段前端辅助处理 stores/ patient.ts # 患者上下文 record.ts # 病历主数据 schemas/ medicalRecord.ts # 病历 Schema 定义 template.ts # 模板 Schema 定义 utils/ exportPdf.ts exportDocx.ts diffVersion.ts

这个划分的原则很朴素:编辑器组件只管渲染和交互,业务状态走 Pinia store,数据契约走 schema,工具函数全部纯函数化。后面加新功能的时候,大多数时候不需要改 Editor 主体,只要加扩展或加一个 composable 就行。这套结构在项目中期迭代模板功能时,帮我们省了大量重构时间。

3. 富文本编辑与病历模板定制的实现路径

3.1 基于 TipTap 的基础编辑功能

富文本编辑是地基,所有基础能力都通过 TipTap 扩展实现。这个项目里用到的核心扩展包括:

  • StarterKit:提供段落、标题、加粗、斜体、列表、引用等基础能力
  • @tiptap/extension-table:病历和检查记录里表格出现频率极高,表格扩展是必需品
  • @tiptap/extension-image:支持插入检查影像截图
  • @tiptap/extension-placeholder:空病历时的提示
  • 自定义的文本对齐扩展:控制对齐方式,但做了严格限制

这里有个容易忽略的细节:医疗场景的文本格式要克制。普通编辑器巴不得提供两百种样式,但病历场景只需要黑体、宋体、楷体三种字体,字号也就小四到四号之间,颜色除了黑色和红色(用于警示)外基本禁用。所以我在工具栏配置层面做了白名单,没有把所有扩展都暴露给用户。

颜色这块的处理思路值得单独说一下:我没有直接放开color扩展,而是做了一个"关键内容标红"按钮,底层把颜色值固定为#c0392b。同时把格式信息作为 mark 属性存进 JSON,后续做质控检测的时候就可以识别——比如"主诉不能标红""诊断必须带红色警示标记"这些规则,都是靠这个固定颜色值来判断的。

3.2 模板系统:从"复制粘贴整篇"到"结构化填空"

模板是电子病历效率的关键。医生一天要写几十份病历,每份都从空白开始根本不现实;但直接复制上一份改成新患者,又容易出现"上一任患者的名字没改干净"这种医疗事故。

我的方案是把模板拆成三层结构:

  1. 静态文本:固定不变的内容,比如"患者体温为""查体见"这类引导语。
  2. 变量占位:从患者基本信息自动填充,比如姓名、年龄、就诊号。占位符约定为{{patientName}}{{age}}这种格式,模板渲染时从患者上下文里取值。
  3. 动态区块:需要医生逐项录入的表格、检查列表,每一行都是一个独立的录入单元。

模板本身用 JSON 存储,结构大致长这样:

{ "id": "tpl_admission_note", "name": "入院记录-标准版", "version": 3, "blocks": [ { "type": "heading", "content": "入院记录" }, { "type": "variable", "key": "patientName", "label": "姓名" }, { "type": "staticText", "content": ",男," }, { "type": "variable", "key": "age", "label": "年龄" }, { "type": "dynamicTable", "columns": ["检查项", "结果", "单位", "参考值"], "minRows": 1, "maxRows": 20 } ] }

这个 JSON 不会直接渲染成可视模板,而是先进入 TipTap 的文档模型,解析成对应的结构化节点。这样做的好处是:模板和最终病历是同一种数据形态,导出、预览、版本对比全都可以复用同一套处理逻辑,不用维护两套数据结构。

3.3 让编辑器认识"病历语义":自定义扩展节点

前面说 TipTap 强在 schema,这里具体讲一下怎么用。我自定义了三个核心节点,它们把病历里最常见的三类语义数据变成了编辑器原生支持的结构。

diagnosis(诊断项):一个节点包含 ICD 编码、诊断名称、确诊状态三个字段。界面上呈现为带编号的列表,但从数据模型看,它是独立的结构化对象,不是"一段文字加了个冒号"。

medication(用药条目):字段更多——药品名、剂量、单位、频次、给药途径、开始日期。渲染时显示为一行,但存进 JSON 的是完整对象,后端可以直接把这条信息同步到药房系统。

examinationItem(检查记录):包含检查名称、结果、参考值、异常标记。

节点定义的方式大概是这样的:

import { Node, mergeAttributes } from '@tiptap/core' export const Diagnosis = Node.create({ name: 'diagnosis', group: 'block', atom: true, addAttributes() { return { icdCode: { default: '' }, name: { default: '' }, status: { default: 'confirmed' }, } }, parseHTML() { return [{ tag: 'div[data-type="diagnosis"]' }] }, renderHTML({ HTMLAttributes }) { return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'diagnosis' }), 0] }, addCommands() { return { addDiagnosis: (attrs) => ({ commands }) => commands.insertContent({ type: 'diagnosis', attrs }), } }, })

节点一旦进了 schema,编辑器就自动获得了一整套能力:用insertContent插入节点、定义拖拽对象、校验字段合法性、控制嵌套规则。放到业务里,医生点了"添加诊断",新增的就是一个带输入框的卡片,卡片填完就是一条结构化数据,后续统计、上报、质控全都基于这个数据。

4. 结构化存储与数据校验:既要给人看,也要给机器读

4.1 双层数据模型:JSON 作为唯一事实来源

这是一个我在项目里坚持了很久的设计原则:编辑器永远不直接保存 HTML,而是保存 JSON 文档树

TipTap(ProseMirror)的getJSON()方法可以拿到完整的文档树,包含节点类型、属性、文本内容。这个结构是可逆的:从 JSON 可以恢复到编辑器状态,也可以渲染成 HTML。我在数据库里把 JSON 作为主字段存储,同时生成一份 HTML 快照字段用于快速检索和预览展示。

这样设计的好处非常明显:

  • 编辑历史可以基于 JSON 做 diff,粒度精确到某个节点,而不是整篇字符串比对
  • 结构化字段(比如诊断 ICD 编码)可以被查询、统计、对接外部系统
  • 渲染展示和编辑预览可以从同一份 JSON 派生,不会出现"库里存的跟界面上看的不一致"

存储结构大致是这个样子:

{ "recordId": "rec_20240101_001", "patientId": "pat_12345", "templateId": "tpl_admission_note", "contentJson": { "type": "doc", "content": [] }, "contentHtml": "<p>...</p>", "version": 18, "createdBy": "doctor_zhang", "createdAt": "2024-01-01T10:00:00Z", "updatedBy": "doctor_zhang", "updatedAt": "2024-01-03T14:30:00Z" }

4.2 Schema 校验与数据清洗

结构化带来一个麻烦:结构一旦定了,数据进去之前必须严格校验,否则脏数据会污染整棵文档树。我在项目里引入了轻量级的运行时校验库 zod,定义了病历结构体的约束规则:

import { z } from 'zod' export const diagnosisSchema = z.object({ icdCode: z.string().regex(/^[A-Z]\d{2}(\.\d{1,2})?$/, 'ICD编码格式不正确'), name: z.string().min(1, '诊断名称不能为空'), status: z.enum(['confirmed', 'suspected', 'excluded']), }) export const medicalRecordSchema = z.object({ templateId: z.string(), contentJson: z.custom<TipTapDoc>(), diagnosis: z.array(diagnosisSchema), visibleTo: z.array(z.string()), })

这个校验函数在每次保存前执行,不合格就回滚并提示医生修改。上线前两周,ICD 编码格式错误是最高频的拦截项,后来在界面上加了智能提示组件,正确率才明显上来。这里我的经验是:结构化的系统,校验一定要前置到保存动作之前,而不是等数据进了库再补救

4.3 向医疗数据标准靠拢的设计

医疗数据要对接外部系统(比如疾控上报、医保结算),纯自定义 JSON 结构是不够的,需要向行业标准的数据模型靠拢——HL7 FHIR 是当前国际通用的健康数据交换框架,里面定义了 Patient、Observation、Condition 这些资源模型。

我们的做法不是在编辑器里直接生成 FHIR 数据,而是在存储层维护一个 mapping 层:病历 JSON 里的诊断字段映射到 FHIR 的 Condition 资源,检查项映射到 Observation 资源,患者信息映射到 Patient 资源。这个 mapping 层用独立的 TypeScript 模块维护,今天对接院内系统,以后要对接省级平台,改 mapping 就行,不用动编辑器本体。

5. 实时预览与多格式导出:从编辑态到交付态

5.1 双面板实时预览:同一个数据源的两个视图

实时预览没有用什么黑科技,核心就是"一份数据,两个渲染目标"。编辑器左侧是 TipTap 的可编辑视图,右侧是只读渲染的预览视图,两个视图都基于同一个 JSON 文档树。

预览面板本质上也是一个 TipTap 实例,只是把editable设为 false,再套一层打印样式。这样预览和编辑永远实时同步,不存在"保存了才发现排版不对"的问题。

我还加了一个细节:滚动联动。左侧滚到哪,右侧按内容高度比例滚到对应位置。实现逻辑不复杂,监听左侧滚动容器的scrollTop,按内容高度比例计算出右侧应该滚动的位置,再用requestAnimationFrame做平滑移动。实测在五千字以上的长病历时,这个联动依然流畅,没有明显掉帧。

5.2 PDF 导出:架构上不能只走一条路

PDF 导出是医疗交付的硬需求,也是坑最多的地方。

我一开始用了纯前端方案:html2pdf.js(jsPDF + html2canvas),把预览 DOM 截图生成 PDF。测下来问题很明显——文档一长,canvas 画布尺寸巨大,生成一张 A4 截图要占几百 MB 内存,手机浏览器直接白屏;而且截完图 PDF 里的文字是图片格式,完全不能搜索和复制,这在医疗文件存档场景是致命的硬伤。

后来改成浏览器原生打印方案:把预览 DOM 放进一个隐藏的 iframe,调用window.print(),用户选择"另存为 PDF"。好处是不依赖任何第三方库,渲染引擎直接输出矢量文字,清晰度、体积、可搜索性全都是原生支持。缺点是打印样式要精心调,需要专门写一套@media printCSS,但一次性投入,后续非常省心。

如果站点需要服务端生成 PDF(比如系统自动归档),思路是前端把 JSON 发给后端,后端用 Puppeteer 打开服务端渲染模板来输出 PDF。我的建议是:前端交互预览用打印方案,自动化归档走服务端 Puppeteer 方案,两者各管一摊,不要混用

5.3 其他导出格式:HTML、DOCX、Markdown

除了 PDF,这个项目还需要导出 HTML 和 DOCX。

HTML 最简单,直接用contentHtml字段,再加一个完整病历样式头就行。

DOCX 导出踩了一个大坑。一开始我用html-docx-js,简单页面没问题,但带复杂表格的病历导出后,Word 打开错位严重。后来换成了docx这个库,不走 HTML 转换,而是把 JSON 直接映射成 Document 对象的段落和表格:

import { Document, Packer, Paragraph, Table, TableRow, TableCell } from 'docx' const doc = new Document({ sections: [{ properties: {}, children: jsonToDocx(recordJson), }], }) const blob = await Packer.toBlob(doc)

jsonToDocx在内部做递归映射:文档节点转 Paragraph,表格节点转 Table,诊断节点转带编号的段落。这样生成的文件结构可控、样式统一,比"先转 HTML 再转 DOCX"多写了几十行代码,但稳定性提升了一个量级。

6. 权限管理、版本控制与数据加密:医疗合规的底子

6.1 细粒度权限:从"能进不能进"到"能改这一行不能改那一行"

电子病历的权限模型和普通后台系统完全不同。医生不是对所有病历都有编辑权,也不是对同一份病历的所有部分都能改。比如实习医生可以录入主诉和现病史,但诊断和医嘱必须主治医师确认;护士只能写护理记录,碰不了诊疗部分。

项目里用的是RBAC + 数据域交错的组合方案:

  • 角色:admin(管理员)、doctor(主治医生)、intern(实习/规培医生)、nurse(护士)、viewer(只读角色)
  • 每个角色绑定一组操作权限:read / write / confirm / archive
  • 数据域:角色只能操作自己科室和"被授权患者"的病历

在前端,权限不只是控制"显示不显示按钮",还要深入到编辑器内部。这里的核心细节是:Editor 的editable状态是全局的,但我们需要字段级锁定。我的做法是,在 TipTap 的自定义节点上增加一个locked属性,保存前由权限服务遍历文档树,把当前用户没有编辑权的节点标记为 locked;渲染层对 locked 节点禁用输入,保存时后端再用同一套权限规则校验一遍,前后端双重拦截。

6.2 版本控制:改了就留痕,对比精确到节点

版本控制是医疗纠纷审计的硬性要求。实现思路不复杂,但细节上花了不少功夫:

每次保存不覆盖,而是生成一个新版本,旧版本进入历史表。但版本历史表里如果每次存完整 JSON 快照,数据量会非常大。我采用了增量存储方案:

  • 每个版本记录一个基础字段baseVersion
  • contentJson只存相对baseVersion的 operation 列表(ProseMirror 的修改本身就是一组 transaction steps)
type VersionRecord = { version: number baseVersion: number steps: Step[] // ProseMirror changes timestamp: string author: string }

需要加载历史版本时,从 baseVersion 开始按顺序 applySteps,就能重建任意时间点的完整文档。

版本对比用的是prosemirror-changeset库,它可以直接计算两个版本之间的结构化差异,而不是简单比较字符串。界面上左侧旧版右侧新版,增删改的地方用红绿高亮,医生可以快速看出上一次改动了哪些内容。这个功能上线后,临床科室的反应非常好,尤其是处理"病历到底改了没"这种日常纠纷时,效率提升明显。

6.3 数据加密:前端能做什么,不能做什么

数据加密需求提出来之后,第一件事就是和团队对齐认知:前端加解密更多是为了"端到端传输安全 + 敏感字段最小可见",完整的加解密防线必须靠服务端

项目里落地了三层:

  1. 传输层:全站强制 HTTPS / TLS 1.2 以上,这个没有商量余地。
  2. 存储层:后端对患者姓名、身份证号、联系方式这些敏感字段做 AES-256-GCM 字段级加密,密钥由密钥管理服务托管。前端拿到的数据是接口解密后的明文,这一层前端不参与。
  3. 前端辅助层:对导出文件做本地二次保护,比如导出 PDF 后可设置打开密码,前端在生成文件时用 Web Crypto API 对文件内容做对称加密,密钥通过安全渠道传递。

注意:任何"纯前端加密存储"的方案在设计评审时都应该被打回。前端密钥分发是一个无解的伪命题——代码都跑到客户端了,密钥藏在哪都能被找到。前端加密只能当辅助手段,绝对不能当主防线。

7. 响应式与跨平台适配:编辑器在不同屏幕下怎么活下来

7.1 移动端的定位重组:能看不难,能写才难

电子病历的典型使用场景是 PC 端的医生工作站,但会诊、查房、远程协同这些场景越来越多,平板和手机上的访问需求完全不能忽视。

我的原则是:移动端不强求完整编辑,但要保证"可用"。具体做了三档适配:

  • PC 端(≥1280px):完整工具栏、双栏预览、快捷键、批量模板
  • 平板(768px-1280px):工具栏收进抽屉,保留编辑能力,但表格编辑优化为"点击格子弹原生输入框"
  • 手机(≤768px):默认只读预览模式,提供"轻量批注"功能(给某段文字添加批注),真正要编辑时引导到 PC 端

UI 层用一套响应式 CSS,关键断点定义在1280px / 768px / 480px,配合 CSS Grid 重新排列编辑区和预览区。移动端工具栏踩了个大坑:如果做成横向滚动条,医生根本不知道后面还有工具,容易误操作。后来改成了"自适应挤压 + 溢出进更多菜单",实测比横向滚动更容易上手。

7.2 contenteditable 在跨浏览器下的三个老大难

不管底层用什么编辑器框架,最终都逃不过浏览器对 contenteditable 的差异处理。我踩过的坑主要有三个:

粘贴格式不可控。从 Word 或网页复制内容粘贴进来,会带进大量垃圾样式。解决方案是自定义handlePaste,解析剪贴板 HTML,只保留白名单标签(p、strong、em、table 等),其余全部清洗成纯文本。这个清洗函数必须放在入库之前执行,否则脏数据会污染整个 JSON 结构。

输入法组合态问题。中文输入法在拼音组合过程中会触发编辑器的事件和选区变化,导致历史记录出现半截拼音的中间态。我在 TipTap 的beforeinput事件里加了判断:当isComposing为 true 时跳过所有历史记录提交。

光标和选区在空块里乱跳。空段落里按回车、上下箭头,不同浏览器处理表现不一致,有时候光标会跳到块外。TipTap 官方处理了大部分场景,但遇到自定义的 atom 节点(比如诊断卡片),默认光标逻辑还是不够聪明。我补充了handleClickhandleKeyDown,让点击自定义节点时自动聚焦到节点内部,方向键在节点边界时自动跳到下一个可编辑块。

7.3 Electron 离线版的思考

医院内网的网络环境有时不稳定,特别是老院区。这个项目后面扩展了一个基于 Electron 壳的离线版本,把编辑器和本地 SQLite 打通,医生离开工作站也能写病历,网络恢复后自动同步。

这里只提一个设计原则:离线包只做数据暂存和编辑,不做权限下发。权限校验必须在线完成,离线状态下写好的病历只能暂存为"待同步草稿",绝不允许直接归档进正式病历库,否则审计链路就断了。这个底线一定要守住,不然合规上会出大问题。

8. 踩坑记录:编辑器的"鬼"往往藏在你看不见的地方

8.1 那个让我排查了整整一天的"样式丢失"Bug

有一次测试反馈:医生保存病历后重新打开,有些字体颜色变成了默认色。第一反应是数据库存取问题,查了半天没结果。最后把 JSON dump 出来才发现,编辑器存储的 mark 是<span style="color: rgb(192, 57, 43)">这种内联样式,但我的清洗函数在保存前把style属性全删了。

这是典型的"编辑态可以,持久态不行"。问题不在存储层,而在清洗规则和编辑器 schema 不一致。后来我统一了一套原则:编辑器允许什么 mark,清洗函数就放行什么 mark,两者共用同一个配置函数生成,从根上杜绝了规则分裂。

8.2 表格里拖拽复制导致文档结构损坏

TipTap 表格扩展在跨行拖拽复制时,偶尔会生成不满足 schema 约束的行列结构,比如单元格数量不匹配。这类 Bug 不容易稳定复现,但一旦出现,整份病历都无法正常渲染。

解决思路不是试图去修复 ProseMirror 的表格模型,而是在保存前做一次结构校验。我会遍历表格节点,检查每行的 cell 数量是否一致、单元格的 rowspan/colspan 是否越界,发现异常就阻止保存,提示医生撤销重做。同时在编辑器层面绑定 transaction 监听,识别到拖拽事件后的表格结构异常时立即拦截。

8.3 不要小看"空内容"的处理

最后一个是一开始完全没注意、上线后才被医生吐槽的问题:空病历的视觉提示。医生打开一份新病历,编辑器里一片空白,光标都找不到。我用了 TipTap 的 Placeholder 扩展加了「请选择左侧模板或直接开始录入,带 {{}} 的内容会自动填充」提示,但真正关键的是另一件事:空白区域要能感知点击,并且点击后光标要准确落到第一个可编辑块。这个看似简单的问题涉及 ProseMirror 空节点处理机制,值得花点时间调优,直接影响到医生对系统的第一印象。

8.4 别指望"保存网络不好"是罕见情况

还有一个经验是来自上线后的真实反馈:病历保存的失败重试机制必须做好。医院里网络波动、WiFi 漫游、内外网切换是常态,医生辛辛苦苦写了一大篇,点保存的时候接口超时,前端如果直接报错就把内容丢了,那是要被骂上天的。

我的做法是:编辑器内置"自动保存草稿"机制,每 30 秒把当前 JSON 快照存到 localStorage;另外保存接口失败后进入重试队列,退避重试最多五次,同时 UI 上明确提示"网络异常,内容已保存到本地草稿"。这套机制上线后,因为网络问题导致的病历丢失投诉基本清零。


最后分享一点个人体会。电子病历编辑器这类需求,难点从来不在"能不能做出来",而在于"能不能把医疗语义嵌进编辑器里"。很多项目死在第一步——用了普通富文本,后面所有高级需求全部被数据模型卡死,越做越痛苦。如果你也在做类似方向,我的建议是先用两周时间把数据模型和 schema 定清楚,再开始写界面,后面会省下大量返工时间。编辑器内核选 TipTap 这套方向,目前看依然是这个场景下性价比最高的方案。希望这篇对正在做医疗信息化的朋友有帮助,也欢迎大家交流各自在病历编辑器上踩到的坑。

本文还有配套的精品资源,点击获取

http://www.cnnetsun.cn/news/4331801.html

相关文章:

  • 【计算机毕业设计】基于fastapi+vue的宠物领养管理系统
  • 【计算机毕业设计】基于 Python 的美妆销售数据分析 Web 系统
  • 基于TensorFlow 2.3与MobileNetV2的花卉识别系统设计与实现
  • Hermes Studio小方盒固件更新:文字输出与屏幕显示实战指南
  • 基于Python的多平台电商商品信息爬虫框架设计实战
  • 元宝 LeetCode 18. 四数之和 Python3实现
  • PyInstaller打包Python脚本全攻略:环境准备、路径兼容与排查指南
  • 分治与随机化:从复杂度分析到排序算法的思维框架
  • 计算机毕业设计之基于HTML5的物流配送系统设计与实现
  • 新手好上手AI界面设计的几个基础步骤
  • 电力巡检缺陷检测数据集工程实践:从7z解压到YOLOv8训练部署全记录
  • Matlab Simulink非线性空气悬架建模与仿真全流程解析
  • Python天气数据爬取与可视化:从API调用到交互式图表实战
  • 基于STM32的数据采集系统设计:从ADC采样到串口协议全解析
  • 基于 Spring Boot 的校园社团管理系统的设计与实现
  • Python 机器学习算法二之逻辑回归的推导及实战
  • 从NumPy到Pandas:一条避开数据分析学习弯路的高效路径
  • 机器学习及其Python实践
  • 安全厂商技术岗笔试复盘:从操作系统到网络的计算机基础考察
  • 偏振成像与MATLAB实现:从三角度图像到DoP/AoP参数提取
  • 论坛社区系统源码实战:商城、知识付费、拓客广告四合一拆解
  • CVPR 2022 | 无需训练的Transformer架构搜索
  • 基于YOLO的交通事故检测系统:从模型训练到部署落地全复盘
  • 书接上回(Convolution)
  • 会议拍摄灯光实战:北京晋商联合大厦项目中的艾蒙拉200X与爱图仕300X应用详解
  • 用Codex和GitHub Actions实现个人网站的自动化部署
  • 【计算机网络 | 网络层9:路由选择算法:距离向量与链路状态算法】
  • 用友秋招笔试真题解析:Java、SQL与ERP业务场景全攻略
  • 数据库里的结构化数据,怎么建立RAG知识库?
  • 基于深度学习的农作物叶片病害识别系统源码与论文实现