☰
Node.js + Vue 家庭医生管理系统实践:从权限设计到部署避坑
2026/10/13 11:00:09 网站建设 项目流程

一个家庭医生管理系统,看起来无非就是“增删改查”几个模块堆起来,但真正落地的时候,业务边界、角色权限、前后端数据流转这些环节,每一个都能让你折腾到半夜。这篇文章就围绕我实际开发一套 Node.js + Vue 家庭医生管理系统的过程,聊聊模块规划、后端接口设计、前端状态管理,以及那些文档里不会写清楚的部署和环境坑。

这套系统的核心价值不在于技术多新,而在于把所有家庭医生服务场景,比如签约、建档、随访、慢病跟踪、在线沟通,串成一条完整的业务链路。文中的代码片段都是可直接用的,架构思路也适用于其他医疗或预约类管理系统。

1. 家庭医生管理系统的业务边界与模块规划

很多人拿到这类项目第一反应是直接开写,结果做到一半发现需求边界完全失控。家庭医生管理系统跟普通挂号系统最大的区别在于:它是一个“长期关系”的管理系统,核心不是单次预约,而是持续的健康跟踪。

1.1 角色模型设计

从实际使用场景出发,这个系统至少要拆出三类角色:

  • 超级管理员:管理医生账号、审核签约申请、查看全站数据看板。
  • 家庭医生:处理签约申请、录入健康档案、制定随访计划、开处方。
  • 患者/居民:提交签约申请、查看自己的档案和随访建议、与医生进行在线沟通。

有些系统会再加一个“护士/助理”角色,但初期版本不建议加,角色越多权限逻辑越复杂,第一版应该做减法。权限控制我放在后端的中间件里统一做,前端只负责隐藏入口,真正的安全校验必须落在服务端。

1.2 核心模块拆分

按业务优先级,我整理出六个核心模块:

模块核心功能关键数据表
签约管理签约申请、家庭医生审批、解约contract, contract_log
健康档案基本信息、既往病史、过敏史、体检记录patient_profile, medical_record
随访计划周期生成、任务提醒、完成反馈follow_up_plan, follow_up_task
慢病管理血压/血糖记录、趋势图表、异常预警chronic_sign, chronic_alert
在线咨询图文聊天、常见问题回复message, consultation
系统管理医生排班、公告、数据统计schedule, announcement, stats

这里有一个非常容易忽略的细节:签约状态和随访计划状态必须联动。比如患者解约之后,所有待执行的随访计划应该自动失效,而不是继续给医生发提醒。这个逻辑不做好,后面上线会被投诉死。

2. Node.js 后端:接口设计、JWT 认证与数据模型的落地细节

后端我用的是 Node.js + Express + MySQL。选 Express 而不是 Koa,核心原因是我需要它的中间件生态足够成熟——文件上传(multer)、跨域(cors)、JWT 校验(jsonwebtoken)都是现成的,接入成本极低。

2.1 目录结构与接口分层

项目的后端目录结构如下,这是我个人偏好的按模块划分方式:

server/ ├── app.js ├── config/ │ └── db.js ├── middleware/ │ ├── auth.js │ └── errorHandler.js ├── modules/ │ ├── auth/ │ │ ├── auth.controller.js │ │ ├── auth.service.js │ │ └── auth.route.js │ ├── contract/ │ ├── record/ │ └── followup/ ├── utils/ │ └── response.js └── sql/ └── init.sql

接口统一走 RESTful 规范,返回格式固定为:

{ code: 0, message: 'success', data: { ... } }

在utils/response.js里做统一封装:

exports.success = (res, data, message = 'success') => { res.json({ code: 0, message, data }); }; exports.fail = (res, message = 'error', code = 1) => { res.json({ code, message, data: null }); };

这样做的好处是前端不需要在 axios 响应里判断不同的结构,只要看res.code === 0就能确定接口是否正常,异常统一走 message 展示。

2.2 JWT 身份认证的完整流程

身份认证是整个系统的安全基石。我的实现思路是:登录成功之后发放 access_token(有效期 2 小时),同时把用户角色塞进 token 的 payload,后续请求通过中间件验证。

const jwt = require('jsonwebtoken'); const config = require('../config/db'); exports.generateToken = (user) => { return jwt.sign( { id: user.id, role: user.role }, config.secret, { expiresIn: '2h' } ); }; exports.verifyToken = (req, res, next) => { const header = req.headers.authorization; if (!header || !header.startsWith('Bearer ')) { return res.status(401).json({ code: 401, message: '未登录或登录已过期' }); } const token = header.split(' ')[1]; try { const decoded = jwt.verify(token, config.secret); req.user = decoded; next(); } catch (err) { return res.status(401).json({ code: 401, message: 'token 无效或已过期' }); } };

角色鉴权单独抽一个中间件,支持传多个角色:

exports.authorize = (...roles) => { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ code: 403, message: '无权限访问' }); } next(); }; };

在实际业务中我发现,很多开发者只在前端做路由守卫,后端所有接口裸奔,这是非常危险的。后端的每个受保护接口必须显式装饰authorize中间件,不能靠自觉。

2.3 数据模型的几个关键设计

数据库表的设计是系统能否支撑业务演进的关键,我挑三个值得展开的地方:

**签约表(contract)**状态机要清晰。我用status字段区分待审批、已签约、已解约三种状态,并用contract_log表记录每次状态变更的操作用户、原因和时间。健康档案中敏感字段很多,所有涉及患者姓名的查询接口默认开启脱敏,前端列表展示时只显示姓名加星号,详情页才展示全名。

随访任务生成逻辑很值得注意。我在follow_up_plan表存了计划周期时间,用定时任务每天扫表,把需要执行的随访任务批量插入follow_up_task表。MySQL 这边一条 SQL 搞定:

INSERT INTO follow_up_task (plan_id, patient_id, doctor_id, task_date, status) SELECT p.id, p.patient_id, p.doctor_id, CURDATE(), 0 FROM follow_up_plan p WHERE p.status = 1 AND p.next_task_date <= CURDATE() AND NOT EXISTS ( SELECT 1 FROM follow_up_task t WHERE t.plan_id = p.id AND t.task_date = CURDATE() );

慢病数据表的设计直接决定前端图表好不好画。血压、血糖这类数据我建议单独建表,而不是塞进 JSON 字段,因为后续统计趋势图需要按时间做范围查询:

CREATE TABLE chronic_sign ( id INT PRIMARY KEY AUTO_INCREMENT, patient_id INT NOT NULL, type ENUM('blood_pressure', 'blood_sugar', 'heart_rate') NOT NULL, value VARCHAR(50) NOT NULL, measure_time DATETIME NOT NULL, note VARCHAR(255), INDEX idx_patient_time (patient_id, measure_time) );

value 字段之所以设计成 VARCHAR,是因为血糖有5.6这种小数、血压有120/80这种组合值,统一字符串存储最省事,做数值比较的时候再用CAST转换。

3. Vue 前端:登录态管理、动态路由与医患交互的实现逻辑

前端用的是 Vue 3 + Vue Router 4 + Pinia + Element Plus。之所以选 Vue 3 是因为组合式 API 在维护复杂业务逻辑时确实比选项式 API 清爽,而且 Element Plus 对 Vue 3 支持已经很成熟,表格、表单、对话框这些后台管理的高频组件全都有。

3.1 登录态与请求拦截

前端登录成功之后拿到 token,存到 localStorage 是常规操作,但要注意不能只存 token,用户基本信息建议也缓存一份,避免每次刷新页面都要重新拉取用户信息。

axios 封装是重点,我把核心逻辑放在拦截器里:

import axios from 'axios'; import { useUserStore } from '../stores/user'; import router from '../router'; const http = axios.create({ baseURL: '/api', timeout: 10000 }); http.interceptors.request.use(config => { const userStore = useUserStore(); if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}`; } return config; }); http.interceptors.response.use( response => { const res = response.data; if (res.code !== 0) { if (res.code === 401) { userStore.logout(); router.push('/login'); } return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response && error.response.status === 401) { userStore.logout(); router.push('/login'); } return Promise.reject(error); } );

这里最容易踩的坑是:token 过期之后,前端收到的响应码可能是 200 而不是 401,因为很多后端开发者习惯把所有业务逻辑的异常都包在 200 里。我在开发这套系统时专门做了约定:认证失败必须返回 HTTP 401,业务异常返回 HTTP 200 + code 1,这样前端拦截器才能准确区分。

3.2 动态路由与权限控制

不同角色进入系统后看到的菜单完全不同,我用的是动态路由方案。登录成功拿到用户角色后,前端根据角色动态注册路由表:

import { createRouter, createWebHistory } from 'vue-router'; const routes = [ { path: '/login', component: () => import('../views/Login.vue') }, { path: '/', component: () => import('../layout/Index.vue'), redirect: '/dashboard', children: [] } ]; const doctorRoutes = [ { path: '/patient', component: () => import('../views/doctor/PatientList.vue'), meta: { title: '患者管理' } }, { path: '/followup', component: () => import('../views/doctor/FollowUp.vue'), meta: { title: '随访任务' } } ]; const adminRoutes = [ { path: '/doctor-manage', component: () => import('../views/admin/DoctorManage.vue'), meta: { title: '医生管理' } } ];

路由守卫里做全局判断:

router.beforeEach((to, from, next) => { const userStore = useUserStore(); if (to.path === '/login') return next(); if (!userStore.token) return next('/login'); next(); });

需要注意,动态路由采用router.addRoute方式挂载,不要在定义路由表时就全部注册,而是在用户信息拉取完之后再按角色挂载,否则权限控制形同虚设。我见过很多项目把全部路由都写上,只是靠菜单隐藏来“伪装”权限,这种做法前端调试时没问题,但一旦有人手动输入 URL 就能直接越权访问。

3.3 医患在线沟通的交互实现

在线咨询模块是整个系统里交互体验最复杂的部分。我采用了轮询方案而不是 WebSocket,原因是轮询的部署成本低、兼容性好,而且家庭医生场景下的消息频率并不高,1~2 秒轮询一次完全够用。

前端聊天逻辑放在组合式 API 中:

import { ref, onMounted, onUnmounted } from 'vue'; import { getMessageList, sendMessage } from '@/api/consult'; export function useChat(consultationId) { const messages = ref([]); const inputText = ref(''); let timer = null; const fetchMessages = async () => { const list = await getMessageList(consultationId); messages.value = list; }; const handleSend = async () => { if (!inputText.value.trim()) return; await sendMessage(consultationId, inputText.value.trim()); inputText.value = ''; await fetchMessages(); }; onMounted(() => { fetchMessages(); timer = setInterval(fetchMessages, 2000); }); onUnmounted(() => { if (timer) clearInterval(timer); }); return { messages, inputText, handleSend }; }

这个方案在并发量小的业务场景非常稳定,也不需要考虑断线重连和心跳包的问题,是实用主义的最优解。

4. 前后端联调中的会话保持与接口字段对齐实战

前后端联调是整个项目中最磨人的阶段,绝大多数隐形 bug 都出现在这一环节。跨域问题、字段命名不统一、日期格式差异、空值判断缺失,每一样都能让前后端工程师互相“甩锅”半天。

4.1 跨域与代理配置

开发环境下我用的 Vite 代理,生产环境用 Nginx 转发,这两个配置必须始终保持一致。Vite 配置在vite.config.js:

export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } });

而生产环境 Nginx 配置的核心就一段转发:

location /api/ { proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }

在联调阶段我发现一个高频翻车点:代理配置里changeOrigin和proxy_pass结尾的斜杠。/api/转发到http://127.0.0.1:3000/时,请求路径/api/contract/list会变成/contract/list,也就是后端路由不包含/api前缀。如果后端接口定义时包含了/api,那转发路径也要带上/api,否则 404。

4.2 日期时间格式统一

前后端联调时日期格式是个最容易碰出火花的细节。MySQL 的DATETIME存的是2024-03-15 14:30:00,但前端图表组件有时候需要时间戳,有时候需要YYYY-MM-DD。我统一的方案是:

  • 后端返回:所有日期时间字段统一返回YYYY-MM-DD HH:mm:ss字符串,不做时区转换。
  • 前端展示:直接用字符串展示,需要比较时new Date(str).getTime()转时间戳。
  • 前端传参:提交表单时一律YYYY-MM-DD格式的字符串。

坚持“后端不处理时区,前端不处理格式化”的原则,整条链路会清爽很多。千万别让后端返回时间戳,否则前端每次渲染都要dayjs(timestamp).format('YYYY-MM-DD'),看着就累。

4.3 字段命名对齐的工程化手段

后端的命名习惯是user_name,前端的习惯是userName,如果不做统一规范,联调时前端要把一半时间花在字段映射上。我给出的解决方案很简单,却非常有效:

  • 后端全部返回camelCase字段,不在数据库层面做映射,而是在 SQL 查询里使用别名处理。
  • 后端约定所有列表接口返回{ list, total }结构,前端分页组件直接对接。
  • 所有 POST 请求体字段名和 GET 请求查询参数名保持完全一致。

字段对齐还有一种做法是开放GET /schema/xxx接口返回字段描述,但这套流程做起来太重。小团队只要约定好命名规范,效率远高于引入各种代码生成工具。

5. Node.js 环境安装与 npm 报错:开发机上的真实排查经历

开发这套系统时,我也在环境配置上吃了不少苦头,尤其是npm.ps1报错问题。这个报错在外网搜索量极高,几乎每个 Windows 开发者都见过。它长这样:

npm.ps1 无法加载文件 D:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本

5.1 为什么会出现这个报错

这个报错的本质是 Windows PowerShell 的执行策略默认是Restricted(禁止运行任何脚本文件),而 npm 的命令行入口是一个.ps1脚本,所以 PowerShell 直接拒绝运行。这个跟 Node.js 本身是否安装成功没有任何关系,很多开发者误以为是 Node.js 安装失败。

5.2 解决办法

最简单的方式是改 PowerShell 的执行策略,以管理员身份打开 PowerShell 执行:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned

RemoteSigned的意思是本地脚本可以直接运行,从互联网下载的脚本必须有数字签名才能运行。执行之后输入Y确认即可。

如果不想改全局策略,也可以临时用一个命令绕过:

powershell -ExecutionPolicy Bypass -Command "npm install"

或者直接用npm.cmd替代npm,因为.cmd不是.ps1,不受 PowerShell 执行策略限制:

npm.cmd install

我的建议是直接用Set-ExecutionPolicy一劳永逸,因为后续还会遇到很多 npm 脚本的执行问题。

5.3 Node.js 版本管理:一个必须提前解决的问题

开发过程中需要切换 Node.js 版本是常有的事。某些旧版本的项目需要 Node 14,而这个家庭医生系统用的是 Vue 3 + Vite,至少要 Node 16 以上。我在开发机上装了nvm-windows,它是 nvm 的 Windows 移植版,安装完之后可以用一条命令切换全局 Node 版本:

nvm install 18.18.0 nvm use 18.18.0

这里有一个很关键的坑:nvm 安装的 Node.js 跟官方安装包的目录结构不一样,npm 全局安装的包需要重新装一遍。切换版本后如果执行npm -v失败,先检查环境变量里的NODE_PATH是否指向了正确的版本目录。所以说,团队协作时建议统一 Node 版本记录在.nvmrc文件里,避免“我这边跑得好好的”这类经典问题反复出现。

6. 生产部署与运维的实操总结

开发完成之后,部署是一个完全独立的战场。前端打包、后端进程管理、数据库备份、日志处理,任何一环出问题都能让你在深夜被电话叫醒。

6.1 前端构建与部署

Vue 项目打包要特别注意环境变量。我开发时维护了三个环境文件:

.env.development # VITE_API_URL=/api .env.production # VITE_API_URL=https://api.yourdomain.com/api

打包命令很简单:

npm run build

产物在dist/目录,直接上传到服务器的 Nginx 站点目录,然后重启 Nginx 即可。

6.2 后端进程守护的选型与配置

Node.js 服务端进程用 PM2 守护是最省心的选择。我之前也试过直接用nohup node app.js &来跑,结果一旦进程因为未捕获异常退出,整个人会被搞到崩溃。PM2 自带崩溃重启,这个能力在凌晨三点是救命稻草。

在项目根目录下载依赖后:

npm install pm2 start app.js --name family-doctor --max-memory-restart 512M

开机自启配置(Windows/Linux 都要做):

pm2 startup pm2 save

--max-memory-restart 512M这个参数值得单独说一下,Node 进程的内存占用要控制在合理范围,超过了会自动重启,能有效避免内存泄漏导致的雪崩。

6.3 日志与数据库备份

日志是排查线上问题的唯一线索。PM2 的日志默认写到~/.pm2/logs/,建议配合按天切割,避免单日志文件无限膨胀。

数据库备份我用的是 cron 脚本,每天凌晨自动备份:

0 2 * * * mysqldump -uroot -p*** family_doctor > /backup/doctor_$(date +\%Y\%m\%d).sql --single-transaction

保留最近 7 天的备份,其余清理:

0 3 * * * find /backup -name "doctor_*.sql" -type f -mtime +7 -exec rm {} \;

这个系统虽然业务不算深,但“家庭医生”场景下的数据敏感度非常高,备份策略不能马虎。

7. 实测踩坑记录:那些不起眼但致命的小问题

整个项目做下来,我遇到的很多问题都不是那种“查一下文档就能解决”的,而是藏在细节里,不跑到真实场景根本发现不了。

7.1 时间字段的初版设计缺陷

第一版做随访计划日期时,我直接用了DATE类型存next_task_date。后来发现一个问题:计划生成的时候需要精确到小时,但DATE类型存不下。这导致当月第一天生成的计划,在当天晚上就插入了第二天的任务,整个提醒逻辑全部错乱。

改成DATETIME之后还要注意数据库的时区:

SET time_zone = '+08:00';

如果在连接字符串里设置了timezone,一定要跟服务器一致,否则定时任务会全部错位。

7.2 列表大数字段导致页面卡顿

用户列表接口直接返回所有字段,结果在健康档案很多的患者上,页面渲染明显卡顿。解决方案很简单:

  • 列表接口只返回 10 个核心字段(id、姓名、性别、年龄、签约医生、签约状态等)。
  • 详情接口再返回完整字段。
  • 大文本字段(病史、过敏史、备注)拖到详情页加载。

一次性返回所有字段在数据量小的时候没问题,但数据量上来之后,接口响应时间和内存占用都会指数级上升。

7.3 退出登录后路由守卫失效

做退出登录时,我最初只在 Pinia 里清空了 token,然后直接router.push('/login')。后来发现一个 bug:用户退出登录后,如果在地址栏手动输入一个内部页面路径,居然还能直接进入。

原因是路由守卫只判断了userStore.token,但router.push('/login')执行的时候,Pinia 的 token 清空了,路由守卫只判断 token 的时候,发现没有 token 就放行去 login 了,但用户已经在 login 页,这是正常逻辑。问题是用户直接手动输入内部路径时,如果在跳转过程中 token 还没清空,就会被误放行。

最后的解决方案是:退出登录时同时调用一个后端接口,让服务端把 token 加入黑名单,并清空localStorage:

const logout = async () => { await http.post('/auth/logout'); localStorage.removeItem('token'); userStore.$reset(); router.replace('/login'); };

路由守卫的判断逻辑也需要加强,必须在setTimeout里等 token 清空完成后再跳转,否则会出现竞态问题。这其实是一个很经典的前后端会话一致性难题。

7.4 响应体里的 null 值导致前端报错

前端在渲染健康档案时,某个患者的“过敏史”字段为NULL,结果页面渲染patient.profile.allergy_history.length直接抛错。后端的处理方案是:所有可为空的字符串字段默认返回空字符串,所有可为空的数组字段默认返回[]:

exports.normalizeRecord = (row) => { return { ...row, allergy_history: row.allergy_history || '', tags: row.tags ? JSON.parse(row.tags) : [] }; };

这个规范越早定越好,等到联调阶段再来补 NULL 判断,代码会被补丁搞到惨不忍睹。

8. 从这套系统中沉淀的通用经验

家庭医生管理系统只是众多后台管理系统中的一种,但它的业务复杂性覆盖了账号体系、权限控制、长周期任务调度、实时交互、敏感数据处理等多个维度。做完这个项目,我有几个体会特别深。

第一,权限控制必须在后端落地,前端只是锦上添花。很多团队把权限写在路由守卫和菜单显隐上,后端接口完全开放,这是典型的自欺欺人。我在做这个项目时,后端每个跟患者数据相关的接口都占用了authorize中间件做角色校验,虽然代码量增加了,但安全性有质的提升。

第二,前后端协作的规范比技术选型更重要。我们统一了返回结构、错误码、日期格式、字段命名,联调阶段几乎没有“字段对不上”的问题,整个联调过程非常顺畅。这些规范应该在项目启动的第一天就定下来,而不是等到联调阶段再补。

第三,生产环境的部署方案要提前规划。开发阶段用npm run dev跑得飞起,但部署时域名、HTTPS 证书、Nginx 配置、PM2 开机自启、数据库备份、日志切割每一项都要花时间。等到项目做完了再研究部署,那几天基本是通宵加班。

第四,代码里要留够监控和日志。仪表盘统计功能不光是给客户看的,也是给自己定位问题用的。我在健康档案模块加了一个简易操作日志表,记录每次新增和修改,出问题的时候能快速排查是谁在什么时候动了什么数据。这比事后翻 MySQL binlog 舒服太多了。

第五,不要把简单问题复杂化。在线咨询模块完全可以用 WebSocket,但轮询 2 秒一次在这个业务场景下已经足够;动态路由可以用更复杂的权限模型,但按角色拼装路由表已经足够。技术选型要考虑团队维护成本和业务实际需求,而不是追求技术上的炫技。所谓“没有最好的架构,只有最合适的架构”,在这类项目中体现得淋漓尽致。

我目前还在持续优化这套系统,下一步计划加入体检报告 OCR 自动识别归档,以及基于历史慢病数据的趋势预测提醒。技术方案演进是无止境的,但每解决一个真实问题,这套系统的价值就增加一分。

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

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

立即咨询