ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Node.js与Vue的养老院管理系统毕设全栈开发实战

基于Node.js与Vue的养老院管理系统毕设全栈开发实战 简介一套面向毕业设计的养老院管理系统源码与数据库压缩包基于Node.js与Vue构建已通过导师指导可直接运行。适合计算机相关专业学生用于毕业设计、课程设计或期末大作业也适合需要项目实战练习的开发者。压缩包内共四十九个文件包括十八个JavaScript业务脚本、十五个Vue前端组件、五个JSON配置文件、TypeScript类型定义、图片图标以及说明文档等整体大小仅一点七MB目录结构清晰便于快速部署。系统后端使用Node.js前端使用Vue数据库采用MySQL功能涵盖养老院日常管理界面美观操作简便。资源附带完整项目源码、数据库SQL脚本、运行工具与项目说明可帮助读者理解前后端分离开发流程并可直接作为毕设成果提交。目前已有671人学习下载具有较高的参考价值。1. 基于nodejsvue的养老院管理系统在毕设里解决什么问题这类压缩包每年答辩季都会出现在校园网盘里。养老院管理系统成为毕业设计热门不是因为业务复杂而是住养老人、房间床位、护工排班、健康档案、费用结算这些实体关系足够清晰画得出 E-R 图也写得出标准接口正好覆盖数据库建模、后端接口、前端页面三层评审点工作量可控又能往任意方向加功能撑篇幅。标题里的 nodejs 和 vue 已经定死了技术栈Node.js 起 HTTP 服务并连接 MySQLVue 写管理后台单页应用中间用 JSON 接口对接。这个组合的优点是 npm 生态成熟组件化和路由机制也适合按页面逐块推进答辩时每一层都有东西可以讲。拿到 zip 后别急着跑先核对三个位置sql 导入后的库名和账号、后端配置文件里的监听端口、前端代理指向的后端端口。九成“跑不起来”都出在这三处没有对齐。后面的内容按数据库、接口、页面、联调排错的顺序把整套方案拆开讲。2. 数据库设计先行养老院管理系统的表结构与建模要点2.1 从业务流反推实体和关系建表之前先画一遍业务流家属咨询后提交入住申请护理部评估老人自理能力分配房间床位随后产生健康档案、每日照护记录、用药记录月底按床位费和照护等级结算费用。这条链路上能抽出老人、家属、房间、床位、护工、费用、健康记录七类核心对象它们之间的归属和引用关系决定了字段怎么放。关系上一位老人有一到多位家属联系人属于一对多老人与房间多对一护工与老人之间存在照护关系适合用照护记录表承载避免在老人表里堆多选字段。登录账号单独放 user 表与业务表只做逻辑关联不建物理外键因为 Node.js 后端在联调阶段经常要批量清理测试数据物理外键的级联约束会变成阻碍。命名上建议统一用下划线风格主键一律 id创建时间 created_time更新时间 updated_time状态类字段用 TINYINT 小整数。这样写出来的 SQL 风格一致也经得起答辩时“为什么这样设计”的追问。2.2 核心表建表 SQL 与关键字段说明老人表、房间表、费用表这三张是最核心的分别对应主体信息、资源分配和资金流水。先看老人表和费用表CREATE TABLE elder ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 主键, name VARCHAR(50) NOT NULL COMMENT 姓名, gender TINYINT NOT NULL DEFAULT 1 COMMENT 1男 2女, id_card CHAR(18) NOT NULL COMMENT 身份证号, birthday DATE NOT NULL COMMENT 出生日期, care_level TINYINT NOT NULL DEFAULT 1 COMMENT 照护等级:1自理 2半自理 3全护理, room_id INT UNSIGNED NULL COMMENT 房间id逻辑关联不建物理外键, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在住 2退住, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_id_card (id_card), KEY idx_room_id (room_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT老人信息表;id_card 加唯一索引是因为身份证是业务上唯一且经常拿来查询的字段出生日期用 DATE 类型而不是字符串以后按年龄段做统计报表时可以直接用日期函数。status 用于软删除老人退住只改状态不删行费用明细和照护记录才能继续追溯。CREATE TABLE fee_record ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, elder_id INT UNSIGNED NOT NULL COMMENT 老人id, fee_type TINYINT NOT NULL COMMENT 1床位 2护理 3餐费 4其他, amount DECIMAL(10,2) NOT NULL COMMENT 金额单位元, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未缴 1已缴, pay_time DATETIME NULL COMMENT 缴费时间, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_elder_id (elder_id), KEY idx_pay_status (pay_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT费用记录表;金额用 DECIMAL(10,2) 而不是 FLOAT 或 DOUBLE十进制精确存储可以避免浮点误差。pay_status 建立索引是因为月度欠费统计是高频查询数据量到几十万行时没有索引的 COUNT 会明显拖慢接口响应。完整的一套表通常长这样表名用途关键字段user系统登录账号username, password, roleelder老人档案name, id_card, care_level, room_id, statusfamily家属联系人elder_id, name, phoneroom房间与床位room_no, floor_no, bed_countcare_record每日照护记录elder_id, caregiver_id, content, record_datefee_record费用流水elder_id, fee_type, amount, pay_status2.3 建模阶段容易埋雷的三个细节第一个雷是状态字段直接存中文比如 status 写“在住”“退住”。查询时看着直观但统计报表都要做字符串匹配前端下拉框的值也不好映射改成 TINYINT 加注释由前端做展示层翻译更稳。第二个雷是时间字段不设默认值。created_time 如果漏了 DEFAULT CURRENT_TIMESTAMP应用层传参稍微疏漏就会写入空值排序和按时间筛选全部错乱排错成本很高。第三个雷是删除操作直接 DELETE。老人退住后费用记录和照护记录都指向这条被删的行历史账目查不到这在答辩时是必被追问的点。正确做法是 elder 表里保留 status2 的软删除状态列表默认过滤掉即可。3. 后端实现用 Node.js 搭 REST API 的选型、鉴权和分页约定3.1 框架选型Express 为什么是这类项目的默认答案Node.js 后端框架集中在 Express、Koa、NestJS 三者。Express 是 nodejs 框架里生态最老、中间件最全的一个资料量和现成 demo 远多于另外两者遇到问题搜起来成本低Koa 用 async/await 写法更优雅但中间件基本要自己拼装NestJS 工程化程度高、依赖注入很加分可学习曲线对一到两个月的工期并不友好。如果答辩组里有后端方向的老师大概率会问“为什么选 Express”。比较稳的回答是十几张表以内的业务规模Express 的路由和中间件模式足够保持结构清晰依赖少、部署解释成本低。把精力留给鉴权和数据校验比在框架上炫技性价比高。常见目录结构如下入口只做装配业务按路由和控制器拆开server/ app.js # express 实例、中间件注册、路由挂载 config/db.js # mysql2 连接池 middleware/auth.js # JWT 校验中间件 routes/auth.js # 登录、注册 routes/elder.js # 老人信息路由 controllers/elder.js# 业务处理逻辑 utils/response.js # 统一响应格式3.2 连接池配置与统一响应格式数据库连接用 mysql2 的连接池避免每次请求都新建连接。配置如下// config/db.js const mysql require(mysql2); const pool mysql.createPool({ host: process.env.DB_HOST || localhost, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER || root, password: process.env.DB_PASSWORD || 123456, database: process.env.DB_NAME || elderly_care, waitForConnections: true, connectionLimit: 10, namedPlaceholders: true }); module.exports pool.promise();connectionLimit 表示连接池上限默认 10 对这个规模的系统足够namedPlaceholders 开启后可以使用:name形式的命名占位符。全部查询走pool.query(sql, params)参数用问号占位符由驱动层做转义这是防 SQL 注入的第一道防线。后端接口建议统一返回结构方便前端拦截器判断我在项目中一般固定为{ code, data, msg }// utils/response.js exports.ok (res, data, msg ok) res.json({ code: 0, data, msg }); exports.fail (res, msg, code 1) res.json({ code, msg });code 为 0 表示业务成功非 0 是业务失败HTTP 状态码只保留 200、401、500 几类。前端 axios 拦截器统一判断 code比直接解析 res.status 要省事得多。提示连 MySQL 8 如果报 ER_NOT_SUPPORTED_AUTH_MODE是默认认证插件 caching_sha2_password 与 mysql2 不兼容在 MySQL 里对该账号执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 123456;即可解决。3.3 JWT 鉴权签发、校验与角色控制登录流程是前端 POST /api/auth/login后端比对 user 表里的账号密码密码用 bcryptjs 哈希存储不能是明文或简单 sha1。校验通过后用 jsonwebtoken 签发 tokenpayload 只放 id 和 role 两个字段过期时间设为 2 小时前端把 token 存 localStorage后续请求在请求头带Authorization: Bearer token。校验逻辑抽成一个中间件挂在需要登录的路由上// middleware/auth.js const jwt require(jsonwebtoken); module.exports function (req, res, next) { const token req.headers.authorization req.headers.authorization.replace(Bearer , ); if (!token) return res.status(401).json({ code: 401, msg: 未登录 }); try { req.user jwt.verify(token, process.env.JWT_SECRET || dev_secret); next(); } catch (e) { return res.status(401).json({ code: 401, msg: 登录已过期 }); } };中间件里只负责“你是否登录”角色判断再拆一个 isAdmin 叠在后面职责分离。路由和权限大体如下方法路径说明权限POST/api/auth/login登录拿 token公开GET/api/elder/page老人分页查询登录用户POST/api/elder新增老人管理员PUT/api/elder/:id编辑老人管理员POST/api/elder/leave/:id办理退住管理员GET/api/fee/statistics月度费用统计管理员3.4 分页、模糊查询与防注入的参数约定列表接口是前端访问最频繁的约定 pageNum 从 1 开始pageSize 默认 10、上限 100超出直接钳制。老人分页接口的写法如下router.get(/elder/page, auth, async (req, res) { const pageNum Math.max(1, Number(req.query.pageNum) || 1); const pageSize Math.min(100, Number(req.query.pageSize) || 10); const keyword (req.query.keyword || ).trim(); const offset (pageNum - 1) * pageSize; let where WHERE 11; const params []; if (keyword) { where AND (name LIKE ? OR id_card LIKE ?); params.push(%${keyword}%, %${keyword}%); } const [list] await db.query( SELECT id, name, gender, care_level, room_id, status FROM elder ${where} ORDER BY created_time DESC LIMIT ? OFFSET ?, [...params, pageSize, offset] ); const [countRows] await db.query( SELECT COUNT(*) AS total FROM elder ${where}, params ); res.json({ code: 0, data: { list, total: countRows[0].total } }); });pageNum 和 pageSize 都用 Number 显式转换避免用户传入字符串导致 SQL 拼接时报类型错。WHERE 11是动态拼接查询条件的惯用技巧后续条件都带 AND省略判断是否首条条件的分支。列表和总数分开查是因为 COUNT 不需要回表取行数据MySQL 对 COUNT(*) 有独立优化路径比一条 SQL 加 SQL_CALC_FOUND_ROWS 更稳定。OFFSET 计算是 (pageNum-1) × pageSize前端传 0 或负数时 Math.max(1) 直接兜底。4. 前端实现Vue 路由参数、axios 封装与列表页闭环4.1 版本与依赖选择Vue 2 Element UI 仍是这类源码的主流这类源码包最常见的组合是 Vue 2.7 配 Element UI较新的会换 Vue 3 配 Element Plus。如果拿到手的项目本身就是 Vue 2不建议自行升级到 Vue 3组件库 API、路由写法、响应式原理差异都不小迁移成本远大于收益。Vue 3 的优势在组合式 API 和 TypeScript 支持但对这套管理后台业务没有质变。前端依赖安装阶段很多人卡在“vue安装依赖”这一步npm install 慢或者直接失败常见解法是切换 npm registry 镜像后再装。装完看 package-lock.json 是否生成lock 文件存在说明依赖树已经完整解析之后再装就不会出现版本漂移。前端目录一般长这样web/ src/api/request.js # axios 实例与拦截器 src/api/elder.js # 老人模块接口函数 src/router/index.js # 路由表 src/views/elder/List.vue src/views/elder/Detail.vue vue.config.js # devServer 代理与打包配置4.2 vue-router 的页面骨架与参数两种传法路由配置决定整个后台的页面结构登录页、布局页、业务页分层挂载// src/router/index.js const routes [ { path: /login, component: () import(/views/Login.vue) }, { path: /, component: Layout, redirect: /dashboard, children: [ { path: elder/list, component: () import(/views/elder/List.vue) }, { path: elder/detail/:id, component: () import(/views/elder/Detail.vue) } ] } ];路由参数有 params 和 query 两种传法这是 vue面试题里反复出现的考点区别整理如下方式URL 示例取值典型场景params/elder/detail/12this.$route.params.id详情页一个对象一个页面query/elder/list?keyword张this.$route.query.keyword列表筛选条件多字段可并列params 配合路径里的 :id 使用时刷新不丢失因为它本身在 URL 里query 适合搜索条件好处是带着条件刷新页面列表状态还能还原。把搜索关键词放进 query是管理后台最实用的习惯。4.3 axios 拦截器token 注入与 401 统一处理接口请求封装是前后端联调的枢纽核心代码// src/api/request.js import axios from axios; const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) config.headers.Authorization Bearer token; return config; }); service.interceptors.response.use( res { const { code, data, msg } res.data; if (code 0) return data; if (code 401) { localStorage.clear(); window.location.href /login; } return Promise.reject(new Error(msg || 请求失败)); }, err Promise.reject(err) ); export default service;baseURL 写 /api 而不是写死 http://localhost:3000具体端口由 devServer.proxy 转发这样将来换后端端口或者上 Nginx 都不用改业务代码。拦截器里直接返回 data 字段页面调用时拿到的是后端统一响应里真正的业务数据少一层解包。401 出现时说明 token 过期或非法清空本地缓存并强制跳登录页避免带着脏 token 一直打接口。4.4 列表页完整闭环搜索、分页、状态映射以老人列表为例一个完整闭环是这样的template div el-input v-modelquery.keyword placeholder姓名或身份证 clearable keyup.enter.nativehandleSearch stylewidth: 240px / el-button typeprimary clickhandleSearch查询/el-button el-table :datalist v-loadingloading border el-table-column propname label姓名 width120 / el-table-column proproomId label房间 width100 / el-table-column label照护等级 template slot-scope{ row }{{ levelText(row.care_level) }}/template /el-table-column el-table-column label状态 template slot-scope{ row } el-tag :typerow.status 1 ? success : info {{ row.status 1 ? 在住 : 退住 }} /el-tag /template /el-table-column el-table-column label操作 template slot-scope{ row } el-button sizemini clickgoDetail(row.id)详情/el-button /template /el-table-column /el-table el-pagination background layouttotal, prev, pager, next :current-pagequery.pageNum :page-sizequery.pageSize :totaltotal current-changehandlePageChange / /div /template script export default { data() { return { list: [], total: 0, loading: false, query: { pageNum: 1, pageSize: 10, keyword: } }; }, created() { this.fetchList(); }, methods: { async fetchList() { this.loading true; try { const data await this.$api.elderPage(this.query); this.list data.list; this.total data.total; } finally { this.loading false; } }, handleSearch() { this.query.pageNum 1; this.fetchList(); }, handlePageChange(page) { this.query.pageNum page; this.fetchList(); }, levelText(v) { return [, 自理, 半自理, 全护理][v] || -; }, goDetail(id) { this.$router.push({ path: /elder/detail/${id} }); } } }; /script留意两个细节。一是 handleSearch 里必须先重置 pageNum 为 1否则当前在第 5 页时输入新关键词接口会返回“第 5 页的新结果”看起来就像数据丢失这种 bug 在评审演示时最容易翻车。二是 loading 放在 finally 里关闭快速连续切换搜索词时旧响应不会闪一下新数据再消失。跳详情页用的是 path 拼 id对应路由里elder/detail/:id的 params 传参回到列表时通过 query 把 keyword 带回来筛选条件不丢。5. 运行与排错Node.js、Vue 与数据库三端联调的高频问题5.1 解压后的启动顺序与四项核对解压后的目录通常分成 server、web、database 三块启动顺序固定先导入 sql再改后端配置然后前端安装依赖最后先起后端再起前端。这个顺序不能乱前端 npm run dev 启动后立刻就要向后端要数据后端没起只能看到一串网络报错。拿到源码后我会逐项核对四个位置第一sql 文件用 utf8mb4 导入导入后到数据库客户端里查一下表数量是否与文档一致一次跨机迁移的数据库同步千万别只导一半第二后端 config/db.js 里的库名、账号、密码是否与本机 MySQL 一致第三前端 vue.config.js 里 devServer.proxy 的 target 端口是否等于后端监听端口第四登录账号在 user 表里真实存在且密码是哈希值。前两项是九成启动失败的来源。5.2 四个高频报错与对应处理Windows 下最常见的报错是npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这是 PowerShell 默认执行策略 Restricted 拦截了 .ps1 脚本与 Node.js 本身无关以管理员身份打开 PowerShell 执行Set-ExecutionPolicy RemoteSigned后重新开终端即可。后端启动抛 EADDRINUSE 是端口被占用在 app.js 里换一个监听端口或者用netstat -ano | findstr :3000找到 PID 后结束占用进程。前后端联调时被跨域拦截优先在前端 devServer 里配 proxy 把 /api 转发到后端而不是在后端装 cors 插件放开所有来源生产部署时更干净。部署阶段还有一个典型现象是“vue 打包后布局异常”index.html 能打开但 CSS 和 JS 全部 404页面完全错位。原因九成是 vue.config.js 里的 publicPath 用了绝对路径/部署到子目录后资源全部找不到把它改成./就能解决大部分静态资源路径问题。5.3 用一条 curl 验证后端到数据库全链路后端和数据库是否打通不一定要打开浏览器用 curl 验证更快。先登录拿 tokencurl -s -X POST http://localhost:3000/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}返回 JSON 里找到 data.token 字段复制出来再请求分页接口curl -s http://localhost:3000/api/elder/page?pageNum1pageSize10 \ -H Authorization: Bearer 上面拿到的token返回 code 为 0 且 total 字段正常说明前端、后端、数据库整条链路是通的。返回 401 先检查 token 是否过期或请求头格式返回 code 为 1 且 msg 带 SQL 关键字说明查询语句执行失败优先去数据库客户端核对表名和字段名而不是改接口代码。如果 code 为 0 但 total 是 0先查 sql 文件里的 seed 数据导没导进去再看后端查询语句的表名前缀和实际建表是否一致这两处对齐之后这份源码才算真正接进了你的机器。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进