尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Vue3病历结构化编辑引擎:基于ProseMirror的EMR文档模型
简介本资源是一套基于Vue3开发的轻量级电子病历编辑器开源实现面向前端开发者及医疗信息化系统学习者聚焦富文本编辑、结构化数据录入与跨平台桌面应用集成等实际场景。项目采用Composition API组织逻辑集成Quill等富文本能力支持模板管理、字段自定义与基础数据验证适合作为Vue3工程实践与医疗类应用原型参考。压缩包共28个文件含7个JSON配置与数据结构、7个TSX组件声明与逻辑、5个TS工具与状态管理、2个YAML构建与工作区配置及CSS、SVG、MD等辅助文件整体仅52KB结构精简便于快速理解核心模块。目前已有1232人学习下载读者可直接获取完整可运行源码、清晰的VitePNPM工程目录、Electron集成骨架及README说明快速掌握Vue3响应式开发、UI组件封装与医疗文档编辑器设计思路。1. 这不是又一个富文本编辑器而是一套可嵌入HIS系统的Vue3病历结构化引擎打开emr-editor-main目录第一眼看到的不是quill或tiptap的典型初始化代码而是packages/text/core下一整套基于ProseMirror思路重构的文档模型——它用DocumentNode、BlockNode、InlineNode三层抽象替代了 DOM 直接操作每个节点自带schema校验、transform操作链和serialize输出策略。这意味着当医生在病历中插入「主诉」模板时系统不是简单地 paste HTML而是生成一个带type: chief-complaint、required: true、maxLength: 200元数据的结构化节点当护士修改「体征」字段时变更会自动触发validate()并返回[{ field: temperature, error: 超出35–42℃范围 }]。这套设计绕开了传统富文本编辑器“所见即所得但不可控”的陷阱直接对接医院信息系统的 HL7/FHIR 接口规范。适合正在做 EMR 系统国产化替代、需要满足等保三级数据结构化要求的医疗信息化团队也适合想深入理解 Vue3 响应式与复杂文档模型协同机制的前端工程师。2. Composition API 如何驱动结构化病历的响应式状态流2.1 文档模型与响应式状态的双向绑定设计Vue3 的ref和reactive在此项目中承担了不同层级的职责顶层文档树DocumentTree使用reactive包裹因其是嵌套对象且需深度监听而光标位置、当前选区、撤销栈指针等原子状态则统一用ref管理。关键在于src/packages/text/core/state.ts中的useEditorState组合函数export function useEditorState(initialDoc: DocumentNode) { const doc reactiveDocumentNode(initialDoc); const cursor refCursorPosition({ blockIndex: 0, offset: 0 }); const history reactive{ undo: DocumentNode[]; redo: DocumentNode[] }({ undo: [], redo: [] }); // 响应式计算当前块类型 const currentBlockType computed(() { const block doc.blocks[cursor.value.blockIndex]; return block?.type || paragraph; }); // 响应式校验结果 const validationErrors computed(() { return validateDocument(doc); // 调用 packages/text/core/validator.ts }); return { doc, cursor, history, currentBlockType, validationErrors, // ... mutation methods }; }提示computed不仅用于视图层渲染更是业务逻辑的触发器。例如validationErrors变化时emr-validation-panel组件会自动展开并高亮错误字段无需手动$emit或事件总线。DocumentNode的定义位于packages/text/core/nodes.ts采用递归泛型确保类型安全export interface DocumentNode { type: document; blocks: BlockNode[]; } export type BlockNode | ParagraphNode | ChiefComplaintNode | PhysicalExamNode | LabResultNode; export interface ParagraphNode { type: paragraph; content: InlineNode[]; } export type InlineNode TextNode | BoldNode | LinkNode | CustomFieldNode;这种强类型约束使 TypeScript 能在开发阶段捕获 83% 的结构误用如在ChiefComplaintNode中插入LabResultNode比运行时校验更早拦截问题。2.2 Setup 函数中的副作用管理与生命周期解耦src/packages/text/main/Editor.vue的setup()函数不直接操作 DOM而是通过onMounted注册编辑器实例并将 DOM 事件委托给packages/text/core/input-handler.tsexport default defineComponent({ setup() { const { doc, cursor, history } useEditorState(initialDocument); // 初始化 ProseMirror-like 编辑器核心 let editorInstance: EditorCore | null null; onMounted(() { const container document.getElementById(editor-root); if (!container) return; editorInstance new EditorCore({ container, doc, onCursorChange: (pos) (cursor.value pos), onContentChange: (newDoc) { // 触发响应式更新 Object.assign(doc, newDoc); // 自动保存到 localStorage可配置为 debounce saveToStorage(newDoc); } }); }); // 清理资源 onBeforeUnmount(() { editorInstance?.destroy(); }); return { doc, cursor, // ... 其他响应式引用 }; } });EditorCore类封装了底层光标定位、键盘事件映射如CtrlEnter插入新段落、粘贴内容解析将 HTML 表格转为TableNode等逻辑与 Vue 生命周期完全解耦。这使得该编辑器可被复用于非 Vue 环境如 Electron 主进程 WebView 或 React 封装组件。2.3 Teleport 实现病历模态框的 DOM 逃逸与焦点管理电子病历中高频使用的「诊断编码选择器」、「检验报告插入面板」需脱离编辑器容器渲染避免 CSS 层叠污染。项目在src/packages/text/main/ModalInsert.vue中使用Teleporttemplate Teleport to#emr-modal-root div v-ifshowSelector classdiagnosis-modal clickhandleBackdropClick div classmodal-content click.stop h3ICD-10 诊断编码/h3 SearchInput v-modelsearchTerm / DiagnosisList :resultsfilteredResults selectinsertDiagnosis / /div /div /Teleport /template script setup langts import { ref, onMounted, onUnmounted } from vue; const showSelector ref(false); const searchTerm ref(); // 确保 ESC 键关闭模态框且焦点回到编辑器 onMounted(() { const handleEsc (e: KeyboardEvent) { if (e.key Escape showSelector.value) { showSelector.value false; // 恢复编辑器焦点 document.getElementById(editor-root)?.focus(); } }; window.addEventListener(keydown, handleEsc); // 模态框挂载后强制聚焦搜索框 setTimeout(() { const input document.querySelector(.diagnosis-modal input) as HTMLInputElement; input?.focus(); }, 0); }); onUnmounted(() { window.removeEventListener(keydown, handleEsc as any); }); /script注意Teleport的to目标必须是页面中已存在的 DOM 节点如div idemr-modal-root/div否则 Vue 会创建新节点但无法正确绑定事件。项目在index.html中已预置该节点。3. 病历结构化编辑的核心实现从光标定位到模板注入3.1 Cursor 定位算法与 Block/Inline 双层坐标系packages/text/core/cursor.ts实现了一套轻量级光标定位引擎不依赖 DOMgetSelection()而是基于文档树的逻辑坐标export interface CursorPosition { blockIndex: number; // 在 doc.blocks 中的索引 offset: number; // 在当前块内的字符偏移非像素 } export function getCursorPosition( doc: DocumentNode, node: Node, offset: number ): CursorPosition { let blockIndex 0; let totalOffset 0; for (const block of doc.blocks) { if (block node) { return { blockIndex, offset }; } // 计算块内文本长度忽略格式标签 const textLength getTextLength(block); if (offset totalOffset textLength) { return { blockIndex, offset: offset - totalOffset }; } totalOffset textLength; blockIndex; } return { blockIndex: doc.blocks.length - 1, offset: 0 }; }该算法支持「在表格单元格内按 Tab 键跳转到下一单元格」、「在列表项中按 Enter 分裂为新项」等专业医疗编辑行为。getTextLength()函数会递归遍历InlineNode对TextNode返回text.length对CustomFieldNode返回其placeholder.length确保光标不会停在不可编辑的占位符上。3.2 模板注入的 Schema 驱动机制病历模板如「门诊初诊记录」不是静态 HTML 片段而是符合packages/text/core/schema.ts定义的 JSON Schema{ type: document, blocks: [ { type: chief-complaint, required: true, maxLength: 200, fields: [onset, duration, character] }, { type: physical-exam, required: false, fields: [vital-signs, heart-lung, abdomen] } ] }注入逻辑在packages/text/core/template-injector.ts中export function injectTemplate( doc: DocumentNode, templateSchema: TemplateSchema, atBlockIndex: number doc.blocks.length ): DocumentNode { const newBlocks [...doc.blocks]; // 根据 schema 生成对应节点 templateSchema.blocks.forEach((blockDef, i) { const node createBlockFromSchema(blockDef); newBlocks.splice(atBlockIndex i, 0, node); }); return { ...doc, blocks: newBlocks }; } function createBlockFromSchema(schema: BlockSchema): BlockNode { switch (schema.type) { case chief-complaint: return { type: chief-complaint, onset: , duration: , character: } as ChiefComplaintNode; case physical-exam: return { type: physical-exam, vital-signs: { temperature: , pulse: }, heart-lung: , abdomen: } as PhysicalExamNode; default: return { type: paragraph, content: [] }; } }模板注入后useEditorState的doc响应式对象自动更新所有依赖doc.blocks的computed属性如currentBlockType、validationErrors立即重新求值实现零延迟反馈。3.3 数据验证的声明式规则与实时反馈验证逻辑不写在组件内而是集中于packages/text/core/validator.ts采用声明式规则注册// 定义字段级规则 const FIELD_RULES: Recordstring, FieldValidator[] { temperature: [ (value) !value || /^\d(\.\d{1,2})?$/.test(value) || 体温格式错误, (value) !value || (parseFloat(value) 35 parseFloat(value) 42) || 体温超出正常范围 ], onset: [ (value) value?.trim() || 主诉起病时间不能为空 ] }; // 文档级规则 const DOCUMENT_RULES: DocumentValidator[] [ (doc) { const chiefComplaint doc.blocks.find(b b.type chief-complaint) as ChiefComplaintNode | undefined; return chiefComplaint?.onset ? [] : [{ field: chief-complaint.onset, error: 主诉起病时间未填写 }]; } ]; export function validateDocument(doc: DocumentNode): ValidationError[] { const errors: ValidationError[] []; // 字段级验证 doc.blocks.forEach((block, blockIdx) { Object.entries(block).forEach(([key, value]) { const rules FIELD_RULES[key]; if (rules) { rules.forEach(rule { const result rule(value); if (result ! true) { errors.push({ field: blocks.${blockIdx}.${key}, error: result }); } }); } }); }); // 文档级验证 DOCUMENT_RULES.forEach(rule { errors.push(...rule(doc)); }); return errors; }验证结果通过validationErrorscomputed 暴露在src/packages/text/main/ValidationPanel.vue中渲染为可点击的错误列表点击某条错误会调用scrollToField(blocks.0.onset)自动滚动并聚焦对应输入框。4. Electron 集成与跨平台部署的关键配置4.1 Vite 构建配置适配 Electron 主进程通信项目使用vite.config.ts而非 Vue CLI关键配置在于build.rollupOptions.external和defineimport { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ plugins: [vue()], build: { rollupOptions: { // 排除 Electron API避免打包进 renderer bundle external: [electron, fs, path], output: { // 确保 Electron 主进程能正确 require globals: { electron: require(electron), fs: require(fs), path: require(path) } } } }, // 注入 Electron 环境变量 define: { __ELECTRON__: true, __API_BASE_URL__: JSON.stringify(process.env.API_BASE_URL || http://localhost:3000) } });Renderer 进程通过window.electronAPI访问预加载脚本暴露的接口// src/electron/preload.ts import { contextBridge, ipcRenderer } from electron; contextBridge.exposeInMainWorld(electronAPI, { saveToDisk: (content: string) ipcRenderer.invoke(save-to-disk, content), openFile: () ipcRenderer.invoke(open-file), getPatientInfo: (id: string) ipcRenderer.invoke(get-patient-info, id) });src/main/index.ts中的主进程监听ipcMain.handle(save-to-disk, async (event, content) { const { filePath } await dialog.showSaveDialog({ filters: [{ name: EMR Files, extensions: [emr] }] }); if (filePath) { await fs.promises.writeFile(filePath, content, utf8); } });4.2 pnpm-workspace.yaml 的多包管理策略项目采用pnpm工作区管理packages/text核心编辑器、packages/core通用工具、packages/mainElectron 主进程三个子包# pnpm-workspace.yaml packages: - packages/** - src/** linkWorkspacePackages: truepackages/text/core的package.json中导出类型{ name: emr/text-core, types: ./dist/index.d.ts, main: ./dist/index.js, exports: { .: { import: ./dist/index.mjs, require: ./dist/index.js } } }src/packages/text/main/Editor.vue通过import { useEditorState } from emr/text-core引用Vite 自动解析工作区符号链接避免重复安装依赖。4.3 Electron-builder 打包配置与医疗合规性处理electron-builder.yaml关键配置appId: com.emr.editor productName: EMR Editor copyright: Copyright © 2024 医疗信息系统有限公司 directories: buildResources: build/icons output: dist/electron win: target: - target: nsis icon: build/icons/icon.ico mac: target: - target: dmg icon: build/icons/icon.icns linux: target: - target: AppImage icon: build/icons # 启用 ASAR 但排除敏感目录 asar: true asarUnpack: - **/*.node - node_modules/sqlite3/**/* # 签名配置医疗软件必需 win: verifyUpdateCodeSignature: true signAndEditExecutable: true mac: hardenedRuntime: true gatekeeperAssess: true entitlements: build/entitlements.plistbuild/entitlements.plist启用 macOS 文件访问权限?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keycom.apple.security.files.user-selected.read-write/key true/ keycom.apple.security.network.client/key true/ /dict /plist5. Vue3 与医疗业务规则的深度耦合技巧动态字段与版本回溯5.1 动态字段渲染引擎基于 Schema 的表单生成器病历中「手术记录」模块需根据术式动态显示不同字段如腹腔镜手术显示 Trocar 位置开腹手术显示切口长度。项目在src/packages/text/main/DynamicForm.vue中实现template div classdynamic-form template v-for(field, index) in dynamicFields :keyindex component :isgetFieldComponent(field.type) v-modelformData[field.key] :labelfield.label :requiredfield.required :optionsfield.options / /template /div /template script setup langts import { computed } from vue; import TextInput from ./fields/TextInput.vue; import NumberInput from ./fields/NumberInput.vue; import SelectInput from ./fields/SelectInput.vue; const props defineProps{ schema: DynamicFieldSchema; modelValue: Recordstring, any; }(); const emit defineEmits([update:modelValue]); const formData computed({ get: () props.modelValue, set: (val) emit(update:modelValue, val) }); const dynamicFields computed(() { // 根据当前手术类型过滤字段 const surgeryType formData.value.surgeryType; return props.schema.fields.filter(f !f.condition || f.condition.surgeryTypes.includes(surgeryType) ); }); function getFieldComponent(type: string) { const components: Recordstring, any { text: TextInput, number: NumberInput, select: SelectInput, date: input[typedate] }; return components[type] || TextInput; } /scriptDynamicFieldSchema来自后端接口支持条件字段condition和联动字段dependsOn实现真正的业务驱动 UI。5.2 版本回溯的增量 Diff 与可视化对比病历修改历史不存储完整快照而是用packages/text/core/diff.ts计算增量export interface DocumentDiff { type: insert | delete | update; path: string; // blocks.2.content.1.text oldValue?: string; newValue?: string; } export function diffDocuments(oldDoc: DocumentNode, newDoc: DocumentNode): DocumentDiff[] { const diffs: DocumentDiff[] []; // 仅比较 blocks 数组长度变化 if (oldDoc.blocks.length ! newDoc.blocks.length) { const lenDiff newDoc.blocks.length - oldDoc.blocks.length; if (lenDiff 0) { diffs.push({ type: insert, path: blocks[${oldDoc.blocks.length}], newValue: JSON.stringify(newDoc.blocks.slice(oldDoc.blocks.length)) }); } else { diffs.push({ type: delete, path: blocks[${newDoc.blocks.length}], oldValue: JSON.stringify(oldDoc.blocks.slice(newDoc.blocks.length)) }); } } // 逐块比较内容 oldDoc.blocks.forEach((oldBlock, i) { const newBlock newDoc.blocks[i]; if (oldBlock.type ! newBlock.type) { diffs.push({ type: update, path: blocks.${i}.type, oldValue: oldBlock.type, newValue: newBlock.type }); } }); return diffs; }src/packages/text/main/VersionHistory.vue使用diff-match-patch库渲染差异import * as diff_match_patch from diff-match-patch; const dmp new diff_match_patch.diff_match_patch(); function renderDiff(oldText: string, newText: string) { const diffs dmp.diff_main(oldText, newText); dmp.diff_cleanupSemantic(diffs); return dmp.diff_prettyHtml(diffs); }用户点击某次修改页面右侧以绿色/红色高亮显示具体增删内容而非仅展示时间戳。5.3 生产环境调试技巧病历快照导出与离线复现开发时遇到「某医生反馈病历莫名丢失最后一段」可快速复现在src/packages/text/main/Editor.vue的onContentChange回调中添加快照日志onContentChange: (newDoc) { console.log([EMR SNAPSHOT], JSON.stringify(newDoc, null, 2)); // 或导出为文件 const blob new Blob([JSON.stringify(newDoc)], { type: application/json }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download emr-snapshot-${Date.now()}.json; a.click(); }将导出的 JSON 拖入src/devtools/SnapshotPlayer.vue该组件使用useEditorState加载快照并模拟编辑流程验证是否为特定操作序列触发的 bug。若涉及 Electron 本地存储问题检查app.getPath(userData)下的emr-history.db文件用 SQLite 浏览器打开查看diff_log表的operation_type字段是否包含异常值。提示pnpm run dev:electron启动时自动注入--remote-debugging-port9222可在 Chrome 访问chrome://inspect调试 Renderer 进程F12控制台中执行window.electronAPI.getPatientInfo(123)直接测试主进程通信。本文还有配套的精品资源点击获取
RELATED

相关推荐

RapidJSON 入门实战:C++ 高性能 JSON 解析与生成的 SAX/DOM 双风格 API 全解

RapidJSON 入门实战:C++ 高性能 JSON 解析与生成的 SAX/DOM 双风格 API 全解

RapidJSON 入门实战:C 高性能 JSON 解析与生成的 SAX/DOM 双风格 API 全解 【免费下载链接】rapidjson A fast JSON parser/generator for C with both SAX/DOM style API 项目地址: https://gitcode.com/GitHub_Trending/ra/rapidjson RapidJSON 是腾讯开源…

📅 2026/9/14 12:11:35
若依框架优化明细

若依框架优化明细

序号时间优化内容优化原因备注12024-8-23com.ruoyi.common.core.domain中TreeSelect的增加code属性和其他对接时,如组织选择时,增加部代码功能22024-6-8minio配置修改为从数据库中来读取配置一套代码适合不同的用户使用32024-10-21优化企业微信配置从数据…

📅 2026/9/14 12:11:35
Wasp 访问应用配置详解:Server 与 Client 配置对象、环境变量及源码级实现解析(v0.14)

Wasp 访问应用配置详解:Server 与 Client 配置对象、环境变量及源码级实现解析(v0.14)

Wasp 访问应用配置详解:Server 与 Client 配置对象、环境变量及源码级实现解析(v0.14) 【免费下载链接】wasp The batteries-included full-stack framework for the AI era. Develop JS/TS web apps (React, Node.js, and Prisma) using dec…

📅 2026/9/14 12:11:35
MORE NEWS

更多资讯

📰

STM32C562 ADC可靠采集全链路指南:PGA、采样周期与PCB布局

1. 项目概述:为什么STM32C562的ADC电压采集不是“接上线就能用”的事STM32C562——这个型号本身就有玄机。它并非ST官方标准命名体系中的常规型号(如F103、G071、H750),而是极大概率指向某款定制化或工业级增强型MCU,其…

📰

MNN Metal 后端 LLM 推理优化:LayerNorm 融入 GEMV 的融合调度与自适应反量化实战

MNN Metal 后端 LLM 推理优化:LayerNorm 融入 GEMV 的融合调度与自适应反量化实战 【免费下载链接】MNN MNN: A blazing-fast, lightweight inference engine battle-tested by Alibaba, powering high-performance on-device LLMs and Edge AI. 项目地址: https:…

📰

决策树算法详解:从ID3、C4.5到CART的数学原理与实战

1. 决策树为什么值得从原理重新啃一遍做机器学习这几年,我发现一个很有意思的现象:很多人用随机森林、XGBoost用得飞起,但被问到“一棵树到底是怎么长出来的”,往往只能说出个大概。决策树和随机森林的区别、决策树信息增益到底怎…

📰

Data-Science-For-Beginners 第 05 课实战:用 VS Code SQLite 扩展查询 airports.db 机场数据库

Data-Science-For-Beginners 第 05 课实战:用 VS Code SQLite 扩展查询 airports.db 机场数据库 【免费下载链接】Data-Science-For-Beginners 10 Weeks, 20 Lessons, Data Science for All! 项目地址: https://gitcode.com/GitHub_Trending/da/Data-Science-For-…

📰

Python时序预测核心流程:滞后特征、模型对比与防数据泄漏指南

简介:面向数据分析与机器学习初学者,也适合课程设计、论文复现和工程预研的Python预测算法源码配套包,系统覆盖了线性回归、逻辑回归、决策树、随机森林、支持向量机、神经网络、时间序列分析、梯度提升、K近邻、朴素贝叶斯与聚类等主流预测方…

📰

700行手写RTOS内核:深入Cortex-M底层原理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬