简介:这是一份基于Spring Boot与Vue技术栈开发的会议室预约系统完整项目源码,面向计算机相关专业课程设计、毕业设计及需要快速搭建企业级预约管理场景的开发者。项目包含前后端分离实现:后端以Java类、XML配置、SQL脚本和YML配置文件为主,涵盖用户信息管理、会议室信息管理、预约信息管理及账户权限等核心模块;前端由JS、CSS、HTML和JSP页面组成,同时附带ECharts可视化图表模块,便于统计会议室使用数据。资源共1049个文件,除主要源码与页面资源外还包括GIF演示动画、PNG/JPG图标素材及字体文件,压缩包整体大小仅17.73MB,结构清晰、开箱即用。目前已有407人学习使用,适合希望直接运行学习、二次扩展或借鉴前后端交互设计的开发者。压缩包内同时提供数据库脚本与核心业务逻辑类,能帮助使用者快速理解预约冲突检测、权限控制等关键实现思路。
1. 会议室预约系统是什么:Spring Boot + Vue 这套组合能解决什么
公司里订会议室靠Excel、靠群里吼,时间一撞就扯皮。会议室预约系统要解决的就是这个:把“谁在哪段时间占用了哪个会议室”变成一个可查询、可约束的在线状态。Spring Boot负责提供预约接口和冲突校验,Vue负责把日历、表单、列表这些交互摆到用户面前。这套前后端分离结构是Java方向最常见的毕业设计组合,也适合后端新手完整走一遍“表设计→接口→联调→部署”的闭环。
如果你手里拿到的是带.zip结尾的源码包,先别急着在IDE里打开。这类包通常包含前端目录、后端目录和SQL脚本,第一步是把环境对齐:后端是Maven工程,前端是Vue工程,数据库要手动导入。后面几章按后端、前端、部署这条链路拆开讲,包括参数怎么设、坑在哪儿,照着做就能跑起来。
2. 后端落地:Spring Boot 里把预约核心逻辑做对
后端部分最值得花时间的是两件事,一件是预约不冲突,另一件是接口能安全地用。会议室预约和购物车不一样,它有时间边界和状态流转,表结构和校验逻辑一旦设计错,后面的接口全要返工。所以这一章从数据模型说起,再到接口实现和配置。
2.1 数据模型设计:三张表和字段取舍
我一般把表拆成三张:用户表、会议室表、预约记录表。用户表存账号和密码(BCrypt加密),会议室表存名称、位置、容纳人数、设备,预约记录表存谁在什么时间用了哪个房间,以及当前状态。预约记录表是核心,字段至少要有id、user_id、room_id、start_time、end_time、status、remark和create_time。status用数字表示,1待确认、2已确认、3已取消,学生项目里两个状态也够用。
字段类型上,时间段我建议用datetime,不要拆成“日期+开始时间+结束时间”三个字段。拆开在一些简单查询里看起来直观,但做跨天查询和月度视图时反而要拼字符串,SQL写起来别扭。datetime可以直接用比较运算符和BETWEEN,月份视图查询也简洁。下面是常见建表SQL,注意预约表里我把(room_id, start_time, end_time)做成普通索引,后面2.2会解释为什么不用它做唯一约束。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), role TINYINT DEFAULT 1 COMMENT '1-普通用户 2-管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE meeting_room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_name VARCHAR(100) NOT NULL, location VARCHAR(200), capacity INT, equipment VARCHAR(500) COMMENT '投影、白板,逗号分隔', status TINYINT DEFAULT 1 COMMENT '1-可用 0-停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, room_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 1 COMMENT '1-待确认 2-已确认 3-已取消', remark VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_room_time (room_id, start_time, end_time), CONSTRAINT fk_res_user FOREIGN KEY (user_id) REFERENCES sys_user(id), CONSTRAINT fk_res_room FOREIGN KEY (room_id) REFERENCES meeting_room(id) );这段SQL里的两个设计细节值得展开。第一,equipment用字符串存设备列表,列表页展示没问题,但后续要做“按设备筛选会议室”的查询,就要考虑拆关联表或JSON类型。中小型场景用字符串够用,别急着上关联表,维护成本会高一截。第二,role用TINYINT而不是字符串,是为了避免角色名散落在代码里,前端根据role决定显示“新增会议室”还是“只看预约记录”,后端在拦截器校验接口权限时判断数字也比判断字符串可靠。业务表里有软删除需求的话,再考虑加deleted字段,这个系统可以不加。
2.2 预约冲突校验:重叠判断和并发双保险
预约接口的核心是“这段空闲时间还空不空”。时间段重叠的判断是一个经典条件:新预约的start_time小于已有预约的end_time,同时新预约的end_time大于已有预约的start_time。用一句话记就是“新的开始早于旧的结束,新的结束晚于旧的开始”,两个条件同时成立就说明时间交叉了。
-- 在新增预约前,查同一会议室是否存在重叠记录 -- end_time > newStart 表示旧预约还没结束 -- start_time < newEnd 表示旧预约已经开始 -- 同时过滤掉已取消(status=3)的记录 SELECT COUNT(*) FROM reservation WHERE room_id = #{roomId} AND status != 3 AND start_time < #{endTime} AND end_time > #{startTime}这个条件覆盖三种情况:新预约完全在旧预约中间、旧预约完全在新预约中间、两者首尾部分交叉。唯一不重叠的情况只有“旧的结束<=新的开始”或“旧的开始>=新的结束”,上面两个不等号刚好排除这两种。边界值我一般按“半开区间”处理,结束时间等于开始时间的预约视为前后衔接不冲突,数据库里存的就是时间段,逻辑统一即可。
对应到Service,我会把参数校验、冲突检查、插入放在同一个事务里,保证中间任何一步抛异常都不会留下半截数据。代码大致如下:
@Transactional(rollbackFor = Exception.class) public Reservation createReservation(ReservationDTO dto) { // 1. 参数校验:开始时间必须小于结束时间,不能预约过去 if (dto.getStartTime().isAfter(dto.getEndTime())) { throw new BusinessException("开始时间不能晚于结束时间"); } if (dto.getStartTime().isBefore(LocalDateTime.now())) { throw new BusinessException("不能预约过去的时间"); } // 2. 冲突检查:查到 count > 0 直接拒绝 int conflict = reservationMapper.checkConflict( dto.getRoomId(), dto.getStartTime(), dto.getEndTime()); if (conflict > 0) { throw new BusinessException("该时间段已被预约"); } // 3. 落库,默认待确认状态 Reservation reservation = new Reservation(); BeanUtils.copyProperties(dto, reservation); reservation.setStatus(1); reservationMapper.insert(reservation); return reservation; }这里有几个参数需要说明。dto.getStartTime()用LocalDateTime接收,只要前端传的字符串格式和后端@JsonFormat对齐就不会解析失败,具体格式问题在4.5单独讲。BeanUtils.copyProperties要求字段名一致,DTO里不要包含id、createTime这类数据库生成字段,避免从前端把不该有的值拷进去。@Transactional指定rollbackFor=Exception.class是因为Spring默认只回滚RuntimeException,如果BusinessException继承的是Exception,不加这句话事务不会回滚,这条血泪经验很容易被忽视。
上面这个写法在单线程下没问题,并发时两个请求会同时通过查重,最终库里出现重叠记录。这个坑的表现在4.2专门展开,这里先记住:查询条件正确只是第一层保护,并发场景要靠数据库锁或唯一约束兜底。
2.3 springboot配置:CORS、JSON序列化和拦截器
前后端分开跑时,跨域是第一个要解决的问题。后端跑8080,前端Vite跑5173,浏览器会拦截跨域请求。我的习惯是实现WebMvcConfigurer统一配置,比在每个Controller上贴@CrossOrigin干净,也方便后续接权限拦截器时调整顺序:
@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); } }注意allowedOriginPatterns和allowedOrigins的区别:allowCredentials(true)时,allowedOrigins不能写“*”,要么写具体域名,要么用allowedOriginPatterns配模式。很多人在这里报错Cannot allow credentials for wildcard origin,就是这两行搭配错了。maxAge设3600是为了让浏览器缓存预检结果,减少OPTIONS请求次数,但改了跨域配置后记得清浏览器缓存再试,否则改了半天还是旧结果。
JWT鉴权我用HandlerInterceptor做登录校验,放行登录接口和预检请求。拦截器里先从Authorization头取出token,解析失败就返回401,成功就把用户id塞进request attribute,Controller里用@RequestAttribute("userId")取当前用户。拦截器注册时注意:excludePathPatterns里要包含/login和静态资源,前端预检的OPTIONS请求也要放行,否则CORS配置还没执行就被拦截器挡了。
application.yml里还有三个容易出问题的配置项。第一是日期格式,前端传“2025-06-01 09:00:00”这种字符串,后端要用@JsonFormat或全局配置统一格式,不然LocalDateTime解析直接报错。第二是MyBatis-Plus的分页插件,要用PaginationInnerInterceptor注册,否则分页查询会把全部数据查出来。第三是服务器端口,IDE里调试时可以在启动配置里直接改端口,不用改yml,但要注意yml和启动配置哪个生效。另外,pom.xml里Spring Boot版本如果选的是3.x,要求JDK 17起,本机是JDK 8时编译直接失败,先把版本对好再排查代码。
3. 前端落地:Vue 3 从路由到预约表单的完整链路
后端接口就绪后,前端要解决的是让用户顺手完成“登录→看列表→选时间→预约→看自己的记录”这条链路。Vue 3 + Vite + Element Plus是目前最常用的组合,组件现成,重点是路由设计和请求封装。拿到前端源码包先别急着改代码,第一步永远是npm install把依赖装上,node_modules没就绪,后面所有报错都是噪音。装依赖时注意Node版本,Vite 5要求Node 18+,版本太低启动就报错。
3.1 Vue 项目结构和路由设计:登录守卫与页面跳转
项目结构保持官方脚手架默认的src布局,api目录单独放接口调用,router目录放路由表,views按页面放。路由分为公开路由和需要登录的路由两类,用vue-router的beforeEach守卫做登录拦截:
// router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/', component: () => import('@/layout/index.vue'), redirect: '/rooms', children: [ { path: 'rooms', component: () => import('@/views/RoomList.vue') }, { path: 'reserve', component: () => import('@/views/ReserveForm.vue') }, { path: 'my', component: () => import('@/views/MyReservations.vue') } ] } ] const router = createRouter({ history: createWebHistory(), routes }) // 导航守卫:没有 token 就踢回登录页 router.beforeEach((to) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { return '/login' } return true })routes里的component用箭头函数懒加载,好处是首页只加载登录页代码,进系统后再按路由加载对应页面,打包文件会按路由自动拆成chunk,首次打开更快。createWebHistory是history模式,地址栏干净但部署时要处理后端404,不想处理就直接换createWebHashHistory,地址栏多个#,部署省心。会议室预约系统的菜单只有两三层,用静态路由加条件渲染就够,没必要为了“动态路由”硬上addRoute,路由重复添加的坑反而更多。
会议室列表页用el-card展示房间信息,卡片底部放“预约”和“详情”按钮,操作区用Vue插槽扩展比写死按钮灵活。管理员角色在卡片上多一个“编辑”按钮,根据用户role在v-if里判断就行。这里别把权限逻辑揉进路由表,页面级的按钮显隐用角色判断最简单。
3.2 预约表单的时间选择:dayjs处理日期范围
预约表单里最容易被忽视的是时间控件。用原生Date做比较,时区、格式、夏令时这些历史包袱很容易砸手。常见做法是装dayjs,体积小,接口和moment几乎一样:
import dayjs from 'dayjs' // 预约表单提交前,把时间统一成 YYYY-MM-DD HH:mm:ss 字符串 function formatTime(date) { return dayjs(date).format('YYYY-MM-DD HH:mm:ss') } // 校验:开始时间必须早于结束时间,开始时间不能是过去 function validateTime(start, end) { if (!start || !end) return '请选择完整的预约时间段' if (dayjs(start).isAfter(dayjs(end))) return '开始时间不能晚于结束时间' if (dayjs(start).isBefore(dayjs())) return '不能预约过去的时间' return '' }Element Plus的el-date-picker配type="datetimerange"可以直接选中开始和结束两个值,配合上面这段校验,表单提交链路就完整了。提交时把dayjs格式化后的字符串传给后端,后端LocalDateTime接收,两边格式约定一致就不会出4.5里那种解析错误。dayjs的isBefore(dayjs())判断过去时间时,注意dayjs()取的是当前时刻,用户选了今天上午但当前已是下午,也会被拦,这个行为符合“不能预约过去的时间”的业务约定。
时间段粒度会影响冲突判断的复杂度。按小时预约,start_time和end_time都是整点,后端判断最简单;支持半小时,冲突判断本身没变,界面要体现半小时格。见过有人把粒度做到15分钟,界面好看,但真没多少会议室这么用,冲突率高,用户反复调整反而体验差。半天和全天这种粒度不适合做成表单,更适合在会议室卡片上直接放“上午/下午”按钮,后端把时间范围计算好存进去,别让用户自己填时间。
3.3 axios 封装和路由参数:预约流程的标准姿势
每个页面直接调axios会很难维护,我会在src/api/request.js里封装一个实例,统一处理baseURL、超时时间、token注入和错误提示:
// src/api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' const service = axios.create({ baseURL: '/api', // 开发环境用 vite proxy 转发,部署后由后端收口 timeout: 10000 }) // 请求拦截器:带上 token service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:401 跳登录,业务错误弹提示 service.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } else { ElMessage.error(error.response?.data?.message || '请求失败') } return Promise.reject(error) } )baseURL写“/api”而不是写死后端地址,是为了开发环境下用Vite的proxy把/api转发到127.0.0.1:8080,部署后由Spring Boot或Nginx收口。这样代码里不出现具体IP,换环境只改配置。这个细节很多人没注意,前端写死127.0.0.1:8080,打包发给别人就不能用了。响应拦截器里response => response.data这一步,是把axios包装的response对象拆掉,后面业务代码只需要拿data;如果后端返回的本身就是包装类的JSON,这里要按结构取,别把包装对象丢掉。
从会议室列表跳预约页,参数传递选query最简单:this.$router.push({ path: '/reserve', query: { roomId: room.id } }),页面用route.query.roomId取。用pinia或sessionStorage传参数,刷新页面就丢,还得在onMounted里补“没参数就跳回列表”的判断。路由参数传对象则要做序列化,处理起来麻烦。我的原则是:能放query的就不放store,能让页面自己取数据的就不靠别的页面传。预约记录列表里,默认只展开第一条、其余折叠显示详情,用el-collapse就能实现,这个交互在“我的预约”页很常见。
4. 避坑清单:会议室预约系统最容易踩的5个坑
这一章是血泪经验。下面这些坑在调试这类项目时反复出现,每条按现象、原因、解决的顺序写,遇到同款问题直接对应着改。
4.1 时区问题:预约时间莫名偏移8小时
现象:前端页面显示预约时间9:00,数据库里存的是1:00,或者反过来,用户和管理员看到的对不上。
原因:前端new Date()按浏览器本地时区生成时间,如果传给后端的是带时区的ISO字符串,后端LocalDateTime解析时把UTC时间当北京时间存了。另一种情况是后端服务器时区不是Asia/Shanghai,JDBC连接串也没加serverTimezone参数,驱动按默认时区转换了一次。
解决:前后端约定只传“YYYY-MM-DD HH:mm:ss”这种无时区的字符串,前端用dayjs固定格式,后端用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")接收。数据库连接串加serverTimezone=Asia/Shanghai,MySQL的time_zone设为+8:00。三处对齐,单时区场景里时区问题不会再出现。排查这类问题时,先在数据库里select now()看MySQL自身时间对不对,再排除是不是查询工具显示层的时区转换,很多“偏移8小时”其实是Navicat按本机时区转换显示的结果,数据本身是对的。
4.2 并发抢约:两个请求都通过了冲突检查
现象:两个用户同时提交同一会议室同一时间段,两个请求都过了checkConflict,最终库里出现两条重叠记录。
原因:问题出在“先查后插”不是原子操作。两个请求并发执行时,请求A查完没有重叠,请求B查完也没有重叠,然后A插入、B插入,中间没有互斥。synchronized加在service方法上没意义,它只锁当前JVM实例内的方法,多实例部署时锁不住。
解决:数据库层兜底。常见做法是给会议室行加锁,在同一个事务里先执行SELECT id FROM meeting_room WHERE id=? FOR UPDATE锁住对应行,再查重、插入。锁的粒度是会议室,不是整张表,其他会议室预约不受影响。如果预约流程还要支持“按时间段模糊找任意空闲会议室”这类高级玩法,再考虑Redis分布式锁,会议室预约这个场景没必要。还有一种思路是把时间段转成固定格式字符串列,比如把2025-06-01 09:00-10:00存入一个time_slot列并加唯一索引,由数据库挡住重复插入,做法简单但灵活性差,不适合需要精准冲突判断的系统。
4.3 打包后刷新404:Vue history模式部署翻车
现象:本地npm run dev一切正常,npm run build后把dist目录放进Spring Boot的static目录,启动后访问首页正常,在预约页按F5刷新报404。部署到Nginx也一样。
原因:history模式下访问/rooms时会向服务器发真实请求,服务器找不到这个路径就返回404。开发环境有Vite的dev server兜底,部署后没有。
解决:最省事是前端路由改hash模式。坚持history模式的话,Spring Boot要加转发规则,把非API和非静态资源的路径转发到index.html;Nginx里常见做法是try_files $uri $uri/ /index.html。我的习惯是:前端打包放进Spring Boot就选hash,避免服务器端多一道配置。改hash模式只把createWebHistory调用换成createWebHashHistory,routes不用动,重启前端就能生效。
4.4 CORS配置看着对但就是报跨域
现象:前后端都配了跨域,浏览器控制台还是报Access-Control-Allow-Origin缺失。后端明明写了允许全部来源,前端还是被拦。
原因:最常见三种。一是前端配置了Vite proxy但axios的baseURL还写后端地址,请求没走代理,是真实跨域;二是Spring Boot的JWT拦截器先于CORS配置返回了响应,预检请求直接401,跨域头根本没机会加;三是上线接Nginx后,Nginx层没加跨域头。
解决:开发环境统一走/api前缀加Vite proxy代理,前端代码里不出现后端IP。后端CORS配置写在WebMvcConfigurer里,拦截器放行OPTIONS预检请求。上线用Nginx的话,在location块加add_header Access-Control-Allow-Origin *; 并处理OPTIONS。排查时用浏览器F12看请求头:请求发到了哪个地址、响应头里有没有跨域字段、哪个网络层把请求拦了,三步就能定位,比盲改要快。Vite proxy配置在vite.config.js的server.proxy里,target写后端地址,changeOrigin设为true,改完要重启dev server才生效。
4.5 日期时间字符串的隐式转换坑
现象:前端传startTime为“2025-06-01 09:30”这种字符串,后端接口用LocalDateTime接收,报400,日志有JSON parse error。
原因:前端传的字符串格式和后端期望不一致。@JsonFormat默认按ISO标准解析,带空格的格式解析不了。更隐蔽的是前端用toLocaleString()生成“2025/6/1 09:30:00”这种带斜杠的,后端也解析不了。
解决:前端统一用dayjs的“YYYY-MM-DD HH:mm:ss”格式生成字符串;后端全局配置Jackson的日期格式,在application.yml里设置spring.jackson.date-format和time-zone。校验逻辑里再补一道“结束时间必须晚于开始时间”,防止用户手输一个框架解析不了的格式直接被拦截,报错不友好。这个坑和4.1是同一个根源:前后端没有事先约定时间格式,各按各的来。在接口文档里把时间格式写死,比在代码里到处适配要省心得多。
5. 验证与进阶:预约核心链路跑通之后还能做什么
项目能编译能启动只是开始,预约核心链路要真验证过。我一般会按“登录→查空房间→提交预约→查我的预约→取消预约”走一遍,每步看一眼数据库状态。用curl在服务器上验证最快:
# 1. 登录拿 token TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}' | jq -r '.data.token') # 2. 提交预约:房间1,明天10点到11点 curl -X POST http://localhost:8080/api/reservation \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"roomId":1,"startTime":"2025-06-10 10:00:00","endTime":"2025-06-10 11:00:00"}'登录接口返回的token需要jq这个命令行工具提取,没装就手动从响应里拷贝。命令行验证的重点是“同一时间段提交第二次必须返回冲突”:第一次提交成功后立刻再执行一次相同的curl,如果返回“该时间段已被预约”,说明查重逻辑生效。两个并发请求同时提交的情况,则要看4.2说的数据库锁是否落到位。
验证通过后再开前端页面,如果还有问题,问题就锁定在前端展示或传参,而不是后端接口。跑通之后想进阶,三个方向按难度递增:会议室信息Excel导入,省去管理端逐一录入;预约成功发邮件通知参会人,把“预约-通知”闭环补完整;前端加WebSocket或轮询,别人预约成功后页面日历自动刷新。这三个方向分别练到导入导出、JavaMail、WebSocket,写在简历上比“实现了CRUD”有说服力。
会议室预约系统的核心从来不是页面多漂亮,而是状态的并发和一致性。后端冲突校验做了数据库层兜底,前端时间格式统一,部署模式选对,这套系统就能按上线标准去要求。我的习惯是收尾前把4.1到4.5的检查清单过一遍,省得部署时才发觉时区没对齐、路由模式没选对这类低级问题。希望帮到你。
本文还有配套的精品资源,点击获取