简介:面向体检中心信息化建设场景,这套基于SpringBoot、Vue与Element UI的前后端分离源码,适合Java开发人员、医疗信息系统学习者及需要快速搭建体检管理模块的团队参考。系统围绕预约、结果录入与查询等典型业务设计,采用Spring Security与JWT保障访问安全,前后端分离架构便于并行开发与后续扩展。资源包共1656个文件,约13.64MB,以Vue组件(296个)、Java源码(285个class与265个java)为主,配合JavaScript、CSS、SQL脚本及开发文档,覆盖前端页面、后端接口与数据库初始化等层次,目录结构清晰,便于学习和二次开发。内容还包含批量启动脚本及运行配置,能帮助读者快速启动项目并理解鉴权流程。目前已有108人学习下载,适合希望结合真实项目掌握SpringBoot与Vue整合、RESTful接口设计及权限控制实践的开发者。
1. 体检中心信息管理系统的前后端分离,难点从来不在框架
做过体检业务的人都知道,最折磨人的不是登录、不是菜单,而是“一个订单里挂了几十个检查项,每个项由不同科室录结果,最后总检医师还要把异常项汇总成报告”。如果继续沿用以前的单体 JSP 或 PHP 后台方式改数据,前后端代码混在一个工程里,体检高峰期光是一个“批量录入”页面就能把开发拖垮。所以这一类项目换成 SpringBoot 后端 + Vue 前端 + Element 组件库的「前后端分离」结构,根本原因不是追新,而是业务需要:后端只出接口,前端只关心交互,分工清楚、排错简单、也方便以后接小程序或自助机。这篇内容面向正在做类似管理系统的 Java 开发,讲清楚这类设计源码里最值得拷贝的三块:状态建模、权限接口和科室录入的联动,顺便把容易出错的地方都标出来。
2. 把体检流程先变成状态机,再谈库表设计
体检中心信息管理系统接手时,第一件事永远不是建表,而是把业务流程图铺开:预约、到检登记、分检、抽血、各科室录入、总检审核、报告打印。绝大多数系统的混乱,都源于“这些环节之间的流转关系没定义清楚”。业务上每个环节都会产生状态变化,而状态变化又决定了哪些人能看到哪些界面、能调哪些接口。所以把这层先立住,后端接口和前端页面的工作量能省下至少三分之一。
2.1 体检单的核心状态流:从登记到报告
一个体检单,也就是 exam_order,最稳定的状态模型通常是下面这张表:
| 状态值 | 状态含义 | 可流转方向 | 操作角色 |
|---|---|---|---|
| 0 | 已登记(待检) | 1 | 前台登记护士 |
| 1 | 检查中(可跨科室) | 0, 2 | 各科室医生、护士 |
| 2 | 总检中(待审核) | 1, 3 | 总检医师 |
| 3 | 已出报告 | 0, 2 | 总检医师、系统管理员 |
这里最容易被忽略的是“退回”:总检医师看到某个项目的描述与结论互相矛盾时,必须能把状态重新打回 1,而且退回时还要写清退回原因。否则这个单子就会卡死在天上,变成“查不到、改不了、打不印”的僵尸单。所以在状态机设计时,不要只考虑下行流,倒退回退的路径必须和正向路径一样完整,这直接决定后面代码里状态合法性校验的判断逻辑。
2.2 库表落地:登记单、项目明细、结果表分开存
个人经验是,体检中心信息管理系统的数据库表至少要拆三层:
第一层是体检档案 customer:存姓名、性别、年龄、手机号、既往病史等基础资料; 第二层是体检单 exam_order:一次到检一条记录,存体检日期、所属套餐、总金额、状态、总检结论等; 第三层是体检单明细 exam_order_item:一个订单对应多条项目,每个项目关联科室、检查状态、结果内容。
结果表不建议每个科室建一张,因为检查项目结构差异太大,耳鼻喉科的描述和检验科的数值完全不是一个形态。常规做法是:在 exam_order_item 表里设计一个 result_json 字段,把结果描述、参考范围、异常标记、小图片路径统一塞进 JSON,等出报告时再按项目模板解析展示。这样后端接口的代码几乎不用跟着科室增加而改动。
CREATE TABLE exam_order ( id BIGINT PRIMARY KEY COMMENT '订单ID', order_no VARCHAR(32) NOT NULL COMMENT '体检单号', customer_id BIGINT NOT NULL, package_id BIGINT COMMENT '套餐ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0登记 1检查中 2总检中 3已完成', reviewer_remark VARCHAR(500) COMMENT '总检退回原因', report_path VARCHAR(200) COMMENT '报告PDF路径', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_order_no(order_no) ); CREATE TABLE exam_order_item ( id BIGINT PRIMARY KEY, exam_order_id BIGINT NOT NULL, item_code VARCHAR(32) COMMENT '项目编码', item_name VARCHAR(64) COMMENT '项目名称', dept_code VARCHAR(32) COMMENT '科室编码', status TINYINT DEFAULT 0 COMMENT '0待检 1已采集/已检查 2已出结果', result_json JSON COMMENT '结果内容,结构由模板控制', operator_name VARCHAR(32) COMMENT '录入人', check_time DATETIME COMMENT '检查时间', version INT DEFAULT 0, KEY idx_order_id(exam_order_id) );这两张表的关键设计点在于:exam_order 的 status 控制整单流转,exam_order_item 的 status 只控制项目粒度;总检界面必须先把全部明细状态确认成 2,才能把整单状态推向“已出报告”。写接口时,这个校验必须放在数据库事务里做,不能靠前端去判断状态再传参数。
2.3 并发录入下的行锁与乐观锁
一旦到了上午十点的体检高峰,同一个订单的多个科室项目可能同时被不同医生提交。此时最典型的 bug 是:总检医师看到的是旧数据,或者两个医生同时把同一个项目的结果覆盖掉。预防手段有两个,一是对 exam_order 的更新强制走 select ... for update,让整个审核动作串行化;二是对 exam_order_item 加 version 字段,通过乐观锁控制同一条明细不能被后提交的脏数据覆盖:
int updated = examOrderItemMapper.updateByVersion(item); if (updated == 0) { throw new BizException("该项目结果已被其他科室更新,请刷新后重试"); }3. SpringBoot 后端:接口分层、JWT 校验与总检事务
看这类体检管理系统的源码,第一步应该是先看包结构,而不是去看 controller 里写了多少行。统一的分层结构能让人一眼定位业务逻辑在哪儿、校验在哪儿、数据库映射在哪儿,这比代码注释管用得多。常见做法是把后端拆成 controller、service、mapper、domain、common 五个包,common 里放统一返回体、异常、JWT 工具和配置类。
3.1 接口分层与核心依赖
一个可维护的 SpringBoot 后端工程,目录结构大致长这样:
com.example.pec ├── common │ ├── Result.java │ ├── BizException.java │ └── GlobalExceptionHandler.java ├── config │ ├── SecurityConfig.java │ └── MybatisPlusConfig.java ├── controller │ ├── AuthController.java │ ├── ExamOrderController.java │ └── ReportController.java ├── service │ ├── ExamOrderService.java │ └── impl/ExamOrderServiceImpl.java ├── mapper │ └── ExamOrderMapper.java └── domain ├── entity/ExamOrder.java ├── dto/ReviewRequest.java └── vo/ExamOrderVO.javaSpringBoot 版本选择上,建议卡在 2.7.x,而不是无脑上 3.x。原因很现实:很多企业级系统仍基于 JDK 8 和 javax 包体系,SpringBoot 3 切换到 jakarta 命名空间后,旧版 MyBatis 插件、代码生成器、运维脚本都可能要跟着改。体检中心这类管理系统的诉求是稳定顶住业务高峰,不是做新语法试验田,所以 2.7.x 配 MySQL 8.0 是踩坑成本最低的组合。
依赖方面,预留 Redis 可选项,但核心链路并不强制依赖它:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency>这套依赖里 spring-boot-starter-security 的主要用途不是做会话管理,而是让 Spring Security 的过滤器链接管请求,同时关闭默认登录页,只保留 JWT 过校验。业务上真正用到 Security 的只有两处:密码加密和接口方法级权限注解。
3.2 前后端分离下的 JWT 与请求 Token 处理
前后端分离之后,后端不再维护 HttpSession,识别用户身份的方式改为:前端把 JWT 放在请求头 Authorization 里,后端每次请求先解析并校验签名。这一步做不好,最常见的现象就是用户登录后刷新页面又跳回登录页,或者接口偶发 401。核心过滤器这样写:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Value("${jwt.secret}") private String secret; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { String token = header.substring(7); try { Claims claims = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); String userId = claims.get("userId", String.class); String roleCode = claims.get("roleCode", String.class); // 写入当前请求上下文 UserContext.set(userId, roleCode); } catch (ExpiredJwtException e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"登录已过期\"}"); return; } } chain.doFilter(request, response); } }这段过滤器需要关注的参数有三个:token 前缀约定为 Bearer、过期时间在生成 token 处统一配置为 8 小时、jwt.secret 必须从配置文件中读取而不能硬编码。企业环境里 secret 超过 32 字符才能保证 HS256 签名强度。另一个容易踩的坑是过滤器放行路径,登录接口和静态资源要显式加白名单,否则会出现“登录接口自己返回 401”的乌龙。
3.3 总检审核的事务边界:先锁单再改状态
总检是整个系统里事务性最强的接口,因为一步要同时写主单状态、更新多个明细状态、记录退回原因,任何一步失败都要整体回滚。推荐用 Service 层事务加行锁的方式实现:
@Transactional(rollbackFor = Exception.class) public void review(ReviewRequest req) { ExamOrder order = examOrderMapper.selectByIdForUpdate(req.getOrderId()); if (order == null) { throw new BizException("体检单不存在"); } if (order.getStatus() != 2) { throw new BizException("当前状态不允许总检操作"); } if (req.getPassed() && examOrderItemMapper.countUnfinished(req.getOrderId()) > 0) { throw new BizException("存在未完成的检查项目,无法通过总检"); } order.setStatus(req.getPassed() ? 3 : 1); order.setReviewerRemark(req.getRemark()); examOrderMapper.updateById(order); }方法上加 @Transactional 能保证多表写在同一个事务里,selectByIdForUpdate 会锁住 exam_order 表中对应记录,防止同事工位的总检医师同时审核同一条单子造成状态互相覆盖。注意 review 接口不接收前端传来的目标状态,只接收“通过/退回”,目标状态由后端依据当前状态计算,否则恶意请求能把一个报告单直接改成任意状态。
3.4 统一返回体与错误码约定
前端拿到接口响应时,最怕“一会儿返回对象、一会儿返回字符串、一会儿直接 HTTP 500”。体检管理系统里最常见的约定是统一用 JSON 包一层:
{ "code": 0, "msg": "success", "data": {} }code 为 0 表示业务成功,非 0 为业务失败,HTTP 状态码只负责 transport 层语义。比如登录过期时返回 HTTP 401,前端 axios 拦截器看到 401 就直接清 localStorage 并跳登录页。业务错误码用 4 位数字规划,避免和 HTTP 码混淆。
4. Vue 与 Element 前端:菜单、Token 与科室录入联动
体检中心的前端页面总量不大,但交互密度高,一个检查项目录入页可能要同时处理“单项保存”“批量标记完成”“异常项弹窗备注”三种动作。前端这部分,用 Vue 附带 Element 组件库正好合适:Element 的表格、表单校验、日期选择器都是现成的,开发速度比纯手写快很多,而且前后端分离后,前端工程可以独立部署、独立打包,不用再跟后端抢同一个 Tomcat。
4.1 工程初始化与版本选型
新的项目直接用 Vue 3 + Vite + Element Plus 起步,特别是打包速度比 Webpack 快得多。如果项目代码是以前用 Vue 2 写的,保留 Element UI 也完全可维护,核心差异只在组件引入方式上。这里按 Vue 3 + Element Plus 讲解,初始化命令如下:
npm create vite@latest pec-front -- --template vue cd pec-front npm install element-plus axios vue-router pinia安装完 Element Plus 后,推荐按需自动导入,而不是全量引入,否则打包产物会多出几百 KB,部署到体检中心那种配置普通的服务器上,首屏加载明显变慢。自动导入需要用 unplugin-auto-import 和 unplugin-vue-components,配置在 vite.config.js 的 plugins 里。打包后如果发现样式错乱,优先检查这两个插件版本与 Vite 版本是否匹配,多数情况是版本落差导致的部分组件样式丢失。
4.2 axios 封装与 Vue 请求 Token 处理
前端每个接口请求都要带上 token,不能每写一个接口就手动拼一次请求头。标准做法是把 axios 实例统一封装,在拦截器里集中处理 token 注入和 401 跳转:
import axios from 'axios'; import router from '../router'; import { ElMessage } from 'element-plus'; const service = axios.create({ baseURL: '/api', timeout: 15000 }); service.interceptors.request.use(config => { const token = localStorage.getItem('pec_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, error => Promise.reject(error)); service.interceptors.response.use(response => { const res = response.data; if (res.code !== 0) { ElMessage.error(res.msg || '操作失败'); return Promise.reject(new Error(res.msg)); } return res.data; }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('pec_token'); localStorage.removeItem('pec_user'); router.push('/login'); } return Promise.reject(error); }); export default service;这个封装里有两个关键点:baseURL 设为 /api,上线后由 Nginx 反向代理到后端服务,避免在代码里写死 IP 和端口;响应拦截器里直接返回 res.data,业务代码里拿到的就已经是后端 data 字段的内容,少一层嵌套。注意业务 code 为 401 时后端返回的 HTTP 状态码也要同步是 401,否则拦截器只能靠业务码判断。
4.3 路由守卫与按角色过滤菜单
体检系统里有护士、医生、总检医师、管理员四种角色,不能把所有菜单都渲染给所有人。常见方案是登录接口返回菜单列表 MeunTree,前端拿到后动态添加路由:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('pec_token'); if (!token && to.path !== '/login') { next('/login'); return; } if (token && !router.hasRoute('Home')) { const menus = JSON.parse(localStorage.getItem('pec_menus') || '[]'); menus.forEach(item => { router.addRoute({ path: item.path, name: item.name, component: () => import(`../views/${item.component}.vue`) }); }); } next(); });动态加载组件路径时,import 后面必须用模板字符串把完整相对路径拼出来,否则 Vite 无法在构建时分析出依赖,打包后会报“Failed to resolve component”。初次加载时可以进入 /login 再跳首页,让 Vue Router 把动态路由注册完再渲染菜单,避免出现首页空白。
4.4 科室录入页:Element 表格、表单校验与状态联动
科室录入是整个系统最常被吐槽的页面,因为医生录完一个项目后,下一个项目可能还是同一个科室的,也可能是别的科室的。页面设计上通常分三个区域:左侧待检列表、中间项目表格、右侧异常说明面板。Element 的 el-table 加 selection 列,配合 el-form 的 rules 做校验,基本能覆盖绝大多数录入场景。
表格多选有个非常隐蔽的坑:翻页后默认清空勾选,导致医生刚标记完第一页项目,切到第二页再回来发现全没了。Element Plus 里解决办法是给表格加 row-key 属性,并在 selection 列上打开 reserve-selection:
<el-table :data="items" row-key="id" @selection-change="onSelectionChange"> <el-table-column type="selection" reserve-selection :selectable="row => row.status === 0" width="45" /> <el-table-column prop="itemName" label="项目名称" min-width="140" /> <el-table-column prop="deptName" label="所属科室" min-width="100" /> <el-table-column prop="resultSummary" label="检查结果" min-width="160" /> </el-table>这里 selectable 的作用是把已录完结果的项目置灰,防止医生重复勾选。批量提交按钮触发时,遍历选中行逐个调用保存接口,哪个失败就把行号返回定位到表格里,而不是整批失败。医生电脑上经常同时开着多个页面,保存接口必须返回行 id,前端才能精确提示到底是哪一项冲突了,避免带着一个模糊错误到处找问题。
5. 生产部署与前后端联调验证
体检中心这类内网系统部署路径通常很固定:后端打 jar 包跑在服务器上,前端打包成静态文件丢给 Nginx,前端请求 /api 时由 Nginx 转发到后端 8080 端口。开发环境下,前后端分离的跨域问题是绕不开的一环,Vite 配置代理是最省事的方式:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这个配置的含义是前端请求 /api/login 时,Vite 开发服务器把它转发到 localhost:8080/login,浏览器里看起来是同源请求,不会触发 CORS。生产环境同理,Nginx 上做一层 location /api 反代:
location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; } location / { root /opt/pec-front/dist; index index.html; try_files $uri $uri/ /index.html; }两个 location 的配合要点:location /api/ 把带 /api 前缀的请求转发到后端且去除前缀;location / 里 try_files 归到 index.html,否则 Vue Router 用 history 模式时,刷新子页面会报 404。打包出来的前端文件如果出现白屏,先开浏览器网络面板看 js 和 css 是否 404,404 就看 vite.config.js 里的 base 配置,设置为相对路径 / 或者服务器部署子路径。
部署完成后,建议按下面这个清单走一遍完整闭环,任何一个环节失败都可以快速定位是后端接口问题还是前端交互问题:
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| 后端存活 | curl http://127.0.0.1:8080/api/auth/login | 返回 JSON 而非连接拒绝 |
| 登录签发 | 用护士账号调登录接口 | 返回 token,且角色为护士 |
| 前端静态资源 | 浏览器访问 Nginx 首页 | 加载出登录页,控制台无红色报错 |
| 创建体检单 | 前端登记一条新订单 | 后端库出现 exam_order 数据,状态为 0 |
| 总检流转 | 先录完明细,再调总检通过接口 | 状态由 2 变为 3,报告路径被写入 |
最后有一点值得强调:体检管理系统上线后的日常维护量主要来自科室项目模板调整和报告格式修改,与前端的页面代码关系不大。因此后端把项目模板配置做成数据库驱动,前端将体检表单项做成动态渲染,后续业务变化时就能只改配置不动代码。改完配置后记得用一条变更过套餐的测试体检单重新走一遍流转,确认状态节点从登记顺畅推进到出报告,这套源码才算真正可用。
本文还有配套的精品资源,点击获取