
简介本资源是一个基于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 模板系统从复制粘贴整篇到结构化填空模板是电子病历效率的关键。医生一天要写几十份病历每份都从空白开始根本不现实但直接复制上一份改成新患者又容易出现上一任患者的名字没改干净这种医疗事故。我的方案是把模板拆成三层结构静态文本固定不变的内容比如患者体温为查体见这类引导语。变量占位从患者基本信息自动填充比如姓名、年龄、就诊号。占位符约定为{{patientName}}、{{age}}这种格式模板渲染时从患者上下文里取值。动态区块需要医生逐项录入的表格、检查列表每一行都是一个独立的录入单元。模板本身用 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-typediagnosis] }] }, 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 文档树。TipTapProseMirror的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.customTipTapDoc(), 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.jsjsPDF 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 快照数据量会非常大。我采用了增量存储方案每个版本记录一个基础字段baseVersioncontentJson只存相对baseVersion的 operation 列表ProseMirror 的修改本身就是一组 transaction stepstype VersionRecord { version: number baseVersion: number steps: Step[] // ProseMirror changes timestamp: string author: string }需要加载历史版本时从 baseVersion 开始按顺序 applySteps就能重建任意时间点的完整文档。版本对比用的是prosemirror-changeset库它可以直接计算两个版本之间的结构化差异而不是简单比较字符串。界面上左侧旧版右侧新版增删改的地方用红绿高亮医生可以快速看出上一次改动了哪些内容。这个功能上线后临床科室的反应非常好尤其是处理病历到底改了没这种日常纠纷时效率提升明显。6.3 数据加密前端能做什么不能做什么数据加密需求提出来之后第一件事就是和团队对齐认知前端加解密更多是为了端到端传输安全 敏感字段最小可见完整的加解密防线必须靠服务端。项目里落地了三层传输层全站强制 HTTPS / TLS 1.2 以上这个没有商量余地。存储层后端对患者姓名、身份证号、联系方式这些敏感字段做 AES-256-GCM 字段级加密密钥由密钥管理服务托管。前端拿到的数据是接口解密后的明文这一层前端不参与。前端辅助层对导出文件做本地二次保护比如导出 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 节点比如诊断卡片默认光标逻辑还是不够聪明。我补充了handleClick和handleKeyDown让点击自定义节点时自动聚焦到节点内部方向键在节点边界时自动跳到下一个可编辑块。7.3 Electron 离线版的思考医院内网的网络环境有时不稳定特别是老院区。这个项目后面扩展了一个基于 Electron 壳的离线版本把编辑器和本地 SQLite 打通医生离开工作站也能写病历网络恢复后自动同步。这里只提一个设计原则离线包只做数据暂存和编辑不做权限下发。权限校验必须在线完成离线状态下写好的病历只能暂存为待同步草稿绝不允许直接归档进正式病历库否则审计链路就断了。这个底线一定要守住不然合规上会出大问题。8. 踩坑记录编辑器的鬼往往藏在你看不见的地方8.1 那个让我排查了整整一天的样式丢失Bug有一次测试反馈医生保存病历后重新打开有些字体颜色变成了默认色。第一反应是数据库存取问题查了半天没结果。最后把 JSON dump 出来才发现编辑器存储的 mark 是span stylecolor: 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 这套方向目前看依然是这个场景下性价比最高的方案。希望这篇对正在做医疗信息化的朋友有帮助也欢迎大家交流各自在病历编辑器上踩到的坑。本文还有配套的精品资源点击获取