WEB电子病历系统开发实践:从技术选型到部署上线的完整指南
开一个WEB电子病历的项目听起来像个普通业务系统但真正做起来你会发现它比一般的企业级WEB开发要复杂得多。它不只是“网页上填个表单再存数据库”后面还牵扯到病历模板、结构化录入、打印归档、权限管控、审计追溯、网络部署甚至跟浏览器兼容性死磕。这篇文章就是我从零搭一套WEB电子病历的完整实践记录包括技术选型、前后端核心模块、安全设计以及上线时的坑希望给正在做或者准备做医疗类web项目的人一点参考。这套系统最终落地的形态是医生在浏览器里打开病历编辑页面按模板录入患者主诉、现病史、既往史、体格检查等内容保存后生成结构化病历文档支持一键打印和PDF导出同时护士站、科室主任、医务科都有不同的查看和审核入口。也就是说它不是静态的HTML页面而是一个包含前端交互、后端接口、数据权限、日志追踪的完整web工程。适合的读者是正在规划医疗信息系统的开发人员、想了解电子病历核心设计的产品/项目经理、以及刚入门web前端开发但想接触企业级项目的同学。1. 项目整体设计与技术选型1.1 电子病历系统的核心需求拆解在动工之前我把需求拆成五块每一块都直接影响技术选型。第一块是结构化录入。病历不能像Word文档一样自由写主诉、现病史、既往史这些字段要有明确的语义后续才能统计和科研检索。这就要求前端表单能动态渲染后端存储不能用大文本一把梭而是按字段存JSON或者分表。第二块是病历模板管理。不同科室的病历格式不一样内科要写既往史外科要写手术史儿科要写喂养史。模板需要支持后台配置医生选择模板后自动带出结构。第三块是打印与PDF归档。打印格式必须按医疗文书标准排版纸张大小通常是A4页边距、字体、行距都有要求。浏览器直接打印经常出现样式错乱所以需要专门的打印样式和导出方案。第四块是权限和审计。医生只能看自己科室的患者病历主任能看全科跨科室查看必须申请并留痕。每次查看、编辑、打印都要有审计日志。第五块是系统对接。电子病历不是孤岛要跟HIS医院信息系统同步患者基本信息、医嘱、检验检查结果。对接协议用HL7会很重我们先用REST接口后面再接HL7网关。1.2 为什么选择WEB架构而不是C/S架构早期很多电子病历是C/S架构需要安装客户端医生工作站、护士工作站都要装一套软件。但现在的医院环境里客户端版本更新、系统兼容、远程维护都很痛苦。WEB架构的核心优势是零安装、集中部署、浏览器访问只要内网通医生随手打开电脑就能用。另一个原因是前后端分离后的扩展性。我们既能做PC端的医生站也能把接口复用到移动查房端护士用平板扫码就能录入生命体征。而且现在web前端开发的生态已经非常成熟Vue、React、Element Plus这些组件库可以大大加速表单开发这在C/S时代是不敢想的。技术上我们最终选用了Vue 3 TypeScript Element Plus做前端FastAPI SQLAlchemy做后端PostgreSQL存业务数据Redis做会话和缓存。选FastAPI而不是Flask或Django主要是看中它的异步性能、自动生成OpenAPI文档以及面向未来高并发的扩展能力。Flask虽然简单但到了后期要自己处理很多异步和数据校验的事Django太重模型管理后台在这类系统里用处不大。对于web项目来说FastAPI SQLAlchemy的高性能服务组合非常适合医疗系统这种接口多、逻辑杂、并发不极端的场景。提示如果是纯传统团队Spring Boot Vue也是稳妥选择。Python技术栈更适合小团队快速迭代而且后续做数据分析和病历检索能直接复用同一套语言。2. 前端核心功能与实现要点2.1 动态表单渲染与结构化病历录入前端第一个硬骨头是动态表单。每个病历模板对应一组字段字段类型有文本、多行文本、单选、多选、日期、数字、下拉选择还有嵌套的“分段小节”和“重复组”。比如体格检查里的“浅表淋巴结”可以出现多组每组包含部位、大小、质地、活动度。我直接采用JSON Schema描述模板结构前端拿到schema后用递归组件渲染。这样后端管理模板时只需要维护JSON配置新增一个科室模板不用改代码。核心组件是一个“字段渲染器”根据字段的type属性动态加载对应的输入组件。interface SchemaField { key: string; label: string; type: text | textarea | radio | checkbox | date | number | select | group; options?: Array{ label: string; value: string }; required?: boolean; defaultValue?: unknown; children?: SchemaField[]; // 用于嵌套组 }递归组件里遇到type为group的字段时就循环渲染children。校验规则也写在schema里保存前用ajv做一次校验。这样医生填表时的体验是所见即所得数据格式又被结构约束不会出现“现病史”里填了一堆无关内容。有一个细节值得提醒不要把整个病历当成一个巨型表单一次性提交。我们设计了自动保存机制医生在输入框失焦后前端把当前section的数据单独提交一次隔15秒也会自动保存。这样就算医生忘了点保存或者浏览器崩溃数据也不会全丢。实测下来医生对这种“无声自动保存”接受度非常高。2.2 病历模板引擎与内容版本管理模板引擎是整个系统的中枢。我的做法是模板由一个“大纲结构”和若干“内容片段”组成大纲控制顺序与标题内容片段则是一个个可复用的block。比如“现病史”这个block包含起病情况、症状特点、伴随症状、诊治经过等子字段。模板配置界面里管理员拖拽block到大纲中保存后发布新版本。版本管理非常重要。病历打印出来后如果模板改了老病历的显示格式不能跟着变。所以系统在医生保存病历时会把当时使用的模板版本号、模板schema快照一起存在病历文档里。这个快照就是那个时刻打印样式的依据后面模板怎么改都不会影响历史病历。前端实现模板预览的方式也踩了坑。一开始用iframe加载模板内容问题很多样式隔离麻烦、打印时跨iframe样式丢失、图片加载闪动。后来改为直接渲染在当前页面中用CSS作用域隔离不同区块。打印时单独套用page样式效果稳定多了。打印和PDF导出我分别做了两条路。打印直接用浏览器原生打印但必须针对Webkit和Firefox分别调优。导出PDF用了一套开源方案先用html2canvas把病历区域生成图片再把图片塞进pdfmake的文档对象里。这个方案在小病历上能用但遇到几十页的长病历就会卡顿甚至内存溢出。后来我改成在服务端用WeasyPrint渲染PDF前端传schema和数据服务端用Python直接生成版式固定的PDF速度和质量都上了一个台阶。如果你们项目里也有web端PDF打印的需求建议优先考虑服务端渲染方案而不是纯前端截图。2.3 病历编辑器的选型与体验优化市面上富文本编辑器很多但真正适合写病历的不多。我们要的不是多媒体炫技而是结构化的段落。试过wangEditor、Quill、TipTap最终选了TipTap因为它是ProseMirror内核基于JSON文档模型能跟我们的schema自然对接。医生在某个“现病史”区块里输入的每一段都会转成结构化节点而不是一堆带style的html标签。为了贴合医疗文书习惯我们做了几个小功能一键插入“查体所见”常用语模板比如“神志清精神可查体合作”减少打字量支持上下标如m²、kg/m²数字自动带单位比如输入体温后自动显示“36.5℃”。这些看起来是锦上添花但医生每天的录入量非常大每省一秒钟都很重要。还有一个被很多人忽略的点病历编辑器要支持快捷键。我把“CtrlEnter”映射为“保存本段并进入下一段”“CtrlS”映射为“整份保存”。医生习惯了之后录入速度飞快。记住医疗系统的交互设计永远要把“效率”放在第一位而不是“炫酷”。3. 后端数据模型与接口设计3.1 病历数据模型怎么存才不会乱病历数据的核心存储结构我设计成三层患者表存基础信息姓名、性别、出生日期、住院号等。这部分数据从HIS同步不在电子病历里新建。病历文档表存一次住院过程的多份病历。表字段包括patient_id、document_type入院记录、病程记录、出院记录等、template_version、schema_snapshot、status。病历内容表存实际内容采用“jsonb”类型。比如postgresql的jsonb列存储每个区块的内容支持GIN索引后续查“主诉包含胸痛的患者”就直接用jsonb_path_ops索引。为什么不用关系表把每个字段拆成一列因为模板动态变化字段可能增加。如果用独立列每次改模板都要ALTER TABLE运维会疯掉。jsonb的方式虽然牺牲了一部分关系查询能力但换来了极大的灵活性。CREATE TABLE emr_document ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), patient_id VARCHAR(32) NOT NULL, visit_id VARCHAR(32) NOT NULL, doc_type VARCHAR(32) NOT NULL, template_version INTEGER NOT NULL, content JSONB NOT NULL, status VARCHAR(16) NOT NULL DEFAULT editing, created_by VARCHAR(32), created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() );配合SQLAlchemyORM模型里直接声明JSONB类型查询时可以用func.jsonb_extract_path_text做条件过滤。虽然FastAPI本身不带ORM但SQLAlchemy是独立的结合pydantic做请求校验非常顺滑。有一点要提醒jsonb字段一定要加默认值{}否则插入时很容易报错。3.2 接口权限与web安全细节电子病历是患者隐私的敏感数据web安全设计不能只停留在“登录后就能访问”的程度。我做了三层防护。第一层是身份认证采用JWT Refresh Token。Access Token有效期设为2小时Refresh Token有效期为7天。用户每次操作都会带着Token后端通过FastAPI的依赖注入校验当前用户和角色。第二层是数据权限同一个用户角色下医生只能看到自己所在科室的患者列表。这个通过科室维度过滤实现。医生ID关联科室ID查询时自动带上WHERE dept_id 当前用户dept_id。跨科访问需要申请后端保留一条“借阅申请”记录主任审批后受控访问权限只开放4小时期间每一次查看和修改都会记录。第三层是审计日志。所有写操作和敏感查操作都会异步写入audit_log表包括操作人、时间、IP、User-Agent、操作类型、目标病历ID、数据变更摘要。这块不能省后面出现医患纠纷时审计日志就是保护系统也是保护医院的重要依据。另外SQL注入和XSS是必须防的。SQLAlchemy的ORM参数化查询天然防注入但有一些手写SQL的地方必须用text()且不要拼接字符串。前端编辑器产生的HTML会经过后端处理把script标签全部剥离只保留白名单标签然后在前端展示时再用DOMPurify清洗一遍。双重清洗非常有用医疗系统里存了全院的病历一旦被XSS打到影响面不敢想。3.3 电子签名与防篡改设计虽然我做的是基础版但电子签名这块还是被需求方点名要求了。医疗病历的电子签名必须具有法律效力实现上可以分为两个层次第一层是用户身份签名。医生在自己负责的病历文书上点击“签名”时系统会调用本地证书服务用医生的数字证书对病历内容做一次摘要签名。摘要值存在signature字段里签名时间也一并记录。第二层是内容防篡改。签名后的病历如果有人修改系统会自动把修改前的版本另存为历史版本同时当前版本状态改为“修订”不允许直接覆盖签名版本。这就保证了“签名后不能再改”的流程闭环。这里多说一句有些小系统觉得电子签名就是加个图片签章那是大误区。签章图片只是一个视觉表达真正核心的是数字摘要和证书链。如果你们要用到正式医疗场景建议直接选通过行业认证的CA服务不要自己造轮子。4. 部署上线与性能优化4.1 Nginx同一个端口部署前后端两个服务我们的部署环境是内网服务器只对外开放一个80端口不可能为了后端单独开一个8000端口给浏览器访问。最终我采用Nginx反向代理让前端静态资源和后端接口共用同一个端口通过路径前缀区分。前端部署在/路径下后端接口统一使用/api前缀。server { listen 80; server_name emr.internal.hospital; root /opt/emr/frontend/dist; index index.html; location /api { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 120s; } location /static/ { expires 7d; add_header Cache-Control public; } location / { try_files $uri $uri/ /index.html; } }这样配完之后浏览器访问http://服务器IP/加载前端所有/api请求被转发到FastAPI进程。如果你还有别的web系统都要挂在同一台服务器上也可以用类似方式每个系统分配一个location前缀例如/emr、/his后端各自监听不同端口Nginx按路径分发。要是两个系统都要绑定同一个域名的根路径那就需要靠nginx的server_name或cookie隔离来做但那情况比较特殊一般不建议端口复用。还有一个容易踩的坑FastAPI挂在代理后面时不处理X-Forwarded-Proto的话有时候会分不清请求是http还是https导致生成的回跳链接变成http某些浏览器会拦截。注意在FastAPI应用里挂上ProxyHeadersMiddleware或者直接用uvicorn --proxy-headers启动。4.2 并发场景下数据库连接池与缓存策略电子病历的并发量不像电商那么夸张但有一点必须注意大批医生在早上查房后集中写病历会出现瞬时几十个写请求。如果数据库连接池太小后端的SQLAlchemy连接池很容易被打满导致503。我用的FastAPI SQLAlchemy的连接池配置如下engine create_engine( settings.database_url, pool_size20, max_overflow10, pool_pre_pingTrue, pool_recycle1800, )pool_pre_pingTrue非常重要它会定期探测连接是否还活着避免数据库重启后产生一堆失效连接。pool_recycle1800也很有必要MySQL默认的wait_timeout是8小时但中间如果被网络设备断开回收连接能避免拿到坏连接。缓存方面我不建议对病历正文做Redis缓存因为改了之后缓存一致性太复杂。我缓存的是两类数据一类是科室、医生、常用词汇等基础字典这类基本不变用Redis存字符串key带版本号后台管理页面修改字典时主动清缓存。另一类是模板配置每次医生打开录入页都要读取模板发布后基本不变缓存在本地进程里都行。我们用的是进程内cache工具因为单体应用够用不需要引入Redis分布式锁这种重量级的东西。4.3 医疗网环境下的浏览器兼容与性能瓶颈医院内网是一个神奇的地方你会发现很多电脑还在用很老的浏览器。我们的前端在实际使用中遇到最多的问题是“加载web视图时出错”和“浏览器不识别现代ES6语法”。我一开始用Vite构建默认target是baseline-widely-available但老机器上的Chrome 70还是不支持。后来把build.target降到了es2018并引入了core-js的按需polyfill才算把老机器的问题解决。强烈建议如果医院信息科不严格控制浏览器版本务必在最开始就问清楚最低版本不然后面返工非常痛苦。另外网页编辑器在部分电脑上会出现光标错乱、中文输入法卡顿的情况。排查下来是GPU硬件加速导致的直接在CSS里把编辑器的transform关闭并让该区域强制走CPU渲染能缓解大部分问题。如果遇到“could not create a WebGL context”这种报错通常是电脑显卡驱动或浏览器设置问题与我们系统无关但医生会归咎于电子病历。我们做了个兼容方案检测到WebGL不可用时不加载任何带3D变换的组件比如一些统计图表改用Canvas 2D绘制避免白屏。5. 常见问题与排查技巧实录5.1 跨域与接口401的排查前后端分离项目最经典的问题是跨域。我们的前端通过Nginx同端口代理后实际上请求是同源的不存在跨域。但如果开发环境下前端跑在5173端口后端跑在8000端口就需要在后端配置CORS。实测中最常见的坑是预检请求。前端用带Authorization头的JSON请求浏览器会先发一个OPTIONS请求。如果后端没有正确处理OPTIONS并返回200前端就会一直报“CORS error”。我在FastAPI里用的是CORSMiddleware配置的allow_origins是精确的域名列表而不是*因为*加allow_credentialsTrue会被浏览器拒绝。这个点很细节但足够卡一下午。还有401问题是token失效引起的。我们前端拦截所有API响应遇到401就静默跳转到登录页并刷新token。有一次用户反馈用着用着就掉线查下来是因为后端修改了token的签发内容导致旧的refresh token解析失败而前端没有做refresh token失效后的自动重新登录逻辑。后来加了判断refresh token失效时清空本地存储并刷新页面问题解决。5.2 打印样式错乱和PDF字体丢失病历打印是电子病历最容易挨骂的地方。第一次上线时医生打印出来的入院记录标题跑到第二页去了页脚被截断。我们针对打印做了一套专门的CSS利用media print控制页面元素隐藏和间距。media print { body { background-color: #fff; } .emr-toolbar { display: none !important; } .emr-page { width: 210mm; min-height: 297mm; padding: 15mm 20mm; margin: 0 auto; box-shadow: none; border: none; page-break-after: always; } pre, blockquote { page-break-inside: avoid; } }注意page-break-after: always要放在病历区块之间而不是最后一份病历后面否则会多打一张空白页。更好的方式是最后一页用page-break-after: auto。这个细节我们调试了很久。服务端PDF导出用的是WeasyPrint字体一开始缺失中文全变方块。解决办法是在服务器上安装中文字体如Noto Sans CJK SC然后通过CSS指定font-family: Noto Sans CJK SC。另外WeasyPrint对CSS网格布局支持不好在用的时候尽可能用传统float和table不要用flex或grid否则排版会乱。5.3 慢查询与数据库锁等待系统上线一个月后医生反馈打开病历列表越来越慢。排查时发现列表页默认查一个月的全部病历未加分页的接口直接返回几千条记录浏览器渲染耗时长。后来改成后端分页前端虚拟滚动一次只返回20条。还有一次某个后端接口执行时老是报数据库锁等待超时检查发现是生成PDF的时候在事务里执行了长时间的网络请求把并发事务拖住了。解决方法是把耗时操作移出数据库事务事务里只做快速的信息读取生成PDF放到异步任务队列里执行。下面整理了一份常见问题速查表算是这个项目中踩坑经验的浓缩问题现象可能原因快速解决方法接口偶发401Access token过期refresh token刷新失败检查JWT签发版本刷新失败时强制重新登录打印出现空白页最后一个区块仍带page-break-after最后一区块动态设置为autoPDF中文变方块服务器缺少中文字体安装Noto Sans CJK SC数据库连接池耗尽连接池太小或事务未及时释放调大pool_size设置pool_recycle老浏览器白屏前端构建目标太高target降为es2018引入core-js列表加载慢接口返回数据量大未分页后端分页前端虚拟滚动编辑器光标跳动GPU硬件加速引起关闭编辑器区域transform6. 一点将后续扩展的思考这个WEB电子病历做完之后我最大的体会是别把它当成“网页填表”它本质上是一个结构化文档系统 权限系统 审计系统 打印排版系统的综合体。仅仅是打印这一条线就牵扯到CSS、PDF引擎、字体、浏览器兼容完全不比后端逻辑简单。如果让我重做一次我会在项目一开始就把所有科室的病历模板梳理成统一的JSON Schema并提前确认医院内网的浏览器版本基线这两件事直接决定后面开发顺畅度。另外一个非常值得做的是将病历数据用于临床科研检索。现在已经用PG的jsonb字段可以做基础的结构化检索后续可以接一个全文检索引擎把病历中的主诉、诊断、用药情况索引起来支持医生按条件查询历史相似病历。这个需求在科室主任那里非常受欢迎属于低成本高价值的功能。最后再分享一个小技巧所有和医生交互的表单一定要记录从打开到保存的时长这里面隐藏着很多流程优化的线索。比如我们发现某个复选框模板导致录入时间翻倍简化之后医生好评大增。别忘了系统最终服务的是使用者多观察他们怎么操作比多写十行代码更有用。