这个话题要从一个真实场景说起。我接过好几个医疗类的系统,包括实验室管理系统、体检中心预约平台,但医院资源管理系统(Hospital Resource Management System,HRMS)是比较综合的。它解决的核心问题很直接:大型医院里,科室、医生、病床、设备、药品这些资源如果靠 Excel 表或者纸质台账来管理,到了就诊高峰期,排班冲突、床位不足、设备闲置这类问题会把人逼疯。这个系统的目标就是把"资源台账"和"业务流"串起来。
资源管理系统和单纯的 CRUD 不一样。虽然表面上都是管理员在后台维护数据,但医院场景对数据的准确性和并发要求很高。比如同一个诊室,上午被心内科排了门诊,下午被内分泌科排了检查,系统必须能控制冲突;再比如病房床位,一个床位不能同时分配给两个病人。这些东西在普通教务管理系统里可能不会把冲突校验做得这么严格,但在医疗项目里是刚需。
项目标题里的几个关键词——SpringBoot2、Vue3、MyBatis-Plus、MySQL8.0——正好是一个前后端完全分离的经典组合。后端用 SpringBoot2 提供 REST API,前端用 Vue3 做单页应用,数据库用 MySQL 8.0,ORM 层用 MyBatis-Plus 提升单表操作的效率。这套组合的特点是开发效率高、控制优雅、社区资源多,特别适合资源管理这类以增删改查为基础、又带复杂查询的业务系统。而且源码里还带了配套文档,数据库设计说明、接口约定、部署步骤这些最容易被忽略的部分都整理好了,这点对于想把这个项目拿去做课程设计或者毕业设计的人来说,价值不比代码本身小。
1. 项目概述与技术选型分析
1.1 医院资源管理系统是干什么的
医院资源管理系统的核心不是"挂号",而是"调配"。挂号的本质是号源分配,而号源又来源于医生的排班;排班要依赖科室的诊室资源;住院要依赖病房床位的周转;设备、药品则要追踪使用状态和维保情况。所以这个系统本质上是把医院内部的人、财、物资源统一到一套后台里做调度。
从使用者角度拆,系统里面有几种角色。系统管理员负责维护基础数据、分配账号权限;科室秘书负责排班、管理本科室资源;护士长或住院部管理员负责病房和床位的分配;医生登录后可以看自己的排班和预约列表;还有前台或分诊人员处理预约签到、状态变更。不同的角色看到的界面不一样,操作权限也不一样,这就是为什么权限模块在管理系统里永远是第一优先级。
这个项目适合谁来参考?如果你的目标是用 Java Web 技术栈完成一个前后端分离的综合性管理系统,或者正在为课程设计、毕业设计找一套完整可复现的工程,那这个项目的结构和代码风格都值得借鉴。它不算特别庞大,但把管理系统开发的常用套路都涵盖了:登录鉴权、权限路由、分页查询、树形结构、状态流转、报表统计。你把这个项目跑通一次,再换个业务域做第二个系统基本不会卡壳。
1.2 技术栈为什么这么选
先聊后端。SpringBoot2 是国内企业级项目的主力版本,这一点到现在都没变。它基于 Spring Framework 5,自带容器、AOP、事务管理,配合 Spring Security 或者 JWT 机制可以完成用户认证授权。选 SpringBoot2 而不是追新上 SpringBoot3,核心原因是生态和兼容性。SpringBoot3 把 javax 命名空间换成了 jakarta,很多老教程和现成依赖直接对不上,对于做毕设或者中小型团队来说,时间成本不划算。SpringBoot2 的教程资料是全网最多的,遇到问题搜索解决方案快,这是开发阶段最大的隐形优势。
ORM 层选 MyBatis-Plus,说白了就是为了提升开发效率。单表的增删改查它帮你做完了,简单列表查询连 XML 都不用写。它有个 LambdaQueryWrapper,写查询条件用链式调用,比如wrapper.like(StringUtils.hasText(name), Doctor::getName, name),这种写法在动态查询场景下特别好用,不用拼接 SQL 字符串,逻辑清晰。它还内置了分页插件和逻辑删除功能,这两点对管理类系统是刚需,不用自己再封装一套分页工具了。
MySQL 8.0 不用犹豫。相比 5.7,它在性能和功能上都有明显提升。默认字符集是 utf8mb4,直接支持表情符号和生僻汉字,医疗系统里经常遇到特殊字符,处理起来没有后患。它支持窗口函数,比如科室就诊量的按月度趋势统计,一条OVER(PARTITION BY ...)就搞定,在 5.7 里还得用临时表加变量去模拟,麻烦还容易出错。
前端 Vue3 就不用多说了。组合式 API 配合 setup 语法糖写业务逻辑,比 Vue2 的选项式 API 舒服太多。配合 Vite 构建,热更新速度极快。Element Plus 组件库现成可用,表格、表单、弹窗这些管理系统的常用组件都齐了。管理类页面本身 UI 复杂度不高,用组件库能省下绝大部分样式时间。
这套技术组合还有一个隐性优势:复用自己的能力。哪怕你以后不做医疗项目,把它换个业务模板,比如物业管理系统、实验室管理系统,核心架构和代码骨架是不变的。所以这套源码的价值不仅限于"医院"这个业务域,而是把它当作一套标准的前后端分离管理系统脚手架。这也是我在实际带人做项目时比较推荐这套技术栈的原因。
2. 数据库设计与核心模块拆解
2.1 核心业务关系梳理
建表的第一步,不是写 DDL,而是把业务关系搞清楚。医院资源管理系统我按三条主线来拆。
一条是"人"。人员分两类:系统用户和医护人员。系统用户可能是管理员、护士长、科室秘书,需要登录后台操作;医生本身也可能要登录查看自己的排班和预约情况。所以人员表要能区分账号体系和业务体系。我的做法是单独建一张 sys_user 表存登录账号,再建一张 doctor 表存医生的业务信息,doctor 表通过 user_id 关联账号,如果医生不需要登录账号,这个字段可以为空。
一条是"物"。病房、设备、药品这些都属于资源资产。它们的特点是都有状态:病房有"占用/空闲/清洁中",设备有"在用/闲置/维修中",药品有"库存充足/预警/缺货"。状态迁移规则各不相同,表里需要提前设计状态字段,后续业务判断都用状态字段做分支。
一条是"事"。预约、排班、领用、维保这些操作记录,就是资源管理的过程数据。预约表会把病人、医生、科室、时段、号源类型都关联起来;排班表则记录某医生在某时间段的出诊科室、号源总数、已约数。
这三条主线的关系很清晰:一个科室(department)下面有多个医生;一个病房(ward)从属于一个病区,一个病区从属于一个科室;一张排班表(schedule)通过 doctor_id、department_id 关联到医生和科室;预约表(appointment)通过 schedule_id 关联到具体排班。实际建表时我不太推荐加物理外键,只建普通索引就够了,因为物理外键在数据量大时影响写入性能,也让逻辑删除变得复杂。
2.2 关键表结构设计
先给一个模块与核心表的快速对照,方便你理解整个系统的横向范围:
| 模块 | 核心功能 | 涉及表 |
|---|---|---|
| 系统管理 | 用户、角色、菜单权限 | sys_user、sys_role、sys_menu |
| 科室管理 | 科室层级维护、启停状态 | department |
| 医生管理 | 医生档案、职称、所属科室 | doctor |
| 排班管理 | 排班创建、冲突校验、号源管理 | schedule |
| 预约管理 | 预约登记、号源扣减、签到 | appointment |
| 病房管理 | 病房床位状态流转、分配 | ward、bed |
| 设备管理 | 设备台账、状态、维保记录 | equipment、maintenance |
| 药品管理 | 药品批次、库存预警 | drug、drug_batch |
然后看科室表 department 的 DDL:
CREATE TABLE `department` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `dept_code` VARCHAR(32) NOT NULL COMMENT '科室编码', `dept_name` VARCHAR(64) NOT NULL COMMENT '科室名称', `type` TINYINT NOT NULL DEFAULT 1 COMMENT '类型:1-门诊,2-住院,3-医技,4-行政', `parent_id` BIGINT DEFAULT NULL COMMENT '上级科室ID', `sort_order` INT DEFAULT 0 COMMENT '排序号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-启用,0-停用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_dept_code` (`dept_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科室表';这里有两个设计细节值得说。第一,deleted 字段是给 MyBatis-Plus 逻辑删除用的,加了它之后你写的 DELETE 语句会被自动改写成 UPDATE,数据不会物理删除,适合排班、预约这类有审计需求的表。第二,status 和 deleted 是两个维度,status 是业务上的启用停用,deleted 是数据删除标记,别混用。
病房和床位我建议拆成两张表。病房表维护房间级信息,床位因为数量多、状态变化频繁,单独一张表每张床一条记录,用状态字段区分空闲、占用、清洁中,同时记录当前病人 ID:
CREATE TABLE `bed` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `ward_id` BIGINT NOT NULL COMMENT '所属病房ID', `bed_no` VARCHAR(16) NOT NULL COMMENT '床号', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-空闲,1-占用,2-清洁中', `patient_id` BIGINT DEFAULT NULL COMMENT '当前病人ID', `note` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_ward_status` (`ward_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='床位表';联合索引 idx_ward_status 是给"查某病房的所有空闲床位"这个高频场景准备的。查询条件同时命中 ward_id 和 status,索引效率最高。如果你只建单列索引 ward_id,全量扫出来再在内存里过滤状态,数据量小时没问题,一栋楼几百张病床同时查,性能差距就出来了。
排班表 schedule 是整个预约业务的核心。这个表的 DDL 我要重点讲:
CREATE TABLE `schedule` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `doctor_id` BIGINT NOT NULL COMMENT '医生ID', `department_id` BIGINT NOT NULL COMMENT '科室ID', `work_date` DATE NOT NULL COMMENT '出诊日期', `period` TINYINT NOT NULL COMMENT '时段:1-上午,2-下午,3-晚班', `total_count` INT NOT NULL DEFAULT 0 COMMENT '总号源数', `used_count` INT NOT NULL DEFAULT 0 COMMENT '已预约数', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-正常,1-停诊,2-满号', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_doc_date_period` (`doctor_id`, `work_date`, `period`), KEY `idx_dept_date` (`department_id`, `work_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';唯一索引 uk_doc_date_period 是防重复排班的最后一道防线。它从数据库层面保证同一个医生同一天同一个时段只能有一条排班记录。业务层可以写判断,但两个管理员并发操作时,请求都可能同时通过判断、同时插入,这时候只有唯一索引能兜底。很多人在设计时忽略了这个细节,上线后数据里出现重复排班,排查起来特别费劲。这里的 version 字段是乐观锁版本号,预约时扣减号源要用到,后面详细说。
定义好这几张主表,预约表、设备表、药品表就顺着这个思路继续展开。设备表重点记录资产编号、类型、采购日期、供应商、状态、所在科室;药品表要关注批次和有效期两个维度,医院药品管理比普通仓库严格得多。权限相关我用五表模型:sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu,通过关联表实现多对多关系,这套模型在后台管理系统里非常成熟,不需要过多解释。
3. 后端核心实现详解
3.1 SpringBoot2 项目搭建与配置
项目结构我按后端开发惯例分层,包名建议用com.hospital.admin:
com.hospital.admin ├── common # 通用工具、统一返回对象、异常处理 ├── config # 配置类:MyBatis-Plus、拦截器、跨域 ├── controller # 控制器层,REST API ├── entity # 数据库实体 ├── mapper # MyBatis-Plus Mapper接口 ├── service # 服务接口与实现 └── security # 登录认证与权限控制创建项目可以用 Spring Initializr 生成,语言版本选 Java 8 或 Java 11。SpringBoot2 的 2.7.x 支持 Java 8 到 17,但考虑到国内服务器环境,Java 8 的兼容性最好。引入依赖时注意三个关键坐标:spring-boot-starter-web、mysql-connector-java、mybatis-plus-boot-starter。
MyBatis-Plus 的 groupId 是com.baomidou,artifactId 是mybatis-plus-boot-starter,3.5.x 版本对 SpringBoot2 兼容良好。如果从 Maven 仓库下载慢,配置阿里云镜像就能解决,这个在本地开发时几乎必做。
核心配置文件 application.yml 我直接给出一份能跑的版本:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital_resource?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 banner: false configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: truedriver-class-name 写com.mysql.cj.jdbc.Driver,这是 MySQL 8.0 的驱动类名,和 MySQL 5.x 时代的com.mysql.jdbc.Driver不一样。新驱动连 MySQL 5.7 也能连,反过来用旧驱动连 8.0 会出现认证协议或 SSL 报错。
serverTimezone 这个参数很多人都踩过坑。MySQL 8.0 默认使用服务器系统时区,如果系统时区不是北京时间,连接池建立连接时会执行时区换算,业务代码查出的时间字段会相差8小时。URL 里显式指定serverTimezone=Asia/Shanghai就能根治。
3.2 MyBatis-Plus 实战要点
先注册分页插件。这个配置在 SpringBoot2 里通过一个 @Configuration 类完成:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); // 单页最大条数,防止恶意大分页 pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }分页插件一定要加上 DbType.MYSQL 参数。它会根据数据库方言生成对应的分页 SQL,MySQL 是 LIMIT 语句。如果不设置 DbType,插件在某些场景下识别不了方言,就会生成错误的 SQL。
然后写 Service 层,直接继承 MyBatis-Plus 的 IService 接口:
public interface DoctorService extends IService<Doctor> { } public class DoctorServiceImpl extends ServiceImpl<DoctorMapper, Doctor> implements DoctorService { }继承 IService 之后,save、removeById、page、list、getById 这些基础方法全都有了。Controller 里配合 LambdaQueryWrapper 写分页查询非常快:
@GetMapping("/page") public R<IPage<Doctor>> page(@RequestParam(required = false) Long departmentId, @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "1") long pageNum, @RequestParam(defaultValue = "10") long pageSize) { Page<Doctor> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Doctor> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(departmentId != null, Doctor::getDepartmentId, departmentId) .and(StringUtils.hasText(keyword), w -> w .like(Doctor::getName, keyword) .or() .like(Doctor::getTitle, keyword)); wrapper.orderByDesc(Doctor::getSortOrder); IPage<Doctor> result = doctorService.page(page, wrapper); return R.ok(result); }这段代码的业务含义是:departmentId 不为空就按科室精确过滤;keyword 有值就在姓名和职称两个字段上做模糊匹配;排序按 sortOrder 倒序。eq、like 这类条件方法第一个参数是 boolean 条件,只有条件成立时才会把对应片段拼进 SQL,这就是 MyBatis-Plus 做动态查询的优雅之处,完全不需要自己写 XML 里的<if>标签。
但多表连接查询我还是建议写原生 SQL。比如统计各科室门急诊量,用 LambdaQueryWrapper 也能实现,但 GROUP BY 聚合在 XML 里可读性更好:
<select id="countVisitsByDept" resultType="java.util.Map"> SELECT d.dept_name AS deptName, COUNT(a.id) AS visitCount FROM appointment a INNER JOIN schedule s ON a.schedule_id = s.id INNER JOIN department d ON s.department_id = d.id WHERE a.visit_date BETWEEN #{startDate} AND #{endDate} AND a.status = 1 GROUP BY d.id, d.dept_name ORDER BY visitCount DESC </select>经验是:MyBatis-Plus 只管单表操作,多表关联统计交给 XML 更清晰。Controller 里调用自定义方法,只需要在 Mapper 接口上声明方法签名,XML 文件里组合好 namespace 和 id。
3.3 核心业务接口实现
预约挂号接口是整个系统业务重心的体现,涉及库存扣减和并发控制:
@Override @Transactional(rollbackFor = Exception.class) public boolean book(AppointmentDTO dto) { // 1. 校验排班是否存在 Schedule schedule = scheduleService.getById(dto.getScheduleId()); if (schedule == null || schedule.getStatus() == 1) { throw new BizException("排班不存在或已停诊"); } // 2. 校验号源 if (schedule.getUsedCount() >= schedule.getTotalCount()) { throw new BizException("号源已满,请选择其他时段"); } // 3. 乐观锁扣减号源 int rows = scheduleMapper.increaseUsedCount(schedule.getId(), schedule.getVersion()); if (rows == 0) { throw new BizException("当前预约人数较多,请重试"); } // 4. 插入预约记录 Appointment appointment = new Appointment(); appointment.setScheduleId(dto.getScheduleId()); appointment.setDoctorId(schedule.getDoctorId()); appointment.setDepartmentId(schedule.getDepartmentId()); appointment.setPatientName(dto.getPatientName()); appointment.setPatientIdCard(dto.getPatientIdCard()); appointment.setPhone(dto.getPhone()); appointment.setVisitDate(schedule.getWorkDate()); appointment.setPeriod(schedule.getPeriod()); appointment.setStatus(0); // 0-待就诊 appointmentService.save(appointment); return true; }方法标注了 @Transactional,保证扣减号源和插入预约记录在同一事务里。扣减号源对应的 SQL 是带乐观锁条件的更新:
UPDATE schedule SET used_count = used_count + 1, version = version + 1 WHERE id = #{id} AND version = #{version}这个方案可以避免不加锁导致的超卖。如果业务量特别大,也可以换成悲观锁SELECT ... FOR UPDATE,但医院门诊的大多数场景,乐观锁性能更好,冲突时提示用户重试即可。
事务这里要强调一个细节:Spring 的 @Transactional 默认只在 RuntimeException 时回滚,如果你抛的是检查型异常,事务不会自动回滚。所以 rollbackFor = Exception.class 这个参数建议写上,稳妥。业务异常 BizException 通常继承 RuntimeException,这样写也能保证业务校验失败时不产生脏数据。
4. 前端 Vue3 项目实战
4.1 项目脚手架与目录规范
前端用 Vite 创建项目,一条命令搞定:
npm create vite@latest hospital-web -- --template vue项目生成后我建议立刻做一次目录整理,把默认样式清掉,改成下面这套结构:
src/ ├── api/ # 接口请求模块 │ ├── doctor.js │ ├── schedule.js │ └── appointment.js ├── components/ # 通用组件 ├── views/ # 页面 │ ├── Login.vue │ ├── Dashboard.vue │ ├── department/ │ ├── doctor/ │ └── schedule/ ├── router/ # 路由配置 ├── stores/ # Pinia 状态仓库 ├── utils/ # 工具函数 └── App.vue这套目录的核心思想是"页面归页面、接口归接口、状态归状态"。每个业务模块对应一个 api 文件,views 按业务模块建子目录。项目变大以后找人改代码,不会出现所有接口塞在一个 request.js 里的情况。
路由配置要加登录拦截。用 vue-router 的全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.public) { next() } else if (!token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })这个守卫的逻辑很直白:公开页面直接放行;访问其他页面时检查 token,没有 token 就踢回登录页,并把原始目标路径放到 query 里,登录成功后跳转回原来的页面。状态管理我用 Pinia,因为它是 Vue3 官方推荐的方案,对 TypeScript 支持更好,写法更简洁。登录接口返回 token 后存入 localStorage,用户信息放进 Pinia,后续组件里随时读取。
4.2 登录鉴权与请求封装
axios 封装是前端的基础设施。通常建一个 utils/request.js,把 baseURL、拦截器、统一错误处理集中在这里:
import axios from 'axios' import { ElMessage } from 'element-plus' const service = axios.create({ baseURL: '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } if (res.code === 401) { localStorage.removeItem('token') window.location.href = '/login' return Promise.reject(new Error('登录已过期')) } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message || '请求失败')) }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )统一处理的好处是业务页面里不用每个请求都重复判断状态码和弹错误提示。一个细节:拦截器里判断的是后端返回的业务码 code,不是 HTTP 状态码。如果你的后端用的是 RESTful 风格,同时用 HTTP 状态码和业务码,那需要对两个维度都做兼容,不然部分错误会跑到 error 回调里被误判成网络异常。
具体模块的接口直接导出一个函数集合,比如医生模块:
import request from '@/utils/request' export function getDoctorPage(data) { return request({ url: '/doctor/page', method: 'get', params: data }) } export function saveDoctor(data) { return request({ url: '/doctor', method: 'post', data }) }这样页面里 import 对应函数直接用即可,接口 URL 统一收敛在 api 目录,改后端地址时不用翻业务页面。
4.3 核心页面交互实现
管理系统页面的形态高度类似:顶部搜索区、中间表格、右侧或底部弹窗表单。用 Element Plus 的 el-table、el-form、el-dialog 就能把骨架搭起来。
以医生管理页为例,模板结构大致如下:
<template> <el-card> <div class="search-bar"> <el-input v-model="queryForm.keyword" placeholder="姓名/职称" clearable /> <el-select v-model="queryForm.departmentId" placeholder="所属科室" clearable> <el-option v-for="item in deptList" :key="item.id" :label="item.deptName" :value="item.id" /> </el-select> <el-button type="primary" @click="loadData">查询</el-button> <el-button @click="resetQuery">重置</el-button> </div> <el-table :data="tableData" v-loading="loading" border stripe> <el-table-column prop="name" label="姓名" width="120" /> <el-table-column prop="title" label="职称" width="120" /> <el-table-column prop="deptName" label="科室" /> <el-table-column label="操作" width="160" fixed="right"> <template #default="{ row }"> <el-button link type="primary" @click="openEdit(row)">编辑</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="queryForm.pageNum" v-model:page-size="queryForm.pageSize" :total="total" @current-change="loadData" /> </el-card> </template>操作按钮建议根据角色权限加 v-if 控制,普通用户看到"删除"按钮点了也只会得到 403,界面体验不好。按钮级权限可以用自定义指令,也可以用简单的 v-if 从用户信息里判断,不需要引入重型权限框架。
弹窗表单我推荐把 el-dialog 和 el-form 单独封装成一个组件,通过 v-model 控制显隐。原因是管理系统的表单校验规则往往很长,单独文件可读性更好,复用时传 props 就行:
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> <el-form-item label="姓名" prop="name"> <el-input v-model="form.name" /> </el-form-item> <el-form-item label="职称" prop="title"> <el-select v-model="form.title"> <el-option label="主任医师" value="主任医师" /> <el-option label="副主任医师" value="副主任医师" /> <el-option label="主治医师" value="主治医师" /> </el-select> </el-form-item> </el-form>rules 里用 async-validator 的标准写法,比如姓名必填、手机号正则匹配。日期选择器如果涉及排班,需要加 disabled-date 禁止选择过去日期,这种细节很影响使用体验。
排班页面是 Vue3 组合式 API 发挥价值的地方。你可以用 reactive 维护一个周排班表格,每行是一个医生,每列是一个日期时段,修改某个单元格时调用接口保存。这种二维数据的更新逻辑用 Vue3 的响应式数据配合钩子函数做,比 Vue2 时代清爽很多,代码行数能少一半。
5. MySQL 8.0 配置与部署联调
5.1 MySQL 8.0 环境要点
MySQL 8.0 的安装本身不难,难点通常在安装之后。Windows 推荐用 ZIP 包方式安装,解压后在 my.ini 里配置 basedir 和 datadir,以管理员权限执行mysqld --initialize-insecure初始化,初始密码为空,再执行net start mysql启动服务。这套流程在 Windows 10 上反复验证过,一次成功的概率很高,前提是命令提示符要管理员身份运行。
Linux 或服务器上用 Docker 是最省事的:
docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -e TZ=Asia/Shanghai \ -v /opt/mysql8/data:/var/lib/mysql \ mysql:8.0这里加了 TZ=Asia/Shanghai 环境变量,很多部署问题的根源就是时区。不加这个,容器内系统时区默认是 UTC,比北京时间慢8小时,应用层启动后查时间会有偏差。
初始化脚本的执行顺序建议明确。先建业务库,再按 DDL 建表:
CREATE DATABASE hospital_resource DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;这里要提一个 MyBatis-Plus 用户应该知道的好东西:代码生成器。用 Mybatis-Plus 的 Generator 配置好数据库连接,可以根据表结构自动生成 entity、mapper、service、controller 全套代码。生成的代码虽然不能完全符合你的编码规范,但能把一半的重复劳动省掉,剩下的核心业务逻辑再手写即可。不过要提醒一下,生成代码前一定要把表结构的字段注释写清楚,因为实体类的中文注释就是从数据库字段注释里带出来的。
5.2 前后端联调经验
联调的核心痛点只有两个,一个是跨域,一个是参数格式。
开发环境下,Vite 默认跑在 5173 端口,后端跑在 8080 端口,浏览器访问前端页面时发请求到 8080,会被同源策略拦截。解决办法两个方案。后端加跨域配置是最直接的:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }前端 Vite 配置代理也可以:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }个人习惯是前后端同时配合:开发阶段用 Vite 代理,配置文件跟着代码仓库走,团队每个成员都不用改后端代码;部署阶段用 Nginx 反向代理,前端静态文件和 /api 请求统一交给 Nginx 转发。两个方案互不冲突。
联调阶段最常见的报错是 415 Unsupported Media Type。axios 默认 POST 请求发送的是 JSON 字符串,Content-Type 是 application/json;后端 SpringBoot 接收 JSON 参数必须在方法参数上标注 @RequestBody。如果你的 Controller 参数是普通对象或者 @RequestParam,请求就 415。排查这种问题先打开浏览器网络面板,看请求头到底发的什么格式,再定位后端参数接收方式,不要乱猜。
6. 开发过程中的坑与实战避坑指南
6.1 后端常见坑
第一个坑:MySQL 8.0 驱动导致的连接报错。用旧版本 JDBC 驱动连接 MySQL 8.0,大概率报 Public Key Retrieval is not allowed 或者 SSLHandshakeException。解决办法是在 JDBC URL 上加allowPublicKeyRetrieval=true和useSSL=false,同时把驱动升级到 mysql-connector-java 8.x。开发环境直接关掉 SSL,省心。
第二个坑:SpringBoot2 和 MyBatis-Plus 的版本匹配。3.5.1 之后的 MyBatis-Plus 拆出了 mybatis-plus-spring-boot3-starter 给 SpringBoot3 用,SpringBoot2 项目必须继续引入 mybatis-plus-boot-starter。如果从网上复制代码时不小心用了 boot3 的 starter,启动会直接报版本冲突。这个报错比较明显,但很多人还是会忽略 maven 的依赖树分析。
第三个坑:逻辑删除字段和唯一索引冲突。我在排班表上建了唯一索引 uk_doc_date_period,但逻辑删除只是把记录更新成 deleted=1,并不会物理删除。如果同一天同一个时段再次创建排班,唯一索引里 doctor_id、work_date、period 三个字段完全相等,历史逻辑删除记录还在,新的插入会被唯一索引挡掉。解决办法有两种:一是把 deleted 字段也纳入唯一索引,同时让每次删除的 deleted 值不同,比如删除时把 deleted 更新为当前记录 ID 或时间戳,这样历史删除记录和新增记录在索引维度上不会冲突;二是干脆去掉唯一索引,只在应用层做排班冲突校验。按我的实践,方案一更稳,数据库层面的防线不能丢。
第四个坑:Spring 事务不生效。很多人把 @Transactional 加在 Service 接口上,但调用发生在同一个类的内部方法,事务不会生效。Spring 事务默认基于 AOP 代理,只有通过代理对象调用才会进入事务逻辑,同类内部调用绕过了代理。推荐做法是把事务注解加到实现类的方法上,并且不要在同类内部直接调用带事务的方法,而是拆到另外一个 Service 类里。
6.2 前端常见坑
Vue3 的响应式虽然比 Vue2 聪明,但陷阱也不少。ref 和 reactive 用混的时候,页面不更新是最常见的问题。经验法则是:基础类型用 ref,对象类型用 reactive;复杂场景宁可全用 ref,因为 ref 在模板里会自动解包。如果你把 reactive 对象直接赋值给另一个变量,或者从数组里取出某个对象再修改属性,容易出现响应性丢失,页面上看起来数据改了但 UI 不刷新。排查时先在控制台打印一下修改后的值,确认数据变了再考虑渲染层面的问题。
路由重复跳转报错在 Vue Router 4 里很常见。连续点击同一个菜单,或者进入详情页之后再次点同一个导航,会抛 NavigationDuplicated 错误。虽然功能不受影响,但控制台一堆红色报错影响排查体验。解决方法是给 router.push 包一层 catch,比如router.push(url).catch(() => {}),让重复导航静默处理。
Element Plus 的 el-select 回显问题同样高频。当 v-model 绑定的值在 options 列表里找不到对应项时,选中的文本会直接显示成原始 value 而不是 label。典型场景是编辑弹窗打开时,options 是异步加载的,而绑定值已经赋值,组件 props 里没有匹配项。解决办法是编辑打开时先加载 options 再赋值选中值,或者用 el-select 的 filterable 属性让组件能按已绑定值回显。
还有一个容易被忽略的点是 Vue3 的 v-model 在组件上默认监听的是 modelValue/update:modelValue 事件。如果你习惯了 Vue2 的 .sync 写法,在封装弹窗组件时容易把 prop 名写错。组件内部用defineProps(['modelValue'])接收,defineEmits(['update:modelValue'])触发,父组件写v-model="visible"就对了。
6.3 部署环境坑
部署阶段的坑最磨人。一个典型场景是本地测试正常,部署到服务器后很多接口报 SQL 语法错误或者字符乱码。排查后发现是服务器的 MySQL 字符集没有设为 utf8mb4,默认 latin1 导致中文乱码。解决办法是修改 my.cnf 配置文件,设置character-set-server=utf8mb4和collation-server=utf8mb4_general_ci,重启 MySQL 服务后重建数据库。
另一个经典问题是服务器防火墙和云安全组忘开端口。后端 8080 端口、前端 80 端口,本地测试通了部署到云服务器,浏览器访问超时,十有八九是安全组没放通端口。排查顺序建议先看云服务商安全组规则,再查服务器防火墙,最后看应用是否有监听异常,不要一上来就怀疑代码。
Nginx 部署 Vue3 项目时有一个几乎必踩的坑:vue-router 用的是 HTML5 History 模式,刷新页面时 Nginx 会返回 404,因为 Nginx 找不到 /doctor 这个物理路径。你必须加上这一行:
location / { try_files $uri $uri/ /index.html; }请求路径匹配不到物理文件时,就回退到 index.html,交给前端路由去处理。几乎所有的 Vue 单页应用部署都要加这个配置,不加就会遇到"刷新页面白屏"的经典问题。
部署完开始维护的时候,文档的价值就凸显了。我在交付这类系统时,会强制要求把数据库设计说明书、接口文档、部署手册三样东西整理好,和源码放一起。数据库设计说明书里写清楚每张表的业务含义和状态字段的枚举值,接口文档用 OpenAPI 规范维护,部署手册从 JDK 安装开始记录每个步骤。因为半年之后你自己回头看代码,有些表为什么要这么设计,可能也想不起来了,有文档兜底,无论是自己接手还是交给别人,都从容得多。这个项目的源码配套文档正好做了这件事,这也是我在拿到一份系统源码时最看重的部分。
最后再多说一句题外话。技术选型、代码实现都是可以复制的,真正拉开差距的是对业务的理解和细节上的坚持。一个排班冲突校验、一个号源扣减的并发控制、一个时区问题的处理,这些点滴细节才是让你从"会写 CRUD"走向"能交付系统"的关键。希望这套项目能成为你迈过这道坎的台阶。