ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Node.js+ECharts构建医院就诊数据可视化系统:从HIS聚合到大屏联动

Node.js+ECharts构建医院就诊数据可视化系统:从HIS聚合到大屏联动 如果你接过医疗信息化的活儿一定知道那种感觉HIS 系统里躺着上千万条就诊记录院领导想看的“最近三个月门诊量变化”“哪个科室爆满”“异地患者都从哪来”信息科却只能用 Excel 拉个透视表对付。这次给一家三甲医院做就诊数据可视化分析系统我选了 Node.js 写数据接口、ECharts 画图表前后端全是 JavaScript一个人用一周就从零跑通了演示版。这套方案不是唯一解但它是中小医院信息科最容易维护、也最容易二次开发的组合。这篇文章把整个项目的架构、后端聚合处理、前端图表实现、上线前排坑完整写下来想直接抄作业的可以照着搭想了解背后的设计逻辑也能找到答案。整个项目有几个关键点值得先说透第一医院数据是典型的“大而杂”千万级记录做实时聚合查询必须提前想好缓存和预聚合方案第二ECharts 图表看起来简单真正让它准确表达数据含义坑全在坐标轴、格式化、联动这些细节里第三所有涉及患者信息的数据必须做脱敏这是底线。1. 医院就诊数据可视化从业务痛点、技术选型到系统架构1.1 业务痛点与为什么最终定了 Node.js ECharts先聊聊业务方到底想要什么。医院不缺数据缺的是把数据变成决策信息门诊量是涨是跌哪个科室即将超负荷住院患者集中在哪个年龄段急诊是否存在明显的时段高峰。传统的 HIS 报表都是固定格式想换个维度看数据得找开发提需求排期等半个月出来一张静态表。可视化系统的价值就是让业务方自己拖一拖、点一点就能从多个维度观察数据。技术选型上我对比过几条路线后端方案优点缺点结论Java Spring Boot医疗信息化行业主流大型医院常见工程重、启动慢、前后端语言分裂小团队做演示版太重Python Flask/FastAPI数据分析生态好团队不懂 Python 时维护成本高可选但不是最顺Node.js Express轻量、异步 I/O、开发和调试链路统一计算密集型场景不占优本项目首选前端图表库我也纠结过D3.js 灵活度最高但所有交互都要自己写一个柱状图要写几百行代码AntV 生态不错但版本演进快网上资料经常对不上ECharts 胜在开箱即用、中文文档和社区资源最丰富而且支持 Canvas 和 WebGL 渲染医院内部电脑配置普遍一般ECharts 的渲染性能足够。最后定的技术栈是 Node.js 18 Express 4 mysql2 ECharts 5。之所以强调 Node.js 18是因为项目里用了原生 fetch 和比较新的 ESM 模块规范Node 16 跑起来会有兼容性问题这也是这个项目在环境准备阶段最容易被忽略的一个点。1.2 整体架构与数据流转系统的物理部署很简单一台内网服务器MySQL 存数据Node.js 起服务浏览器直接访问可视化大屏页面。整个数据流是HIS 系统导出就诊记录 → ETL 清洗去重、脱敏、修正时间格式→ 写入 MySQL 原始表 → 定时任务聚合出统计表 → Express 提供 JSON 接口 → 前端 ECharts 渲染图表 → 点击图表触发联动查询为什么要做“原始表 统计表”两层因为医院就诊数据一旦积累几年主表很容易到千万行级别。每次图表加载都实时跑 GROUP BY 聚合数据库压力太大页面也会卡。把高频的统计维度按天、按科室、按年龄段提前算好放统计表接口查询就是一次简单的索引扫描速度可以快一个数量级。目录结构我也按“可复制到下一个项目”的标准来组织hospital-visual/ ├── data/ # ETL 脚本、导入工具 ├── server/ │ ├── app.js # Express 入口 │ ├── routes/ # 路由 │ ├── services/ # 业务逻辑、聚合查询 │ └── db.js # MySQL 连接池 ├── web/ │ ├── index.html # 大屏页面 │ ├── css/ │ ├── js/ │ └── lib/echarts.min.js └── sql/ ├── schema.sql # 建表语句 └── daily_stats.sql # 预聚合存储过程/定时任务2. Node.js 后端的数据加工链路表结构、聚合接口与缓存策略2.1 就诊数据表结构设计与数据预处理先看最核心的就诊记录表。实际项目中我建的是这样的结构CREATE TABLE visit_records ( id BIGINT AUTO_INCREMENT PRIMARY KEY, patient_no VARCHAR(32) COMMENT 患者编号, patient_age TINYINT COMMENT 年龄, patient_gender TINYINT COMMENT 0男 1女, region_code VARCHAR(16) COMMENT 地区编码, department_id INT COMMENT 科室ID, visit_type TINYINT COMMENT 1门诊 2急诊 3住院, visit_time DATETIME COMMENT 就诊时间, cost_amount DECIMAL(10,2) COMMENT 费用金额, INDEX idx_time (visit_time), INDEX idx_dept (department_id), INDEX idx_type (visit_type) ) COMMENT 就诊记录表;很多医院系统会存患者姓名但可视化根本用不到姓名反而涉及隐私合规。我在导入阶段就把姓名抹掉只留 patient_no这样即使数据库被导出也没法直接对应到具体个人。这是做医疗数据项目必须养成的习惯任何不需要的敏感字段在源头就不要带进来。数据预处理阶段踩过不少坑最常见的三个时间格式不统一。HIS 导出的 Excel 里有的是2024/1/5 9:30有的是2024-01-05 09:30:00直接塞进 MySQL 会出乱子。我写了一个清洗脚本统一转成YYYY-MM-DD HH:mm:ss。重复挂号记录。同一个患者同一天去同一个科室两次可能是复诊也可能是重复录入。我用 patient_no visit_time department_id 做联合去重先查一遍重复项再导入。空值处理。科室为空、费用为空的记录如果直接丢弃会影响统计准确性我统一置为“未知科室”费用置为 0并在统计时单独展示让业务方知道有多少脏数据占比。ETL 清洗脚本单独放data/clean.js用 Node.js 跑处理完的数据批量 insert通过connection.execute分批写入避免一次性导入把内存撑爆。一千万条记录大概十几分钟能导入完。2.2 三个核心聚合接口的实现后端接口的设计原则是返回给前端的结构直接就是 ECharts 能用的结构。很多新手习惯后端查出来什么就返回什么把转换逻辑全堆在前端结果图表配置又长又乱。我这边直接在后端把数组拆好前端拿到就是干净的 series 数据。第一个接口是就诊趋势按天统计就诊人次router.get(/trend, async (req, res) { const { days 30, departmentId } req.query; let sql SELECT DATE_FORMAT(visit_time, %Y-%m-%d) AS date, COUNT(*) AS cnt FROM visit_records WHERE visit_time DATE_SUB(CURDATE(), INTERVAL ? DAY) ; const params [days]; if (departmentId) { sql AND department_id ?; params.push(departmentId); } sql GROUP BY date ORDER BY date; const [rows] await pool.query(sql, params); res.json({ code: 0, data: { dates: rows.map(r r.date), counts: rows.map(r r.cnt) } }); });第二个接口是科室分布统计每个科室的就诊量用横向柱状图展示。第三个接口是患者构成按年龄分组和性别分组同时支持门诊/急诊/住院占比。这三个接口覆盖了医院最常问的三个问题“忙不忙”“哪里忙”“什么人来”。接口全部走异步查询用mysql2/promise连接池每个查询返回 Promise不会阻塞 Node.js 事件循环。这里要提醒一句Express 4 的路由处理函数里尽量把 async 包装好否则报错会直接挂掉进程项目里我统一加了asyncHandler包装器把异常交给全局错误处理中间件。2.3 缓存与预聚合接口性能的第一道闸门如果直接对 visit_records 做实时聚合数据量小的时候没感觉一旦超过几百万行DATE_FORMAT 加 GROUP BY 的查询随随便便几百毫秒遇到点击图表联动、频繁刷新数据库很容易被打满。我的方案分两层。第一层是预聚合表daily_stats把最常用的统计口径按天算好CREATE TABLE daily_stats ( stat_date DATE NOT NULL, department_id INT NOT NULL, visit_type TINYINT NOT NULL, patient_age_band VARCHAR(16) COMMENT 年龄段, cnt INT, cost_amount_total DECIMAL(12,2), PRIMARY KEY (stat_date, department_id, visit_type, patient_age_band) );每天凌晨用定时任务Node.js 的node-cron跑一次聚合把昨天的数据从原始表汇总到 daily_stats。前端查趋势、查科室分布时优先查这张表SQL 变成简单的范围扫描查询时间直接降到几十毫秒以内。第二层是 Redis 缓存。聚合结果在一天内基本不变完全可以缓存 5 分钟到半小时。接口先查 Redis有缓存直接返回没有缓存再查数据库查到后写回缓存设置过期时间。用 Redis 的另一个好处是多个服务实例共享缓存后面系统扩容时不用改代码。提示如果只是医院内网小系统不想引入 Redis也可以用 Node.js 进程里的内存缓存。我用的是一个轻量库node-cache演示场景完全够用部署时少一个依赖维护更简单。等真有并发压力了再上 Redis属于“按需扩展”。3. ECharts 前端落地布局、代码示例与图表联动3.1 大屏布局从原型到 CSS Grid前端大屏我用的是一张宽屏页面整体 16:9 适配。布局直接用 CSS Grid把页面分成几大区域顶部一行 KPI 数字卡片中间左边放科室分布柱状图中间主区域放就诊趋势折线图右边放患者类型饼图底部如果接入地图数据就放异地患者来源分布。大屏设计有个经验宁可少放两个图表也不要让页面挤成一团。医院领导看大屏核心诉求是 10 秒内 get 到重点。我把最重要的 KPI 放在顶部字号加大数字用醒目的颜色底下再用图表展示趋势和构成主次分明。页面结构大致如下div classdashboard div classkpi-row div classkpi-card今日门诊量 span idkpi-outpatient--/span/div div classkpi-card今日急诊量 span idkpi-emergency--/span/div div classkpi-card在院人数 span idkpi-inpatient--/span/div div classkpi-card平均费用 span idkpi-cost--/span/div /div div classchart-grid div iddepartmentChart classchart-box/div div idtrendChart classchart-box/div div idtypeChart classchart-box/div /div /divCSS Grid 里我设置了grid-template-columns: repeat(3, 1fr)第一个图表跨一列中间的折线图跨两列底部再放地图这样主次关系很清楚。所有图表的容器高度我统一设置了calc去适配窗口高度避免出现纵向滚动条。3.2 折线图与柱状图最常用的两个图表的完整配置就诊趋势折线图是最核心的图表。我用的配置如下const trendChart echarts.init(document.getElementById(trendChart)); async function loadTrend() { const res await fetch(/api/visits/trend?days30); const json await res.json(); const { dates, counts } json.data; trendChart.setOption({ tooltip: { trigger: axis }, grid: { left: 60, right: 20, top: 40, bottom: 60 }, xAxis: { type: category, data: dates, boundaryGap: false }, yAxis: { type: value, name: 人次 }, dataZoom: [ { type: inside, start: 0, end: 100 } ], series: [{ name: 就诊人次, type: line, smooth: true, symbol: none, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: rgba(64, 158, 255, 0.4) }, { offset: 1, color: rgba(64, 158, 255, 0) } ]) }, data: counts }] }); }boundaryGap: false很关键它让折线从坐标系左边缘开始而不是留白视觉上更连续。smooth让曲线更柔和配合渐变面积图领导一看就懂是“趋势向好还是向坏”。dataZoom是必须加的因为 30 天的数据还能看清如果选 90 天折线会挤成一团有了 dataZoom 可以拖动查看任意区间。科室分布柱状图我用的横向布局因为科室名称普遍较长纵向柱状图的 X 轴会放不下。横向柱状图的配置核心是把 xAxis 和 yAxis 互换再配合barGap控制柱子间距效果比纵向好得多。科室数量如果超过 10 个不要全都堆上去前端截取 TOP10其他归为“其他”图表可读性立刻提升。3.3 饼图与地图让占比和地域分布一目了然患者构成饼图展示门诊、急诊、住院的占比。ECharts 的饼图默认是普通饼图我改用玫瑰图扇区半径和角度同时反映数值视觉冲击力更强。饼图的 tooltip 里除了显示百分比我还用 formatter 显示具体人次让数据更准确tooltip: { trigger: item, formatter: function(params) { return ${params.name}br/人次${params.value}br/占比${params.percent}%; } }服务器返回的百分比可能是一个重复计算的字段需要注意最好让后端直接返回原始值前端用 ECharts 的 percent 计算避免后端和前端对不上。地图这块如果医院涉及异地就医分析可以用省份地图展示患者来源。注意 ECharts 5 之后不再自带中国地图数据需要自己注册。我从公开的 GeoJSON 库下载了省份边界数据通过echarts.registerMap(china, geoJson)注册后再使用。地图的 visualMap 组件设置 min/max颜色从浅到深让“哪个省份来的患者多”一目了然。如果不用地图也可以用横向柱状图替代效果差异不大地图强在空间直觉。3.4 图表联动点击科室柱状图看趋势这是让我在演示时加分的功能。业务方说想看“某个科室的单独趋势”一开始我以为要新加页面后来发现 ECharts 自带事件机制点击柱状图就能触发联动departmentChart.on(click, function(params) { const deptId params.data.id; loadTrend(deptId); // 重新请求趋势接口并刷新折线图 });这里有个必须处理的细节点击后要有一个视觉反馈告诉用户当前选中了哪个科室。我在点击事件里先把其他柱子变成灰色再把选中的柱子高亮然后在页面上显示一句“当前筛选某某科室点击空白处取消”。取消筛选时监听zr.on(click)或判断params.componentType xAxis来重置。这个交互看似简单但对用户体验的提升非常明显业务方会觉得系统“懂需求”。4. 上线前实测排坑从容器宽高到跨域的完整排查过程4.1 图表白屏的真相容器宽高与初始化时机的坑项目第一次跑起来页面是空白的控制台没有报错但图表就是不出来。排查了很久才发现问题不在 ECharts而在初始化时机。我当时的代码是在 DOMContentLoaded 后立即echarts.init(el)理论上等 DOM 加载完再初始化没问题。但那个大屏页面加入了 CSS Grid 布局而 Grid 布局的容器在图片、字体等资源加载完成前高度是 0。echarts.init在容器高度为 0 时并不会报错它会创建一个画布但宽度高度都是 0所以图表就不显示。解决方式有两种一是把初始化放到window.onload确保所有资源加载完成、布局已经算出真实宽高二是在初始化后主动调用一次chart.resize()强制 ECharts 读取容器的新尺寸。最稳的方案是两个都做window.addEventListener(load, function() { initCharts(); setTimeout(() { chart.resize(); }, 100); });后来我还发现如果页面在大屏显示器上缩放、切换全屏图表容器尺寸也会变。所以还要为 window 绑定 resize 事件做防抖后统一调用所有图表的resize()。一个小细节一个页面上有多个图表时最好维护一个图表实例数组统一遍历调用 resize不要一个个去记变量名。4.2 折线图 X 轴刻度乱跳category 与 time 的选择第二个坑出现在时间维度上。最开始我查的是“最近 30 天有就诊记录的日期”但如果某一天恰好没有记录后端返回的 dates 数组就会缺一天折线图就“跳过”这一天走势看起来是连续上升或下降实际上中间断了一截容易误导判断。排查过程是这样的我先打印后端返回的数据发现日期确实有缺失然后怀疑是 SQL 的问题加了generate_seriesMySQL 用递归 CTE去补全日期最后才发现即便补全了如果 ECharts 的 xAxis type 是category它只会按 data 数组的顺序画点不感知真实时间间隔缺失日期的处理还是要靠后端补数据。最终方案是两件事一起做。后端写一个补日期的函数生成完整的日期序列没有就诊记录的补 0。前端把 xAxis 改成xAxis: { type: time, min: startDate, max: endDate }同时 series 数据改成[timestamp, count]的二维数组。用type: time的好处是 ECharts 会根据时间戳自己决定刻度密度完全不用手动管理间隔。这个改动之后不管数据稀疏还是密集X 轴都合理了。4.3 setOption 数据不更新合并模式的真相做科室联动时又栽了一个跟头。点击科室柱状图趋势折线图应该切换到该科室的数据但 setOption 传了新数据后折线还是显示旧数据。控制台看了半天发现是新数据里某些日期没有值而 ECharts 默认的 setOption 是“合并模式”旧的 series 数据会保留一部分导致新旧数据混合显示。解决办法有两个直接用setOption(option, true)第二个参数 true 表示notMerge完全替换新配置或者用chart.clear()然后重新 setOption。我的习惯是改用notMerge: true因为 clear 会清掉事件绑定多一个重新绑定的步骤。trendChart.setOption({ // ...new option }, true);这个坑的排查思路值得说一句遇到图表数据不更新先别急着怀疑 ECharts 本身先在 setOption 前后各打一次chart.getOption()对比看数据到底有没有进去。很多时候是数据进去了但被合并逻辑覆盖了或者新数据为 null / undefined 被忽略了。4.4 Tooltip 换行与跨域开发期最容易卡住的两个点Tooltip 换行这个搜索量特别高是因为很多人在 formatter 里用\n结果不换行。这是 ECharts 的一个经典“坑”在 formatter 返回字符串时用的是 HTML 渲染换行要用br/用\n是无效的。看一下我最后的写法formatter: function(params) { const p Array.isArray(params) ? params : [params]; let html p[0].axisValue br/; p.forEach(item { html ${item.seriesName}${item.value} 人次br/; }); return html; }跨域问题则是另一个高频排查点。前端页面和后端接口不在同一个端口浏览器会拦截跨域请求。我在 Express 里加了cors中间件开发环境允许所有来源const cors require(cors); app.use(cors());但上线时记得收紧只允许医院内网的实际访问域名否则外部站点也可以随便调接口存在数据泄露风险。内网系统一般用 IP 访问那就把origin配成具体的 IP 列表。5. 性能优化与后续可扩展的方向5.1 大数据量渲染优化从 sampling 到按需加载当查询跨度变成“一整年”时折线图会有 365 个点ECharts 默认还扛得住如果看三年的数据上千个点Canvas 渲染就会开始掉帧。我做了三件事优化一是开启 sampling。ECharts 折线图内置了采样算法我用的sampling: lttbLTTB 算法会在尽量保留曲线形状的前提下减少绘制点的数量图表依然好看渲染大幅变快。series: [{ type: line, sampling: lttb, data: points }]二是按需加载。大屏页面首次打开只查询最近 7 天数据用户选择“30 天”“90 天”“全年”才重新请求更大范围的数据。数据量永远和用户选择匹配而不是一次全部拉回来。三是对后端接口做分页或限制条数。比如科室分布柱状图我限制最多返回 15 个科室加一个“其他”避免一次渲染上百根柱子。饼图同理占比低于阈值的类别合并成“其他”既保证信息完整又保证了图表的可读性。性能这块我特意用内网低配机器测过一个 2018 年的普通办公电脑整页 4 个图表 4 个 KPI 数字Full 渲染时间稳定在 1 秒以内属于可接受范围。如果后续要上更复杂的动画、3D 图表那就要考虑 WebGL 渲染和 GPU 加速了。5.2 这套系统还能往哪长实时告警、移动端与报表导出做完第一版业务方开始提更多需求。我梳理了几个后续扩展方向也都是这套架构能直接承接的实时就诊监控。对接医院挂号系统或排队叫号系统的消息队列Node.js 通过 WebSocket 把实时号源、当前排队人数推到前端大屏就可以秒级刷新而不是每次都轮询接口。移动端适配。现在大屏只服务 PC但院领导更常看手机。ECharts 本身支持触屏交互只需要把布局改成响应式配合grid的比例自适应就能快速出一个手机版。报表导出。业务方还是习惯 Excel毕竟要存档。后端可以加一个导出接口把当前页面涉及的数据生成 Excel 下载前端加个按钮调用。我用过exceljs这个库支持流式写入几十万行也没压力。权限系统。不同角色只能看不同维度比如财务科看费用相关图表门诊办看排班和就诊量。这个在 Express 加一个简单的 JWT 鉴权中间件就好图表配置本身不需要大改。还有一点数据可视化上了线不是结束而是开始。医院的数据每天在变有没有人定期 check 聚合任务是否跑成功、有没有新科室需要加到科室映射表这些运维工作要提前想好。我给项目加了一个简单的健康检查接口定时任务每天执行完会写一条日志同事每天早上扫一眼就知道数据是否正常。写在最后这套系统从零到现在我一个人用一周左右就做完了核心功能再花一周打磨细节和排坑。如果让我重新做一遍架构选型上我不会有太大调整Node.js ECharts 仍然是最适合这种中小型内部可视化项目的组合。但在具体实现上我会更早地做预聚合更早地设计好图表联动的交互规范而不是等到踩了坑再回头补。最后分享一个数据可视化项目的通用经验业务方提出的问题往往不是“我要一个折线图”而是“我想知道就诊高峰到底在几点”。把这些问题翻译成正确的图表类型、正确的统计口径才能真正把数据变成决策信息。技术只是工具对业务的理解才是可视化项目最值钱的部分。
返回列表