WEB电子病历系统开发实践:从技术选型到部署上线的完整指南
2026/9/24 20:33:36 网站建设 项目流程

开一个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℃”。这些看起来是锦上添花,但医生每天的录入量非常大,每省一秒钟都很重要。

还有一个被很多人忽略的点:病历编辑器要支持快捷键。我把“Ctrl+Enter”映射为“保存本段并进入下一段”,“Ctrl+S”映射为“整份保存”。医生习惯了之后,录入速度飞快。记住,医疗系统的交互设计永远要把“效率”放在第一位,而不是“炫酷”。

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() );

配合SQLAlchemy,ORM模型里直接声明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_size=20, max_overflow=10, pool_pre_ping=True, pool_recycle=1800, )

pool_pre_ping=True非常重要,它会定期探测连接是否还活着,避免数据库重启后产生一堆失效连接。pool_recycle=1800也很有必要,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_credentials=True会被浏览器拒绝。这个点很细节,但足够卡一下午。

还有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最后一区块动态设置为auto
PDF中文变方块服务器缺少中文字体安装Noto Sans CJK SC
数据库连接池耗尽连接池太小,或事务未及时释放调大pool_size,设置pool_recycle
老浏览器白屏前端构建目标太高target降为es2018,引入core-js
列表加载慢接口返回数据量大,未分页后端分页,前端虚拟滚动
编辑器光标跳动GPU硬件加速引起关闭编辑器区域transform

6. 一点将后续扩展的思考

这个WEB电子病历做完之后,我最大的体会是:别把它当成“网页填表”,它本质上是一个结构化文档系统 + 权限系统 + 审计系统 + 打印排版系统的综合体。仅仅是打印这一条线,就牵扯到CSS、PDF引擎、字体、浏览器兼容,完全不比后端逻辑简单。如果让我重做一次,我会在项目一开始就把所有科室的病历模板梳理成统一的JSON Schema,并提前确认医院内网的浏览器版本基线,这两件事直接决定后面开发顺畅度。

另外一个非常值得做的是将病历数据用于临床科研检索。现在已经用PG的jsonb字段可以做基础的结构化检索,后续可以接一个全文检索引擎,把病历中的主诉、诊断、用药情况索引起来,支持医生按条件查询历史相似病历。这个需求在科室主任那里非常受欢迎,属于低成本高价值的功能。最后再分享一个小技巧:所有和医生交互的表单,一定要记录从打开到保存的时长,这里面隐藏着很多流程优化的线索。比如我们发现某个复选框模板导致录入时间翻倍,简化之后医生好评大增。别忘了,系统最终服务的是使用者,多观察他们怎么操作,比多写十行代码更有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询