
做“基于Vue的自习室预约系统”这个项目前后花了我大概三周业余时间。第一版用Vue2 Element UI练手第二版整体重构到Vue3 Vite Pinia Spring Boot中间踩过的坑不少但也正因为踩过这套系统的完整实现思路、技术选型理由和几个关键模块的写法我都已经摸得很透。如果你正想做一个能写进简历、能应付课程设计、又能真正上线使用的全栈项目这篇内容可以直接照着抄作业。先交代一下背景。自习室预约系统核心解决的是“座位资源有限、使用时间不固定、人工管理效率低”这三个痛点。用户需要能在线看到哪些座位空闲、哪些时段可约提交预约后系统要保证同一时段同一座位不被重复占用管理员要能查看所有预约记录、管理自习室和座位信息。在这个项目里我选择Vue作为前端框架后端用Spring Boot提供接口数据库用MySQL存储用户、座位、预约记录。整个过程我能拆成四个部分来讲项目设计与技术选型、Vue工程搭建、核心预约流程实现、以及联调部署阶段的排查实录。1. 项目整体设计与技术选型思路1.1 自习室预约系统到底要做什么做一个管理系统第一件事不是写代码而是把需求边界画清楚。我这个系统面向两类角色普通用户和管理员。普通用户在系统里要能注册登录、浏览自习室列表、查看座位实时状态、选择具体日期和时段进行预约、查看自己的预约记录、在开课前或开场前取消预约。管理员则要能维护自习室和座位信息、查看全部预约流水、手动关闭某个座位、统计某个时段的使用率。这个边界一旦画清楚前端页面和后台接口的拆分就顺理成章。很多人拿到这种题目就直接开写登录注册、座位管理结果写着写着发现功能互相纠缠。我的建议是先列一张功能清单然后按模块拆分到最小页面单元。以我最终版本为例前端一共有6个主要页面登录页、注册页、自习室列表页、座位预约页、我的预约页、管理后台页。每个页面只做一件事路由也能一一对应。1.2 为什么选Vue而不是React或者原生JS这个问题我面试时也经常被问到。选择Vue核心原因有三个上手曲线平缓、生态完整、对前后端分离的工程化支持非常成熟。先说上手曲线。Vue的模板语法对后端同学或者刚从原生JS转过来的新手特别友好v-if、v-for、v-model这类指令几乎不需要额外学习成本。对比React你得先理解JSX和函数式组件的思维模式从“操作DOM”转换到“操作状态”本身就有一定门槛。而Vue的单文件组件把模板、脚本、样式放在一个文件里结构上一目了然。再说生态。Vue Router和Pinia这两套官方状态管理方案配合Element Plus组件库能让我在极短时间内搭出风格统一的后台界面。预约系统里大量用到表格、日期选择器、弹窗、表单校验这些都是Element Plus的强项。如果你用的是原生JS光是写一个可用的日期时间段选择器可能就要花掉一整天。最后是前后端分离。Vue项目通过Vite或Webpack构建后是纯静态资源部署简单后端只需要提供JSON接口。Spring Boot Vue这种组合在中小企业项目里非常常见。用Vue做前端用Spring Boot写接口再用Nginx代理转发整个链路简单、稳定、好排查。1.3 技术栈版本选择与配置说明这里必须提醒一个很多人会忽略的点Vue的版本差异非常大。我第一版用Vue 2.6第二版直接用Vue 3.4两个版本的响应式原理、路由写法、状态管理方案完全不同。如果你搜资料很多老教程还在讲Vue 2的Options API和Vuex 3直接照搬会报各种奇怪错误。我的最终技术栈如下前端框架Vue 3.4 Vite 5开发语言JavaScript部分逻辑抽成组合式函数使用Composition APIUI组件库Element Plus状态管理Pinia路由Vue Router 4HTTP请求Axios后端Spring Boot 2.7 MyBatis Plus数据库MySQL 8.0部署方式前端部署到Nginx后端打包成Jar运行这里重点说一下为什么状态管理选Pinia而不是Vuex。Vuex在Vue 3里虽然也能用但它的API设计明显是Vue 2时代的产物mutations和actions分离这件事在实际开发中让人觉得繁琐——你改一个数据要先去mutations里定义方法再在actions里调用中间绕了一层。Pinia把这两者合并写法上更接近组合式APITypeScript支持也更好。在这个预约系统里我用Pinia管理登录状态和当前自习室信息刷新页面不丢失代码量比Vuex版本少了一半。2. Vue工程搭建与环境配置细节2.1 安装Node环境和Vue脚手架很多新手卡在第一步其实不是不会写代码而是环境没配好。Vue 3项目依赖Node.js我建议安装Node 18以上版本因为Vite 5需要较高的Node版本支持。安装完Node后自带npm但国内下载依赖非常慢建议先配置淘宝镜像。配置命令很简单Windows和Mac通用npm config set registry https://registry.npmmirror.com验证是否生效npm config get registry如果输出的是https://registry.npmmirror.com说明镜像配置成功。这一步非常关键因为后面安装Element Plus、Vue Router、Axios这些依赖如果不配镜像每次都要等几分钟甚至卡死。创建Vue 3项目的标准命令是npm create vuelatest这个命令会进入交互式问答问你要不要TypeScript、Vue Router、Pinia、ESLint等。我的建议是第一次做项目全部选No先跑通基础流程如果已经有经验可以把Vue Router和Pinia选上省得后面手动安装。我第二版重构时就是直接在这个脚手架基础上选择的Router和Pinia。2.2 安装核心依赖与配置文件项目创建完成后进入项目目录安装依赖npm install然后按需安装其他依赖npm install element-plus npm install axios npm install element-plus/icons-vue这里有几个细节容易踩坑。Element Plus如果全局引入打包体积会很大我建议使用自动按需导入的方式。在Vite项目中先安装两个插件npm install -D unplugin-vue-components unplugin-auto-import然后在vite.config.js里配置import { defineConfig } from vite import vue from vitejs/plugin-vue import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这段配置里同时完成了两个任务Element Plus按需自动导入以及开发环境的接口代理。关于代理后面联调部分我会详细讲这里你先记住server.proxy的作用——它让前端开发服务器把/api开头的请求转发到后端的8080端口从而绕过跨域问题。2.3 目录结构与代码规范项目虽然是个“管理系统”但目录结构绝不能乱。我见过很多新手把所有的API请求、工具函数、页面组件全塞进src根目录结果项目一大了连自己都找不到代码。我的目录规划是这样src/ api/ // 所有接口请求封装 assets/ // 静态资源 components/ // 公共组件 router/ // 路由配置 stores/ // Pinia状态管理 utils/ // 工具函数 views/ // 页面组件这个结构的好处是每个模块有自己的固定位置新增功能时知道该往哪里加代码。比如新增一个“查看历史预约”的功能先在views下建页面再在api下加对应的接口函数然后在router里注册路由逻辑非常清晰。3. 核心预约流程数据模型、路由守卫与状态管理3.1 数据库表设计与接口约定预约系统的核心逻辑集中在三张表用户表、座位表、预约记录表。座位表里有个字段叫status用来记录座位的状态0可用、1占用、2禁用。预约记录表是整个系统的核心我设计字段时特别考虑了唯一性约束seat_id date time_slot三个字段联合唯一。为什么必须做联合唯一因为预约操作本质上是“先判断空闲再写入”的流程。如果两个用户同时发起预约请求后端代码先查询该座位是否空闲再插入预约记录这两个操作之间存在时间差就会导致并发冲突。联合唯一索引的作用是在数据库层面兜底即使后端代码判断有误数据库也会拒绝重复插入。这在面试里是一个很好的加分点。表结构简化后如下表名关键字段说明t_userid, username, password, role角色区分普通用户和管理员t_seatid, room_id, seat_no, status座位编号和状态t_reservationid, user_id, seat_id, date, time_slot, statusstatus为0已预约、1已完成、2已取消时间段的处理我采用了一个简单方案一天划分为8个时段每个时段1.5小时用数字1到8表示。前端展示时映射成“08:00-09:30”这样的文本存储和判断时直接用数字比较逻辑更清晰性能也更好。3.2 Vue Router路由配置与动态路由思路路由配置是前端架构的核心。在Vue Router 4里我使用了createRouter和createWebHistory模式。项目包含登录页、注册页、自习室列表页、预约页、我的预约页、管理页。其中管理页必须登录且角色为管理员才能访问。基础路由配置如下import { createRouter, createWebHistory } from vue-router const routes [ { path: /login, name: Login, component: () import(/views/Login.vue), meta: { public: true } }, { path: /rooms, name: Rooms, component: () import(/views/RoomList.vue), meta: { requiresAuth: true } }, { path: /reserve/:roomId, name: Reserve, component: () import(/views/Reserve.vue), meta: { requiresAuth: true } }, { path: /my-reservations, name: MyReservations, component: () import(/views/MyReservations.vue), meta: { requiresAuth: true } }, { path: /admin, name: Admin, component: () import(/views/Admin.vue), meta: { requiresAuth: true, requiresAdmin: true } } ]这里有一个很多人忽略的细节组件使用() import()懒加载。如果不这样写Vue会把所有页面打包进一个巨大的JS文件首次加载会非常慢。按需加载后每个页面独立打包访问什么页面就加载什么页面对应的JS体验好很多。路由守卫我这里分了两层。第一层是全局前置守卫检查白名单和登录状态router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.public) { next() } else if (!token) { next(/login) } else if (to.meta.requiresAdmin !isAdmin()) { next(/rooms) } else { next() } })这段代码的逻辑很直白公开页直接放行没登录的跳登录页普通用户访问管理页直接弹回自习室列表。关于动态路由——如果你想做更复杂的权限系统比如不同角色看到不同菜单、可访问的路由是登录后动态生成的思路是在路由表中只写公共路由登录后根据角色从后端拉取可访问的路由配置用router.addRoute()动态添加。这个系统初期我用的是静态路由加守卫判断已经足够支撑需求。3.3 Pinia状态管理登录态与座位信息共享预约系统有一个典型场景用户从自习室列表页点击某个座位进入预约详情页。这两个页面都需要知道“当前用户是谁”和“当前自习室是哪间”。如果每次跳转都用路由参数传递参数会越来越长而且刷新页面后状态会丢失。解决办法就是把公共状态放到Pinia里。我的Pinia配置如下import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo)) || null }), getters: { isLoggedIn: (state) !!state.token, isAdmin: (state) state.userInfo?.role admin }, actions: { setLogin(data) { this.token data.token this.userInfo data.userInfo localStorage.setItem(token, data.token) localStorage.setItem(userInfo, JSON.stringify(data.userInfo)) }, logout() { this.token this.userInfo null localStorage.removeItem(token) localStorage.removeItem(userInfo) } } })这里我刻意把token和userInfo都同步到localStorage原因之一是刷新页面后状态不能丢失。如果不做这一步用户预约到一半按F5刷新页面里的userStore清空了路由守卫又把他踢回登录页体验极差。座位预约页面需要记录当前选择的自习室、座位、日期、时间段。这部分属于页面内部的临时状态我直接用reactive和ref管理不需要放进Pinia。只有像“当前自习室”这种跨页面共享的信息才需要全局状态管理不要把什么都塞进Pinia那会让状态变得难以追踪。3.4 座位预约的核心逻辑日期、时段与防重复提交预约页是整个系统的精髓。页面上有座位图、日期选择器、时段选择器。用户点击某个座位后座位状态会变为“已选中”选择日期和时段后点击提交按钮向后端发送预约请求。前端提交前需要做三件事校验是否登录、校验是否选择了座位和时段、校验时间不能是过去的时间。这些校验用简单的if判断即可。真正容易出现的问题是“重复提交”——用户连续点击两次提交按钮会产生两条预约记录。解决方案有两种最简单的是在提交后立即给按钮加loading状态并禁用const submitting ref(false) async function handleSubmit() { if (submitting.value) return submitting.value true try { await reserveSeat(...) ElMessage.success(预约成功) } finally { submitting.value false } }第二种方案是用唯一请求编号每次点击生成一个uuid后端判断这个编号是否已经处理过。对于这个项目第一种方案已经够用。但你要知道还有第二种方案因为面试官很可能会问“如何防止用户快速点击多次提交”。后端接口的设计同样要防重复。我的后端预约接口里插入前会先查询当天该座位该时段是否已有预约记录——注意这里查询和插入之间有一定的时间窗口所以仅靠后端接口判断还是有并发漏洞真正可靠的还是靠数据库联合唯一索引。这个我在前面表设计部分已经强调过。3.5 取消预约与冲突处理的边界情况系统里我加了一个“可取消时段”的规则预约时间开始前1小时内不允许取消防止有人恶意占座再临场取消。这个规则放在前端主要为了交互友好真正的强制校验还在后端——前端可以改代码跳过但后端接口校验不能绕过。处理边界情况时设计模式是前端提示后端强校验。比如用户选择了一个已经被预约的时段前端在渲染时直接把该时段的按钮置灰用户无法点击。但即使这样两个并发的请求仍可能同时提交同一个时段后端会返回“该时段已被预约”的错误信息前端拿到错误后清空选中状态并刷新座位图。这里我踩过一个很典型的坑前端在预约失败后没有刷新座位状态导致界面还显示刚才选中的座位是空闲的用户再点一次就发现一直报错。解决办法是在预约失败后重新加载当前自习室的座位状态让用户看到最新数据。4. 前后端接口联调与环境问题排查4.1 Axios封装、拦截器与token鉴权前端所有HTTP请求都通过Axios我封装了一个统一的request模块好处是所有请求自动带上token响应错误统一处理。import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } ) export default request这里有几个容易被忽略的关键点。第一个是baseURL: /api它和Vite代理配置的/api前缀是对应的。前端请求/api/rooms被Vite代理转发到http://localhost:8080/rooms。这样后端接口不感知前端域名也不会有跨域问题。第二个是401状态处理。如果token过期或无效后端返回401前端拦截器自动清除本地token并跳转登录页。这个逻辑必须在拦截器里全局处理否则每个页面都要自己判断401代码会重复。第三个是响应体格式。我统一约定后端返回格式为{ code: 0, data: ..., message: ok }拦截器直接返回response.data业务代码里就不用每次写res.data.data这种深层取值了。当然这只是约定实际项目里要根据后端定义调整。4.2 跨域问题开发环境代理与生产环境Nginx配置开发环境用Vite代理解决跨域生产环境则必须用Nginx反向代理解决。这两个环节我都踩过坑。先说开发环境。如果前端直接请求http://localhost:8080/rooms浏览器会拦截跨域请求。解决办法有两个后端配置CORS跨域或者前端通过Vite代理转发。我优先推荐Vite代理因为后端不用专门为跨域写代码。配置方法在上面vite.config.js里已经写过了核心就是proxy字段。生产环境的部署方案是前端打包后放到Nginx的html目录Nginx配置监听80端口静态资源请求直接返回前端文件/api开头的请求反向代理到后端服务。server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这个配置里try_files $uri $uri/ /index.html这一行非常重要。Vue Router使用history模式时刷新一个子路由页面比如/my-reservationsNginx会先去磁盘找这个路径对应的文件找不到就会返回404。try_files的作用是找不到真实文件就回退到index.html由前端路由自己解析路径。如果不加这一行你部署上线后刷新页面十有八九会白屏或404。4.3 联调阶段出现的典型报错与排查思路联调阶段我遇到过几个特别典型的问题这里整理成一个速查表。现象原因解决方案POST请求返回404后端接口路径和前端请求路径不一致检查后端Controller的RequestMapping和前端api函数的路径预约时提示“该时段已被预约”数据库联合唯一约束触发刷新座位图状态排查是否存在重复提交登录成功后请求其他接口401token没有正确传递检查Axios拦截器中的Authorization头写法刷新页面后路由404Nginx未配置try_files补充try_files $uri $uri/ /index.html上传部署后样式丢失base路径配置错误在vite.config.js中设置base: /这条表本身没有什么深刻原理但每个条目都是我实际踩过的坑。尤其是Nginx刷新404那个问题我第一版部署上线后用户反馈“点完按钮再按F5就白屏”排查了很久才发现是try_files没配。4.4 状态管理在预约场景中的进阶用法前面提到用Pinia管登录态。实际做预约系统时还有一处值得用组合式函数Composable来组织座位列表的加载和更新逻辑。把座位查询、座位状态切换、预约失败后的刷新都封装到一个useSeat函数里页面代码会简洁很多import { ref } from vue import { getSeatList, reserveSeat } from /api/seat export function useSeat(roomId) { const seatList ref([]) const loading ref(false) async function loadSeats() { loading.value true try { const res await getSeatList(roomId) seatList.value res.data } finally { loading.value false } } async function doReserve(params) { const res await reserveSeat(params) await loadSeats() return res } return { seatList, loading, loadSeats, doReserve } }这样在预约页面里只需要const { seatList, loading, loadSeats, doReserve } useSeat(route.params.roomId)相比把所有逻辑都写在页面组件里这种组合式函数把和数据打交道的逻辑抽离出来页面组件只负责绑定数据和处理用户交互。代码容易测试也容易复用。比如管理员后台的“查看所有座位”功能就可以复用这个useSeat只是传入的房间号参数不同。5. 常见问题速查与避坑经验总结5.1 新手必踩的Vue版本与组件库兼容问题搜索“vue”相关的热词时很多人会搜到旧教程里面用Vue.use(Vuex)、new Vue()、import Vue from vue这类语法。但Vue 3项目里根本没有全局的Vue对象统一用createApp创建应用实例。组件库也一样Element Plus和Element UI是两个完全不同的包前者对应Vue 3后者对应Vue 2混用的话报错会非常难排查。另一个高概率问题是依赖安装不全。如果你使用Element Plus的图标组件比如Edit、Delete、Refresh这些图标必须安装element-plus/icons-vue否则图标渲染不出来页面并不会报错只是图标位置空白。这类问题排查起来很费时间因为控制台看不到任何红色报错。5.2 预约系统的权限控制细节这个系统里我实现了按钮级权限控制不只是页面路由级。具体来说管理员后台的“删除预约”按钮要在用户登录且角色为admin时才显示。实现方式是用自定义指令app.directive(permission, { mounted(el, binding) { const requiredRole binding.value const userStore useUserStore() if (userStore.userInfo.role ! requiredRole) { el.parentNode?.removeChild(el) } } })使用方式很简单el-button v-permissionadmin clickdelReservation删除/el-button这个方案比用v-if判断的优势在于指令逻辑集中在一次定义所有按钮复用。权限相关代码统一维护不会散落在各个组件里。5.3 自己动手验证过的时间段防冲突逻辑前面多次提到防冲突这里给一个简化的后端Service层示例方便你理解整个预约提交的闭环Transactional public void reserve(ReservationDTO dto) { // 1. 校验座位状态 Seat seat seatMapper.selectById(dto.getSeatId()); if (seat.getStatus() ! 0) { throw new BusinessException(座位不可用); } // 2. 校验时间段唯一索引 int count reservationMapper.countBySeatAndTime( dto.getSeatId(), dto.getDate(), dto.getTimeSlot()); if (count 0) { throw new BusinessException(该时段已被预约); } // 3. 插入预约记录 reservationMapper.insert(...); }加上Transactional注解可以保证插入过程中如果任何一步异常整个事务回滚不会留下脏数据。这个注解在预约类系统里几乎是标配面试提一下会显得你考虑得很周全。5.4 一个扩展方向用Vue生成的小程序方案如果你已完成Web版的自习室预约系统想扩展到微信小程序端有两个方向可以考虑。一个是使用uni-app这类跨端框架它允许你用Vue语法编写代码然后编译成小程序。另一个是WebView嵌套方案——把Vue项目打包后通过小程序内嵌WebView承载适合已有Web版本但不想额外开发小程序的场景。从工程成本角度我更推荐第一个方向因为uni-app的代码结构和Vue非常接近页面路由、组件化开发、请求封装都可以沿用现有经验。唯一需要额外学习的是小程序的API调用方式比如登录逻辑要换成wx.login获取code再向后端换取自定义token。这个扩展方向做出来之后项目可以从PC端延伸到移动端简历上又多了个亮点。6. 项目部署与个人实操体会6.1 从开发到上线的完整过程记录项目开发完成后我的部署流程总结成一套固定脚本以后做任何Vue项目都能复用先在前端项目根目录执行构建命令npm run build构建产物在dist目录下把这个目录里的所有文件上传到服务器的/usr/share/nginx/html目录。后端Spring Boot项目用Maven打包mvn clean package -DskipTests生成xxx.jar文件用java -jar命令运行。生产环境下建议用nohup后台启动nohup java -jar api.jar app.log 21 然后配置Nginx把上一步的nginx.conf写入配置文件重载Nginx生效nginx -s reload整个过程最需要注意的是前端和后端的API前缀。我前面提到前端Axios的baseURL是/apiNginx的location /api/代理到后端8080端口。这两个配置必须严格对应否则到时候接口会全部404或跨域。6.2 这套系统还能继续扩展什么自习室预约系统看似简单但扩展空间很大。我给自己的项目列了几个后续迭代方向你可以参考。第一个是消息通知。预约成功、预约取消、临近开场提醒这些都可以接入邮件或短信通知。如果感觉接短信服务成本高先用邮件通知也完全够用。第二个是报表统计。目前管理员只能查看预约记录不能看到“哪个时段最热门”“哪个自习室使用率最高”这类统计数据。加上这部分需要写一些SQL聚合查询前端用ECharts做图表展示项目的工程难度和分析价值都会上一个台阶。第三个是签到核销。用户到自习室后扫码签到预约状态从“已预约”变为“已使用”。这个功能可以在预约记录里增加一个“签到码”管理员后台提供核销入口。这块逻辑不复杂但对真实业务场景的契合度非常高。项目做出来的意义不仅在于“能跑”更在于你理解每一个模块为什么这么设计遇到问题时能快速定位并解决。我在做这个系统的过程中最大的体会是前端技术的细节非常多但大部分问题都不是某个知识点不会而是版本不兼容、配置漏写、路径不对这类工程问题。熟练掌握Vue、Router、Pinia、Axios这四个核心工具之后你想到任何管理系统类项目都能很快搭出框架、填充功能、部署上线。这套方法论比你此刻手头这个自习室项目的具体代码更值钱。