简介:这是一套面向高校课程设计、毕业设计与Java全栈学习者的养老院管理系统源码,采用SSM(Spring+SpringMVC+MyBatis)后端与Vue前端,并配套Android移动端,可用于人口老龄化背景下的机构管理场景实践,帮助读者掌握前后端分离与多端协同的开发思路。压缩包共1541个文件,约23.75MB,其中339个vue组件构成前端页面,232个java文件承载后端业务逻辑,另有242个js、319个png与113个jpg等静态资源,以及sql脚本、xml配置和bat启动脚本,覆盖数据库、接口与部署环节。资源已有83人学习下载,包含完整工程结构与可运行代码,适合对照搭建环境、梳理模块划分、理解SSM与Vue的整合方式,并借鉴Android端的数据交互实现,为课程作业或项目实战提供可复用的参考方案。
1. 养老院管理系统为什么还在用 SSM + Vue + Android 这套组合
去年帮一个做民政信息化的朋友评估一套养老院管理系统,他手里拿到的是一份 Java-ssm-vue 基于 Android 的养老院管理系统实现源码。他问我的第一个问题不是代码好不好,而是这套技术栈是不是已经过时了。这个问题其实很典型,很多做 Java 课程设计或者中小型行业软件的人都会卡在这里。SSM 指的是 Spring + SpringMVC + MyBatis,Vue 负责后台管理界面,Android 负责护工和家属端的移动操作。这套组合在养老院这个场景里其实非常合理,因为养老院的信息化需求不是高并发,而是流程闭环和数据准确性。老人入住登记、床位分配、护理记录、用药提醒、家属探视预约、费用结算,这些业务模块天然适合用 SSM 做分层,用 Vue 做管理端,用 Android 做现场采集。如果你正在找 Java 课程设计案例源码,或者想做一个能跑起来的养老院管理系统,这套架构的落地路径是清晰的,坑也是可以提前避开的。
2. SSM 后端分层:从老人档案到护理记录的建表与接口设计
2.1 养老院管理系统的核心表结构与字段取舍
养老院管理系统的数据库设计决定了后面接口好不好写。我一般会把表分成三类:人员类、业务类、系统类。人员类包括老人档案表、家属表、护工表;业务类包括床位表、护理记录表、用药计划表、费用账单表;系统类就是用户、角色、权限。老人档案表是核心,字段不能照搬学生信息管理那种模板。实际落地时,elderly 表至少要有 id、name、gender、age、id_card、check_in_date、bed_id、care_level、medical_history、emergency_contact、emergency_phone、status。其中 care_level 是护理等级,一般分自理、半自理、不能自理三档,这个字段直接影响护理记录的频率和用药提醒的推送策略。bed_id 关联床位表,status 用 0 表示在住、1 表示已退住、2 表示请假外出。
护理记录表 nursing_record 的字段设计有个容易翻车的地方:很多人只记 content 和 create_time,结果家属端想按时间段查护理情况时发现没有护理类型。我一般会加 care_type 字段,枚举值包括 daily_care、medication、rehabilitation、emergency。这样 Vue 管理端做统计图表时可以直接 group by care_type,Android 端护工提交记录时也能按类型走不同的表单。
CREATE TABLE elderly ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, gender TINYINT DEFAULT 0 COMMENT '0女1男', age INT, id_card VARCHAR(18) UNIQUE, check_in_date DATE, bed_id INT, care_level TINYINT DEFAULT 0 COMMENT '0自理1半自理2不能自理', medical_history TEXT, emergency_contact VARCHAR(32), emergency_phone VARCHAR(20), status TINYINT DEFAULT 0 COMMENT '0在住1退住2请假', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE nursing_record ( id INT PRIMARY KEY AUTO_INCREMENT, elderly_id INT NOT NULL, staff_id INT NOT NULL, care_type VARCHAR(20) NOT NULL COMMENT 'daily_care/medication/rehabilitation/emergency', content TEXT, record_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_elderly_time (elderly_id, record_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;建表时注意两个参数:字符集用 utf8mb4,因为老人姓名和病历里可能出现生僻字;nursing_record 表要建联合索引 idx_elderly_time,因为家属端和 Vue 管理端最高频的查询就是按老人和时间段查护理记录。如果没有这个索引,数据量到几万条时查询会明显变慢。
2.2 MyBatis 映射与 Service 层的事务边界
SSM 里 MyBatis 的 XML 映射文件是容易写乱的地方。养老院管理系统的查询往往带多条件筛选,比如按护理等级、床位区域、入住状态组合查老人列表。我一般不在 XML 里写动态 SQL 的复杂 if 嵌套,而是用 MyBatis 的 Example 或者自定义 DTO 接收查询参数。下面是一个典型的老人列表查询接口。
// ElderlyMapper.xml 片段 <select id="selectByCondition" resultMap="ElderlyResultMap"> SELECT e.*, b.bed_no, b.room_no FROM elderly e LEFT JOIN bed b ON e.bed_id = b.id <where> <if test="careLevel != null"> AND e.care_level = #{careLevel} </if> <if test="status != null"> AND e.status = #{status} </if> <if test="keyword != null and keyword != ''"> AND (e.name LIKE CONCAT('%', #{keyword}, '%') OR e.id_card LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY e.check_in_date DESC LIMIT #{offset}, #{limit} </select>这段 XML 的逻辑说明:用 LEFT JOIN 把床位信息带出来,避免 Vue 端拿到 bed_id 后再发一次请求。where 标签会自动处理第一个条件前的 AND。keyword 同时匹配姓名和身份证号,这是管理端搜索框的常见需求。分页用 LIMIT offset, limit,offset 由 PageHelper 或者手动计算。参数说明:careLevel 和 status 是可选筛选,keyword 是模糊搜索,offset 和 limit 控制分页。
Service 层的事务边界要特别注意。老人退住这个操作涉及三张表:更新 elderly.status、释放 bed 表的占用状态、生成一条退住记录。这三个操作必须在同一个 @Transactional 里,否则中途失败会出现床位已释放但老人状态还是“在住”的脏数据。我一般把事务加在 Service 实现类的方法上,rollbackFor 指定 Exception.class,不要只靠默认的 RuntimeException。
@Service public class ElderlyServiceImpl implements ElderlyService { @Autowired private ElderlyMapper elderlyMapper; @Autowired private BedMapper bedMapper; @Autowired private CheckoutRecordMapper checkoutRecordMapper; @Override @Transactional(rollbackFor = Exception.class) public void checkout(Integer elderlyId, String reason) { Elderly elderly = elderlyMapper.selectByPrimaryKey(elderlyId); if (elderly == null || elderly.getStatus() != 0) { throw new BusinessException("老人不在住状态,无法退住"); } elderly.setStatus(1); elderlyMapper.updateByPrimaryKeySelective(elderly); Bed bed = bedMapper.selectByPrimaryKey(elderly.getBedId()); bed.setStatus(0); bedMapper.updateByPrimaryKeySelective(bed); CheckoutRecord record = new CheckoutRecord(); record.setElderlyId(elderlyId); record.setReason(reason); record.setCheckoutTime(new Date()); checkoutRecordMapper.insert(record); } }这段代码的关键点:先校验状态再操作,避免重复退住;updateByPrimaryKeySelective 只更新非空字段,不会把其他字段覆盖成 null;三个写操作在同一个事务里,任何一步抛异常都会回滚。参数说明:elderlyId 是老人主键,reason 是退住原因,由 Vue 端表单传入。
3. Vue 管理端:路由、权限和护理记录表格的落地细节
3.1 Vue 路由参数与动态菜单的配合方式
Vue 管理端在养老院管理系统里承担的是后台运营角色,护工排班、老人档案、费用账单、统计报表都在这里。路由设计上,我一般用 vue-router 的嵌套路由,父级是 Layout,子级是各个业务页面。路由参数主要用在详情页,比如 /elderly/detail/:id。这里有个容易踩的坑:从列表页跳详情页时用 params 传参,刷新页面后参数丢失。正确做法是用 query 或者把 id 放在路径里。
// router/index.js const routes = [ { path: '/', component: Layout, redirect: '/dashboard', children: [ { path: 'dashboard', component: () => import('@/views/Dashboard'), meta: { title: '工作台' } }, { path: 'elderly/list', component: () => import('@/views/elderly/List'), meta: { title: '老人档案' } }, { path: 'elderly/detail/:id', component: () => import('@/views/elderly/Detail'), meta: { title: '老人详情', hidden: true } }, { path: 'nursing/record', component: () => import('@/views/nursing/Record'), meta: { title: '护理记录' } }, { path: 'bill/list', component: () => import('@/views/bill/List'), meta: { title: '费用账单' } } ] } ]路由配置的逻辑说明:meta.title 用于面包屑和菜单渲染,meta.hidden 控制详情页不在侧边栏显示。动态菜单的常见做法是后端返回权限列表,前端根据权限过滤 routes。养老院管理系统的角色一般分管理员、院长、护工、财务,不同角色看到的菜单不同。护工只能看护理记录和老人基本信息,财务只能看账单。这个权限控制可以在路由守卫里做,也可以在菜单渲染时过滤。
// permission.js 路由守卫片段 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') return } const userRole = localStorage.getItem('role') const routeRoles = to.meta.roles || [] if (routeRoles.length > 0 && !routeRoles.includes(userRole)) { next('/403') return } next() })参数说明:token 存在 localStorage 里,每次路由跳转前检查;role 是登录时后端返回的角色标识;to.meta.roles 是路由配置里声明的可访问角色数组。注意不要把权限判断只放在前端,后端接口也要做同样的校验,否则懂行的人直接调接口就能绕过。
3.2 护理记录表格的分页、筛选与导出
护理记录页面是 Vue 管理端里交互最重的页面之一。护工每天提交的记录可能上百条,管理端要支持按老人姓名、护理类型、时间段筛选,还要能导出 Excel。表格用 Element UI 的 el-table,分页用 el-pagination。筛选条件放在一个 form 里,查询时把参数拼到 axios 请求上。
// views/nursing/Record.vue 片段 export default { data() { return { query: { elderlyName: '', careType: '', dateRange: [] }, tableData: [], total: 0, page: 1, size: 10 } }, methods: { async fetchData() { const params = { elderlyName: this.query.elderlyName, careType: this.query.careType, startDate: this.query.dateRange[0] || '', endDate: this.query.dateRange[1] || '', page: this.page, size: this.size } const res = await axios.get('/api/nursing/record/list', { params }) this.tableData = res.data.rows this.total = res.data.total }, handleExport() { window.open('/api/nursing/record/export?startDate=' + this.query.dateRange[0] + '&endDate=' + this.query.dateRange[1]) } } }这段代码的逻辑说明:fetchData 把筛选条件和分页参数一起发给后端,后端用 PageHelper 分页返回 rows 和 total。handleExport 直接打开一个后端导出接口,用流的方式返回 Excel 文件。参数说明:elderlyName 是模糊搜索,careType 是下拉选择,dateRange 是日期范围数组,page 和 size 控制分页。
导出功能有个血泪经验:如果数据量大,不要在前端用 js-xlsx 拼 Excel,内存会爆。正确做法是后端用 POI 的 SXSSFWorkbook 流式写出,前端直接 window.open 下载。另外导出接口的权限要单独校验,不能因为前端隐藏了按钮就以为安全了。
4. Android 端:护工现场采集的界面与网络层实现
4.1 Android Studio 项目结构与养老院业务模块划分
Android 端在养老院管理系统里主要给护工用,功能包括查看负责的老人列表、提交护理记录、拍照上传、接收用药提醒。用 Android Studio 新建项目时,我一般选 Empty Activity,语言用 Java,最低 SDK 设到 24,因为很多养老院配的平板还是 Android 7.0。项目结构按业务分包:activity、adapter、model、network、util、widget。不要把所有类都堆在根包下,后期改起来很痛苦。
护工登录后进入主界面,底部导航栏一般放三个 tab:我的老人、护理记录、消息提醒。我的老人页面用 RecyclerView 展示老人卡片,每张卡片显示姓名、床位号、护理等级、今日护理次数。点击卡片进入详情页,详情页顶部是老人基本信息,下面是护理记录时间线,底部是“新增护理记录”按钮。
// ElderlyListActivity.java 片段 public class ElderlyListActivity extends AppCompatActivity { private RecyclerView recyclerView; private ElderlyAdapter adapter; private List<Elderly> elderlyList = new ArrayList<>(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_elderly_list); recyclerView = findViewById(R.id.recycler_view); recyclerView.setLayoutManager(new LinearLayoutManager(this)); adapter = new ElderlyAdapter(elderlyList); recyclerView.setAdapter(adapter); loadData(); } private void loadData() { String staffId = SPUtils.getString(this, "staff_id"); ApiService.getElderlyByStaff(staffId, new Callback<List<Elderly>>() { @Override public void onSuccess(List<Elderly> data) { elderlyList.clear(); elderlyList.addAll(data); adapter.notifyDataSetChanged(); } @Override public void onFailure(String msg) { Toast.makeText(ElderlyListActivity.this, msg, Toast.LENGTH_SHORT).show(); } }); } }这段代码的逻辑说明:onCreate 里初始化 RecyclerView 和 Adapter,loadData 从 SharedPreferences 取护工 ID,然后调接口拿负责的老人列表。参数说明:staffId 是登录时保存的护工主键,ApiService 是封装的网络请求类,Callback 是自定义的回调接口。注意 Android 端不要在主线程做网络请求,否则会抛 NetworkOnMainThreadException。
4.2 护理记录提交与图片上传的完整链路
护工提交护理记录时,表单包括护理类型、护理内容、现场照片。照片上传是 Android 端最容易出问题的环节。我一般用 OkHttp 的 MultipartBody 上传,后端用 SpringMVC 的 MultipartFile 接收。图片先压缩再上传,不然一张照片几 MB,养老院的 WiFi 上传很慢。
// NursingRecordActivity.java 上传片段 private void submitRecord() { MultipartBody.Builder builder = new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("elderlyId", String.valueOf(elderlyId)) .addFormDataPart("careType", careType) .addFormDataPart("content", contentEt.getText().toString()); if (imageFile != null && imageFile.exists()) { RequestBody fileBody = RequestBody.create(MediaType.parse("image/jpeg"), imageFile); builder.addFormDataPart("photo", imageFile.getName(), fileBody); } Request request = new Request.Builder() .url(BASE_URL + "/api/nursing/record/add") .post(builder.build()) .build(); OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build(); client.newCall(request).enqueue(new okhttp3.Callback() { @Override public void onFailure(okhttp3.Call call, IOException e) { runOnUiThread(() -> Toast.makeText(NursingRecordActivity.this, "提交失败", Toast.LENGTH_SHORT).show()); } @Override public void onResponse(okhttp3.Call call, Response response) throws IOException { String result = response.body().string(); runOnUiThread(() -> { Toast.makeText(NursingRecordActivity.this, "提交成功", Toast.LENGTH_SHORT).show(); finish(); }); } }); }这段代码的逻辑说明:用 MultipartBody 构建表单,文本字段和文件字段一起提交。图片用 RequestBody.create 包装,MediaType 指定 image/jpeg。OkHttpClient 设置了连接超时 15 秒、写超时 30 秒,因为图片上传可能比较慢。回调里用 runOnUiThread 切回主线程更新 UI。参数说明:elderlyId 是老人主键,careType 是护理类型,content 是护理内容,photo 是图片文件。
后端接收的 Controller 方法要注意:MultipartFile 的参数名要和前端 addFormDataPart 的 key 一致,否则接收不到。文件保存路径不要放在项目目录下,放在外部配置的 upload 目录,数据库只存相对路径。这样部署时不会因为重新打包丢失上传的文件。
5. 避坑与排查:SSM + Vue + Android 联调中最容易翻车的 5 个点
5.1 跨域问题导致 Vue 请求全部 403
现象:Vue 开发环境跑在 localhost:8080,后端 SSM 跑在 localhost:8081,前端调接口全部报 CORS 错误或者 403。原因:浏览器同源策略拦截了跨域请求,后端没有配置 CORS 响应头。解决:在 SpringMVC 的配置里加一个 CORS 过滤器,或者用 @CrossOrigin 注解。我一般直接配一个全局过滤器,允许指定域名、方法和请求头。
// CorsFilter.java public class CorsFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletResponse response = (HttpServletResponse) res; response.setHeader("Access-Control-Allow-Origin", "http://localhost:8080"); response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); response.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization"); response.setHeader("Access-Control-Allow-Credentials", "true"); chain.doFilter(req, res); } }注意 Access-Control-Allow-Origin 不要写成 *,如果前端带了 cookie,必须指定具体域名,否则浏览器还是会拦截。
5.2 Android 端 content:// 路径转 File 失败
现象:护工选完照片后,用 new File(uri.getPath()) 拿到的路径不对,上传时报文件不存在。原因:Android 7.0 以后相册返回的是 content:// 开头的 URI,不是文件路径。解决:用 ContentResolver 查询 _data 字段拿到真实路径,或者直接把 InputStream 读出来写成临时文件。我一般用后者,兼容性更好。
private File uriToFile(Uri uri) throws IOException { InputStream inputStream = getContentResolver().openInputStream(uri); File tempFile = new File(getCacheDir(), "upload_" + System.currentTimeMillis() + ".jpg"); FileOutputStream outputStream = new FileOutputStream(tempFile); byte[] buffer = new byte[4096]; int len; while ((len = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, len); } outputStream.close(); inputStream.close(); return tempFile; }5.3 MyBatis 驼峰映射不生效导致字段为 null
现象:数据库字段是 check_in_date,实体类属性是 checkInDate,查询结果里 checkInDate 一直是 null。原因:MyBatis 默认不开启驼峰命名映射。解决:在 mybatis-config.xml 里加 setting name="mapUnderscoreToCamelCase" value="true",或者在 Spring 配置 SqlSessionFactory 时设置。这个坑很隐蔽,因为 SQL 本身没问题,就是映射不上。
5.4 Vue 打包后刷新页面 404
现象:Vue 用 history 模式,npm run build 后部署到 Tomcat,刷新非根路径页面报 404。原因:history 模式下前端路由由浏览器直接请求服务器,服务器找不到对应文件。解决:要么改用 hash 模式,要么在 Tomcat 配一个 fallback 到 index.html 的规则。我一般图省事直接用 hash 模式,虽然 URL 带 # 不好看,但不会出问题。
5.5 事务不生效导致退住后床位没释放
现象:退住操作执行了,老人状态改了,但床位还是占用状态。原因:@Transactional 注解加在了 Controller 上,或者 Service 类没有被 Spring 代理。解决:事务注解必须加在 Service 实现类的方法上,且该类要被 Spring 扫描到。另外注意,如果方法内部捕获了异常没有抛出,事务也不会回滚。
6. 从能跑到好用:养老院管理系统的接口幂等与数据校验技巧
这套系统做到能跑不难,难的是在真实养老院里稳定用下去。我踩过最深的坑是护理记录重复提交。护工在信号不好的楼层点了提交,界面没反应,又点了一次,结果同一条记录写了两遍。后来我在后端加了幂等控制:客户端每次提交带一个 requestId,后端用 Redis 或者数据库唯一索引做去重。简单做法是在 nursing_record 表加一个 request_id 字段,建唯一索引,插入时如果冲突就返回成功但不重复写。
ALTER TABLE nursing_record ADD COLUMN request_id VARCHAR(64); CREATE UNIQUE INDEX uk_request_id ON nursing_record(request_id);// 插入前先查 request_id 是否存在 NursingRecord exist = nursingRecordMapper.selectByRequestId(requestId); if (exist != null) { return Result.success("重复提交,已忽略"); } record.setRequestId(requestId); nursingRecordMapper.insert(record);另一个实用技巧是数据校验前置。老人入住登记时,身份证号格式、手机号格式、紧急联系人不能为空,这些校验不要只靠前端。Vue 端用 el-form 的 rules 做第一层,后端用 JSR303 的 @NotBlank、@Pattern 做第二层。两层校验的提示文案要一致,不然护工在 Android 端看到的错误信息和 Vue 端不一样,会以为系统坏了。
还有一个我后来才加上的功能:操作日志。养老院管理系统涉及老人隐私和费用,谁改了老人档案、谁删了护理记录,都要能追溯。我一般用 Spring AOP 做一个切面,拦截带有 @Log 注解的方法,把操作人、操作时间、方法名、参数摘要写进 operation_log 表。这个表不用做太复杂,但关键时刻能当后悔药用。
最后说一个部署上的习惯:SSM 的配置文件里,数据库密码、上传路径、文件大小限制这些不要写死在代码里,用 properties 文件外置,部署时按环境改。Android 端的 BASE_URL 也不要写死在代码里,用 BuildConfig 区分 debug 和 release。这些细节不影响功能,但决定了这套系统能不能从课程设计变成真正在养老院跑起来的东西。希望帮到你。
本文还有配套的精品资源,点击获取