Node.js+ECharts构建医院就诊数据可视化系统:从HIS聚合到大屏联动
2026/9/24 21:36:15 网站建设 项目流程

如果你接过医疗信息化的活儿,一定知道那种感觉: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 class="dashboard"> <div class="kpi-row"> <div class="kpi-card">今日门诊量 <span id="kpi-outpatient">--</span></div> <div class="kpi-card">今日急诊量 <span id="kpi-emergency">--</span></div> <div class="kpi-card">在院人数 <span id="kpi-inpatient">--</span></div> <div class="kpi-card">平均费用 <span id="kpi-cost">--</span></div> </div> <div class="chart-grid"> <div id="departmentChart" class="chart-box"></div> <div id="trendChart" class="chart-box"></div> <div id="typeChart" class="chart-box"></div> </div> </div>

CSS 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?days=30'); 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_series(MySQL 用递归 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: 'lttb',LTTB 算法会在尽量保留曲线形状的前提下减少绘制点的数量,图表依然好看,渲染大幅变快。

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 仍然是最适合这种中小型内部可视化项目的组合。但在具体实现上,我会更早地做预聚合,更早地设计好图表联动的交互规范,而不是等到踩了坑再回头补。

最后分享一个数据可视化项目的通用经验:业务方提出的问题,往往不是“我要一个折线图”,而是“我想知道就诊高峰到底在几点”。把这些问题翻译成正确的图表类型、正确的统计口径,才能真正把数据变成决策信息。技术只是工具,对业务的理解才是可视化项目最值钱的部分。

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

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

立即咨询