Node.js+Vue+MySQL打造医院体检预约管理系统全栈实战

发布时间:2026/9/15 5:53:45
Node.js+Vue+MySQL打造医院体检预约管理系统全栈实战 体检预约这件事很多医院内部其实还在用Excel表格加人工排期的方式在跑。科室排班、预约登记、到检确认全靠前台人员手工操作高峰期一乱就容易漏约错约。早两年我接手过一家体检中心的信息化改造需求当时就是用了Node.js Vue ElementUI Express MySQL这套组合做了一套医院体检预约信息管理系统。整套系统跑下来预约效率提升很明显前台人员也从一堆纸质报表里解放了出来。这篇文章就围绕这个项目的完整落地过程来写。不管你是刚学完前端三件套想找个完整项目练手还是已经在做管理类系统想参考一下前后端分离的架构设计这篇分享都有参考价值。我会把技术选型的理由、数据库表设计、核心后端接口、前端页面实现还有真正折磨人的环境配置和坑点排查全部拆开来讲。1. 项目整体设计与技术选型思路1.1 需求到底要解决什么问题做系统之前得先搞清楚业务端到底痛在哪里。体检预约管理表面上是“登记一下谁哪天来体检”但实际拆开需求链路比想象中长用户或者前台代操作要选体检套餐、选体检日期、看当天剩余号源、提交预约后台要管理体检项目、维护套餐价格、安排每日可预约名额到了体检当天前台要核销预约记录变成“已到检”体检完成后报告归档用户可能还要登录查看电子报告。这还不算管理员要看的每日预约量统计、体检项目热度排行、销售额汇总这些数据需求。把流程捋顺后系统角色就清晰了普通用户端做预约和报告查询前台/护士端做登记和到检核销管理员端做基础数据维护和统计。一套系统解决三个角色的问题这就是核心目标。1.2 技术栈为什么选这四件套这套技术组合在中小型管理系统中非常经典每一层都有明确的理由Vue 2 ElementUIVue 的响应式数据绑定非常适合表单类操作密集的管理系统ElementUI 则直接提供了一套成熟的后台组件库表格、表单、日期选择器、弹窗、消息提示全是现成的不用自己从零写样式和交互一周左右就能把页面框架搭完。Node.js Express后端用 Node.js 是因为前端团队能直接上手前后端统一用 JavaScript不用维护两套语言。Express 轻量、灵活中间件机制清晰不像某些重型框架要引入一堆概念适合项目规模中等的业务系统。开发效率很高起一个服务几行代码就行。MySQL预约数据是典型的强关系型数据——用户、套餐、预约记录、支付记录之间存在明确关联用 MySQL 的关系表和事务能力来保证数据一致性是最稳妥的。体检预约涉及真实到检记录任何数据错乱都可能影响医疗流程宁可选保守可靠的方案。1.3 系统模块划分与架构设计我采用的是前后端完全分离的结构前端单独跑一个服务后端单独跑一个服务通过 RESTful API 通信前端放在 8080 端口后端放 3000 端口本地开发时用代理转发解决跨域。后端项目结构按功能模块拆分不是按文件类型堆server/ ├── app.js # Express实例入口注册中间件和路由 ├── config/ │ └── db.js # MySQL连接池配置 ├── routes/ # 路由层定义接口路径 │ ├── user.js # 用户相关接口 │ ├── package.js # 体检套餐接口 │ ├── appointment.js # 预约记录接口 │ └── admin.js # 管理统计接口 ├── controllers/ # 控制层业务逻辑处理 ├── middleware/ │ └── auth.js # JWT鉴权中间件 └── utils/ └── response.js # 统一响应格式封装前端用 Vue CLI 搭建页面分三个角色入口client/ ├── src/ │ ├── views/ │ │ ├── user/ # 用户端选择套餐、提交预约、查看报告 │ │ ├── staff/ # 前台端预约登记、到检核销 │ │ └── admin/ # 管理端套餐管理、数据统计 │ ├── components/ # 通用组件 │ ├── router/ # 路由配置含守卫 │ ├── store/ # Vuex状态管理 │ └── utils/ │ └── request.js # axios请求封装这个结构的好处是每个模块的代码位置非常明确新成员接手时按路由找文件就行不需要翻一整圈才知道某段业务逻辑写在哪。2. 核心细节解析与实操要点2.1 数据库表设计与关系梳理数据库是整个系统的地基表设计直接决定了后续业务逻辑的复杂度。我设计了六张核心表尽量保持精简又在功能上够用第一张是用户表user字段包括用户ID、用户名、密码bcrypt加密存储、手机号、身份证号、角色区分普通用户/前台/管理员、创建时间。要注意手机号和身份证号需要加唯一索引因为实际业务中一个身份证号只能注册一个账号。第二张是体检套餐表package字段包括套餐ID、套餐名称、适用性别、套餐价格、项目内容描述、是否上架。这里有个关键点同一套餐可能同时适用于男性和女性但价格不一样。我一开始只设计了一个价格字段后来发现不对改成了加一个适用性别字段同一套餐做成两条记录一个男性版本一个女性版本价差直接体现在记录上逻辑更简单。后来和体检中心业务人员聊他们说可以接受这样处理因为本来男女体检项目就不同。第三张是每日号源表daily_slot这是容易被忽略但极其重要的一张表。字段包括ID、套餐ID、可预约日期、总号源数、已预约数。为什么要单独建这张表因为每个套餐每天能接待的人数有限如果没有这张表控制用户随便预约到检当天就会爆满。用户提交预约时后端会先查这张表对比已预约数和总号源数满了就拒绝这个流程叫“库存扣减”。第四张是预约记录表appointment字段包括预约ID、用户ID、套餐ID、预约日期、时间段、状态待确认/已确认/已完成/已取消、订单金额、创建时间、核销时间。这张表是整个业务的核心流水表所有查询统计都围绕它展开。第五张是体检报告表report关联预约ID、报告标题、报告文件路径、上传时间、是否已读。第六张是操作日志表operation_log记录关键操作行为比如谁在什么时间修改了套餐价格方便回溯。表之间的关联关系用户表一对多预约记录表预约记录表多对一套餐表套餐表一对多每日号源表预约记录表一对一报告表。这种关系在 MySQL 里用外键或逻辑关联都能实现我推荐用逻辑关联在查询时用 JOIN 而非物理外键物理外键在删除数据时会带来一堆约束限制管理后台做数据修正时非常痛苦。2.2 号源扣减的并发问题处理体检预约有一个必须面对的技术问题同一个人气套餐上午10点放出100个号多个用户同时点击预约最后一天只剩最后一个名额时并发请求打过来会不会超卖我先用了最笨的方法先查询当前已预约数小于总号源数就执行 INSERT 预约记录并 UPDATE 已预约数加1。结果测试时用并发工具一压确实出现了超卖两个请求同时读到“98/100”的状态都成功插入预约记录明明还剩2个号结果约出去了3个人。后来改成事务加行锁的方案在扣减号源时用SELECT ... FOR UPDATE先把号源记录锁住再判断剩余数量然后更新最后提交事务。代码写起来是这样const connection await pool.getConnection(); try { await connection.beginTransaction(); // 锁定该套餐该日期的号源行 const [rows] await connection.query( SELECT total_count, booked_count FROM daily_slot WHERE package_id ? AND slot_date ? FOR UPDATE, [packageId, date] ); if (!rows.length || rows[0].booked_count rows[0].total_count) { await connection.rollback(); return { success: false, message: 当日号源已满 }; } // 扣减号源 await connection.query( UPDATE daily_slot SET booked_count booked_count 1 WHERE id ?, [rows[0].id] ); // 插入预约记录 await connection.query( INSERT INTO appointment (user_id, package_id, appointment_date, status) VALUES (?, ?, ?, ?), [userId, packageId, date, confirmed] ); await connection.commit(); return { success: true }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }这个方案在中小流量下完全够用。MySQL 的行级锁在这个场景下是阻塞式处理同一时间只有一个事务能修改同一行数据进行判断后面的请求排队等锁释放锁释放后重新读取到的是更新后的数据就不会超卖了。2.3 后端接口设计与 JWT 鉴权后端接口我全部按 RESTful 风格设计列表接口统一支持分页参数返回格式统一为{ code: 200, message: success, data: {} }核心接口清单如下用户端包括注册登录、获取套餐列表、获取某日期套餐号源余量、提交预约、查看我的预约、取消预约、查看报告。前台端包括按日期和状态查询预约记录、到检核销、代客预约。管理端包括套餐的增删改查、每日号源配置、预约统计报表、收入统计。鉴权方案用的是 JWTJSON Web Token。用户登录成功后后端用密钥生成一个 token包含用户ID、角色、过期时间前端把 token 存在 localStorage 里每次请求在 header 里带上Authorization: Bearer token。后端写了一个 auth 中间件来校验const jwt require(jsonwebtoken); module.exports function (req, res, next) { const token req.headers.authorization?.split( )[1]; if (!token) return res.status(401).json({ code: 401, message: 未登录 }); try { const decoded jwt.verify(token, process.env.JWT_SECRET); req.userId decoded.userId; req.role decoded.role; next(); } catch (err) { return res.status(401).json({ code: 401, message: 登录已过期 }); } };这里有个注意点不同角色能访问的接口需要分开控制。我写了两个中间件一个 requireAuth 只校验登录状态另一个 requireAdmin 在 requireAuth 的基础上再校验角色是否为管理员。套餐管理接口、统计接口这些敏感操作全部挂上 requireAdmin防止普通用户直接调用接口篡改数据。2.4 前端页面结构与 Vue 核心实现前端我用 Vue Router 做了三个路由模块的划分根路径/是首页展示体检套餐列表/login和/register是登录注册页/user下面挂用户中心相关页面/staff下挂前台操作页面/admin下挂管理页面。路由守卫是很关键的一环。没有登录的用户不管敲什么路由都会被 redirect 到登录页登录了但没有对应角色权限的会被 redirect 到首页。代码实现router.beforeEach((to, from, next) { const token localStorage.getItem(token); const role localStorage.getItem(role); if (to.meta.requiresAuth !token) { next(/login); } else if (to.meta.requiresAdmin role ! admin) { next(/); } else { next(); } });axios 请求封装里我在拦截器中统一处理了 token 注入和 401 跳转。每次请求自动带 token遇到 token 过期就清掉本地存储并跳转登录页这个统一处理能省掉每个页面单独判断的大量重复代码。前端页面中预约操作是最核心的交互流程。用户进入套餐详情页选择日期后前端会调用后端接口获取该日期的剩余号源用 ElementUI 的el-date-picker禁用掉已经约满的日期再从剩余时间段里选择。这一步交互如果做得好能显著减少用户提交无效预约的几率。我一开始没做禁用逻辑用户选了一个满号日期提交时才报错体验很差后来改成日期选择器动态禁用就顺滑多了。ElementUI 组件库在这个项目里用得非常频繁表格用el-table表单用el-form加校验规则弹窗用el-dialog消息提示用el-message分页用el-pagination。特别是表格加分页的组合在管理后台几乎是每页都用。当时 ElementUI 还是 Vue 2 生态下最成熟的组件库文档全面社区案例多遇到问题搜一下基本都有答案。3. 实操过程与核心环节实现3.1 开发环境搭建含 Node.js 安装注意事项环境搭建这一步看着基础但我在给团队搭环境时发现最容易卡住的就是这里。Node.js 的安装直接去官网下载 LTS 版本不要装 Latest 版本LTS 是长期支持版稳定性有保障。安装时一路 Next但要注意安装路径我建议装在默认路径别改因为后续 npm 全局包的相关配置默认会读取当前 Node 的安装目录改了路径容易出幺蛾子。安装完成后在命令行验证node -v npm -v这一步会暴露一个 Windows 用户极其常见的问题执行npm -v时报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这个问题是 PowerShell 的执行策略限制导致的不是 npm 本身坏了。解决办法是用管理员身份打开 PowerShell执行Set-ExecutionPolicy -Scope CurrentUser RemoteSigned执行之后选 Y 确认新开一个命令行窗口npm 命令就能用了。这个问题的本质是 Windows 默认禁止执行未签名的本地脚本而 npm 的 PowerShell 脚本没有数字签名所以被拦下来了。修改为 RemoteSigned 策略意思是本地脚本可以直接运行从网上下载的脚本必须带签名既解决了问题又不会把安全防护完全关掉。3.2 MySQL 安装与数据库初始化MySQL 安装我推荐用 MySQL Installer 装选择 Server only 就行。安装时有一个关键步骤是设置 root 密码同时会问是否创建用户。装完 MySQL 后我用命令行初始化数据库。先登录mysql -u root -p然后创建数据库和表。我推荐把建表语句写成 SQL 文件用 source 命令一次导入不要一条条手工执行。一个小经验表名和字段名统一用下划线命名法避免用驼峰因为 MySQL 在 Windows 下对表名大小写不敏感在 Linux 下却敏感统一用小写加下划线可以规避跨平台问题。后端连接 MySQL 用的是 mysql2 驱动它支持 Promise 语法配合 async/await 写起来比老版的 mysql 驱动舒服很多。连接池配置我设为const mysql require(mysql2/promise); const pool mysql.createPool({ host: localhost, user: root, password: your_password, database: medical_examination, waitForConnections: true, connectionLimit: 10, queueLimit: 0 });connectionLimit 设为 10 就够了体检预约系统不是高并发应用连接池太大反而浪费数据库资源。还有一点Node.js 连接 MySQL 8 及以上版本时如果报错ER_NOT_SUPPORTED_AUTH_MODE是因为 MySQL 8 默认的认证插件是 caching_sha2_password而 mysql2 驱动要用 mysql_native_password 认证。解决办法是在 MySQL 里执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY your_password;这个问题在新版 mysql2 里已经基本解决了但如果你用的是老版本驱动或者 Navicat 连不上顺着这个方向排查就对了。3.3 Express 后端接口实现详解Express 项目的入口我做了这样的设计。app.js 里先注册中间件再挂路由最后监听端口。const express require(express); const cors require(cors); const bodyParser require(body-parser); const app express(); app.use(cors()); // 开发环境用生产环境需要配置具体域名 app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); // 路由挂载 app.use(/api/user, require(./routes/user)); app.use(/api/package, require(./routes/package)); app.use(/api/appointment, require(./routes/appointment)); app.use(/api/admin, require(./routes/admin)); app.listen(3000, () { console.log(Server running on http://localhost:3000); });cors() 中间件在开发阶段非常有帮助。前后端分离的项目天然有跨域问题前端在 8080后端在 3000浏览器默认会拦截跨域请求。生产环境正确做法是在后端配置 CORS 白名单只允许特定域名访问开发阶段直接全局放开比较省事但我看到很多项目把这个习惯带到了生产环境所有域名都能访问后端接口这是安全隐患后面会细说。预约接口的实现是业务重点我刚在前面讲了号源扣减的方案。除了核心的预约创建还有两个容易被忽视的接口取消预约和到检核销。取消预约必须同时把 daily_slot 的 booked_count 减回去否则约了不来的人会占用号源。到检核销是前台扫一下预约单号把 appointment 状态改成已完成并记录核销时间。套餐列表接口需要支持分页和筛选。用户端看到的套餐列表和管理端的略有不同用户端只显示上架状态为1的套餐管理端能看到所有套餐包括下架的。这个区别如果做得不干净就会出现用户端通过改 URL 参数看到下架套餐的漏洞。我处理的方式是接口层面就做区分user 路由和 admin 路由分别查套餐表admin 接口校验角色后才允许访问。3.4 Vue 前端页面与 ElementUI 组件实践前端我用 Vue CLI 4 创建项目选择 Vue Router 和 Vuex 预设。安装 ElementUInpm install element-ui --save然后全局注册。项目规模不大全量引入 ElementUI 就行不用折腾按需加载的 babel-plugin-component减少配置复杂度。全量引入之后package.json 的体积会大一些但对于内部管理系统首屏加载速度不是核心指标稳定和开发效率才是。用户端的首页是套餐展示列表用el-row和el-col做栅格布局每个套餐一张卡片用el-card展示。套餐详情页里放了套餐名称、价格、适用性别、项目内容核心交互是日期选择和提交预约。日期选择器我配置了disabled-date函数通过后端返回的数据判断哪些日期不可选。前台端的预约管理页面我用了一个带筛选条件的el-table。表格顶部的筛选区放了日期选择器、状态选择器、搜索按钮、重置按钮。已选中的预约记录支持单条或批量到检核销大家实际用下来反馈很好批量核销功能帮他们省了大量时间。管理端的统计页面我用了 ECharts 来做图表展示。每日预约人数用折线图套餐热度排序用横向柱状图这周营收和上周做对比用数字卡片。ECharts 本身不依赖 Vue在组件里 mounted 时初始化实例数据用接口返回的统计结果填充。图表类数据如果渲染不出来十有八九是容器宽度为 0 或者高度没设置记得给图表容器设置明确的宽高。3.5 前后端联调与代理配置开发阶段的前后端联调我推荐用 Vue CLI 的 devServer 代理而不是后端开启 CORS。在vue.config.js里配置module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } };这样前端请求axios.get(/api/package/list)时开发服务器会把请求转发到后端的 3000 端口浏览器层面前后端同源不存在跨域。这个方案比在后端开启 CORS 更接近线上生产环境线上前后端通常也用反向代理同域而且不需要在后端代码里配置允许的域名白名单少一层安全隐患。联调时一个常见的低级错误是接口路径对不上。前端敲的是/api/package/list后端路由写的是/api/package/list看起来一样但实际跑起来报 404。排查方法是先在浏览器直接访问后端地址http://localhost:3000/api/package/list如果能通说明后端没问题问题出在代理配置或者前端地址写错。我再补充一个技巧在后端入口处加一个请求日志中间件每次请求都打印 method 和 url这样前端调了什么接口一目了然排查问题效率翻倍。4. 常见问题与排查技巧实录4.1 开发环境问题速查表我自己在这套系统开发中以及帮同事处理问题过程中整理了一份避坑清单按出现频率排序问题表现根本原因解决方案npm 命令提示禁止运行脚本Windows PowerShell 执行策略限制管理员身份执行Set-ExecutionPolicy -Scope CurrentUser RemoteSignedNode.js 安装报错 2203安装程序权限不足右键安装包以管理员身份运行并关闭杀毒软件npm install卡在 idepth 或 ETIMEDOUT网络问题导致依赖下载超时配置淘宝镜像npm config set registry https://registry.npmmirror.com后端启动报EADDRINUSE: address already in use :::3000端口被占用找到占用的进程杀掉或换一个端口前端请求接口 404代理配置错误或接口路径不一致先直接访问后端地址确认再检查 vue.config.js 的 proxy前端请求接口 CORS error后端未配置跨域开发阶段可以用 proxy 代理解决不用动后端MySQL 连接报ER_NOT_SUPPORTED_AUTH_MODEMySQL 8 认证插件不兼容修改用户认证插件为 mysql_native_password打包后页面白屏或路由 404history 路由模式需要服务器配置回退路由改用 hash 模式或配置 nginx try_files这些问题的共同点是什么大部分是环境层面的问题不是业务代码的问题。我发现很多新人遇到环境报错就慌其实环境问题比业务问题简单得多核心思路就是拆分问题域——是 Node 的问题、MySQL 的问题、还是网络的问题定位到具体层面后解决方案基本都是标准化的。4.2 打包部署到服务器实战系统开发完成后要部署上线我用的方案是在服务器上用 Nginx 做静态文件服务加反向代理。前端代码先构建npm run build构建产出的 dist 目录传到服务器上Nginx 配置里把根路径指向 dist 目录。后端代码用 pm2 进程管理器来守护进程保证它一直在跑崩了自动重启。// ecosystem.config.js module.exports { apps: [{ name: medical-api, script: ./app.js, instances: 1, autorestart: true, watch: false, env: { NODE_ENV: production, PORT: 3000 } }] };启动pm2 start ecosystem.config.js pm2 save pm2 startupNginx 配置一个关键点前端用了 history 路由模式的话刷新某个子路由页面会 404因为 Nginx 找不到对应的物理文件。需要在配置里加location / { root /var/www/medical; index index.html; try_files $uri $uri/ /index.html; }最后一行try_files就是解决刷新路由 404 的关键找不到文件时统一回退到 index.html让前端路由接手处理。反代接口配置location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }部署时的另一个经验MySQL 数据库在生产环境不要用 root 账户连接后端服务应该单独创建一个业务账号只授予这个数据库的必要权限。这样即使后端代码泄露攻击者拿到的也只是一个受限于单库的账号不会对整台数据库服务器构成直接威胁。4.3 业务逻辑层面的常见问题处理预约号源时有一个容易忽略的细节用户取消了预约号源要马上释放但如果用户是在体检前一天晚上取消释放出来的号源已来不及被其他人约上这就造成了号源浪费。后来我加了一个“临近日期不可取消需联系前台处理”的限制前台人工判断是否释放号源。这个改动很小却解决了号源虚耗的问题。还有一个数据统计口径的问题。统计销量时是按“用户提交预约”的时间来统计还是按“实际到检”的时间来统计这两个口径的数据可能差不少。我们最后给管理员提供了导出的功能同时在页面上明确标注了统计口径避免业务人员拿两个图表数据对比时产生疑虑。接口安全方面除了 JWT 鉴权我在套餐查询和管理操作上还做了基本的入参校验。比如创建套餐时价格不能为负数名称不能为空这些校验用 express-validator 或手写判断都可以。我看到有朋友的项目因为没做入参校验前端传了一个超大数字当价格数据库直接返回报错整个接口就 500 了。入参校验看起来不起眼但对系统健壮性至关重要。4.4 项目上线后的性能与维护心得系统上线稳定运行后我做了一次性能复盘。这套体检预约系统的并发量其实不算高工作日高峰时段同时在线大概几十人但数据库的查询频率不低尤其是首页套餐列表和号源查询。针对热点查询我给两处加了索引预约记录表的 user_id 和 appointment_date 建了联合索引因为用户查“我的预约”总是按日期排序每日号源表的 package_id 和 slot_date 也建了联合索引这是预约流程中每次都会查的表。增加索引后查询响应时间从原来的上百毫秒降到了几十毫秒以内。然后是慢查询日志。MySQL 默认开启慢查询日志设置阈值为 1 秒上线后运行一周我把超过阈值的长 SQL 拿出来分析发现有几条统计类的 SQL 在数据量大了之后变慢通过改写 SQL 或者增加冗余字段解决。维护阶段多关注慢查询日志能提前发现很多问题不要等用户反馈页面卡了才去排查。最后一点体会这类管理系统的开发业务理解比技术实现更重要。技术选型大差不差真正的差距在于是否理解了体检中心的实际流程——号源怎么分配、预约状态怎么流转、哪些数据需要统计、角色权限怎么划分。把这些业务逻辑吃透技术上只是一个表达能力的问题。先用 Node.js 搭好后端、Vue 写好页面、MySQL 建好表然后照着业务需求把模块一个一个填进去系统就顺理成章地跑起来了。踩过几次坑之后回头看这类系统开发真正花时间的不是写代码而是想清楚数据怎么流转、边界条件怎么处理。我的建议是先画好表结构再开写代码后面返工会少很多。