
上个月帮学校图书馆做了一套自习室座位预约系统正赶上期末季“一座难求”的节点上线每天几千个学生同时进来抢座。前端用 Vue 3 写从 Vite 初始化到打包放进后端服务里走完了一整条生产线。这套系统算不上大但麻雀虽小路由设计、组件拆分、状态管理、实时推送、打包部署这些前端高频技能点全占了。这篇就把整个实现过程和踩过的坑讲清楚适合已经会 Vue 基础语法、想做点真实项目练手的同学也适合要在课程设计里交一套可视化系统的朋友。1. 自习室预约系统要解决的三个核心问题1.1 业务需求拆解预约系统不只是“选个座”做系统之前我先把“自习室预约”这件事掰开揉碎看了一眼。表面上就是学生选时间选座位但落到业务上至少要覆盖这五个环节用户登录与身份识别学生和自习室管理员的权限完全不同学生能预约、取消、查记录管理员要看整体占用率、释放超时座位。座位与时间段建模一个自习室有几十个座位一天又拆成早中晚多个时段座位和时段必须组合绑定才能判断“这个座这个时段被占了没有”。预约与释放流程正常预约要有占用校验取消后座位要能马上释放给其他人超时未签到还要有惩罚或强制释放逻辑。可视化展示用户需要一眼看清楚哪些座位可约而不是看一张文字表格。这就逼着前端做一个座位地图。记录与统计预约历史、高峰时段分析这些是图书馆老师明确要的功能也是加分项。我见过不少新手一上来就写“选座位页面”结果做到一半才发现时段和座位是两个维度数据模型没设计好后面全部返工。这套系统的核心不在页面而在“座位状态”的实时一致性前端再花哨数据对不上就全白搭。1.2 技术选型为什么是 Vue 而不是其他选 Vue 3 作为前端框架有很现实的原因中文社区资料最全遇到问题搜出来的答案能直接看懂Element Plus 这类组件库成熟日期选择器、表格、表单校验开箱即用能省掉大量基础组件编写时间Vue Router 和 Pinia 足以支撑这套系统的路由和状态管理需求不需要引 Redux 级别的重型状态库项目规模适中Vue 的渐进式特性很合适从零到一搭起来快。具体技术栈清单如下模块选型用途构建工具Vite项目初始化、开发热更新、打包优化框架Vue 3 (Composition API)页面开发、组件组织路由Vue Router 4页面跳转、动态路由、权限控制状态管理Pinia用户信息、预约状态共享UI 组件库Element Plus表单、日期选择、弹窗、表格HTTP 请求Axios与后端接口通信实时通信SSE (EventSource)座位状态实时刷新这套组合的另一个好处是一个人写也不会失控。组件层级清晰状态只存在 Pinia 的 store 里页面上不堆逻辑。1.3 项目整体模块划分我按功能域把前端分成了六个页面区登录、自习室列表、座位预约、我的预约、管理后台、个人中心。对应路由结构路由路径页面功能/login登录页账号密码登录保存 Token/rooms自习室列表展示可用自习室、座位总数、空闲数/rooms/:id座位预约页座位地图、日期时段选择、确认预约/reservations我的预约查看记录、取消预约/admin管理后台座位管理、释放超时座位、统计报表/profile个人中心个人信息、修改密码模块化设计决定了后面数据模型怎么写也决定了动态路由往哪些方向挂这一点建议动手前先画出来哪怕画在草稿纸上。2. Vite 初始化与依赖安装的实操细节2.1 从零创建 Vue 3 项目创建项目我用的是 Vite命令行操作非常直接npm create vitelatest study-room-front -- --template vue cd study-room-front npm install npm run dev这里有个容易被忽略的点--template vue生成的是 Vue 3 JavaScript 版本如果你平时写的是 TypeScript把最后的vue换成vue-ts。我建议课程设计或中小型项目用 JS 就够了TypeScript 会带来额外的类型标注成本不是必需。项目结构初始化好后我会立刻删掉默认的HelloWorld.vue和style.css里的模板冗余代码再按自己的模块规划建文件夹src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── layout/ # 页面布局组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态 └── views/ # 页面组件这一步看似简单但它决定了后面代码好不好找。我见过很多项目把所有页面堆在views里、所有请求散落在页面里改一个接口要全局搜索排错体验极差。2.2 依赖清单与版本坑安装依赖时我踩过一个很典型的问题直接npm install vue-routerlatest装到了 Vue Router 5 的测试版结果和 Vue 3 不兼容运行直接报错。所以建议按明确版本安装npm install vue-router4 pinia axios npm install element-plus element-plus/icons-vue进package.json确认一下版本号{ dependencies: { vue: ^3.4.0, vue-router: ^4.3.0, pinia: ^2.1.7, axios: ^1.7.0, element-plus: ^2.7.0 } }如果安装过程中遇到依赖冲突最实用的办法是先删掉node_modules和package-lock.json再重新装一遍不要盲目改版本号。2.3 npm 安装缓慢与源配置很多新手卡在npm install这一步等十几分钟不动或者报各种网络超时。这通常不是代码问题而是默认源慢。我一般会先检查源npm config get registry如果返回的不是常见的国内镜像源可以直接改配置npm config set registry https://registry.npmmirror.com再执行安装速度会明显提升。这里要提醒一句改源只是降低网络延迟不涉及任何特殊网络操作按这个配置填写即可。2.4 引入 Element Plus 的正确姿势Element Plus 有两种引入方式。项目不大我直接全量引入省心// main.js import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import zhCn from element-plus/es/locale/lang/zh-cn import App from ./App.vue const app createApp(App) app.use(ElementPlus, { locale: zhCn }) app.mount(#app)注意这里一定要引入zh-cn语言包不然日期选择器、分页器这些组件显示的都是英文。全量引入会让打包体积变大但对课程设计、内部系统完全够用等真的需要优化再换成按需导入。3. 页面布局与组件拆分插槽的正确打开方式3.1 用布局组件统一三端页面结构预约系统的页面分为学生端和个人端两大块但整体结构是统一的顶部导航栏、左侧菜单管理员才有、右侧内容区。这个统一结构我用一个Layout.vue组件承载里面大量使用了插槽。!-- src/layout/AdminLayout.vue -- template div classlayout header classlayout-header !-- 具名插槽顶部栏内容由父组件决定 -- slot nameheader默认标题/slot /header aside classlayout-side slot namesidebar/slot /aside main classlayout-content !-- 默认插槽放页面主体 -- slot/slot /main /div /template这样设计的好处是登录后不管是学生页面还是管理后台都复用同一个外壳只是往插槽里喂不同的内容。如果以后要把侧边栏从左边换到右边只改布局组件一个文件就行业务页面完全不用动。3.2 作用域插槽座位状态卡片的自定义渲染座位预约页的核心组件是SeatGrid.vue它接收座位数组按矩阵渲染出格子。难点在于座位有四种状态空闲、已约、待签到、维修中每种状态的卡片颜色和操作按钮都不一样。这里如果让子组件内部写死渲染逻辑扩展性会很差。我用了作用域插槽让父页面决定每个座位格长什么样!-- SeatGrid.vue -- template div classseat-grid div v-forseat in seats :keyseat.id classseat-cell slot nameseat :seatseat !-- 默认渲染按状态显示颜色 -- div :class[seat-default, statusClass(seat.status)] {{ seat.seatNo }} /div /slot /div /div /template script setup const props defineProps({ seats: { type: Array, required: true } }) const statusClass (status) { const map { available: seat-available, booked: seat-booked, pending: seat-pending, maintain: seat-maintain } return map[status] || seat-available } /script在预约页面调用时用v-slot拿到当前座位的完整数据SeatGrid :seatscurrentSeatList template #seat{ seat } el-tooltip :contentseat.tips el-button :typeseat.status available ? primary : info :disabledseat.status ! available clickselectSeat(seat) {{ seat.seatNo }} /el-button /el-tooltip /template /SeatGrid作用域插槽的最大价值是把“数据展示”和“交互逻辑”的边界划清楚了。座位网格只负责遍历、布局、传数据至于格子是按钮、卡片还是图表完全由上层业务决定测试也方便。3.3 预约表单的字段设计与校验预约就是让用户选择日期、时段、座位号。这个表单放在座位地图上方字段不多但校验要求明确日期不能是过去时间时段必须二选一或三选一且只能选一个必须先从地图上点了某个座位才能提交且已约座位不能再点。官方文档里的el-form校验方式足够用。我加了一条自定义校验规则保证“座位编号在提交前已经选定”const rules { date: [{ required: true, message: 请选择日期, trigger: change }], timeSlot: [{ required: true, message: 请选择时间段, trigger: change }], seatNo: [{ validator: (rule, value, callback) { if (!selectedSeat.value?.id) { callback(new Error(请先在平面图上选择座位)) } else { callback() } }, trigger: change }] }日期选择我用el-date-picker加disabled-date禁掉过去日期这一步能省不少后端校验压力。表单校验做在前端不纯粹是用户体验也是给后端减轻无效请求的负担。4. 预约流程与座位状态管理从轮询到 SSE 实时推送4.1 座位状态的数据模型怎么设计前端管理座位状态最重要的是把“后端数据”和“本地交互”区分开。我的 Pinia store 里维护了这样的结构// stores/room.js export const useRoomStore defineStore(room, { state: () ({ roomDetail: null, date: new Date().toISOString().slice(0, 10), timeSlot: morning, seats: [], selectedSeat: null, loading: false }), getters: { availableCount: (state) state.seats.filter(s s.status available).length, bookedCount: (state) state.seats.filter(s s.status booked).length }, actions: { async fetchSeats(date, timeSlot) { this.loading true try { const res await api.getSeats(this.roomDetail.id, { date, timeSlot }) this.seats res.data } finally { this.loading false } }, selectSeat(seat) { if (seat.status ! available) return if (this.selectedSeat?.id seat.id) { this.selectedSeat null } else { this.selectedSeat seat } } } })日期或时段一变化seats数组就整体刷新。这个设计的好处是很容易扩展如果以后要把维修中的座位排除在外只需要在getters里多加一个派生数据页面配合改一下渲染逻辑。4.2 实时刷新座位状态SSE 比轮询靠谱在哪开发初期我用了定时轮询每 5 秒请求一次座位状态接口。结果高峰期数据库压力不小而且有 5 秒延迟经常出现两个人同时抢同一个座位的现象。后来我改成 SSEServer-Sent Events后端在座位状态变更时主动把最新数据推给前端前端彻底摆脱了定时器。前端实现很简单// utils/sse.js export function createSeatSse(roomId, callback) { const eventSource new EventSource(/api/sse/seat-stream?roomId${roomId}) eventSource.onmessage (event) { const data JSON.parse(event.data) callback(data) } eventSource.onerror () { // 网络异常会自动重连这里只需补一个提示 console.warn(SSE 连接异常等待重连...) } return eventSource }在预约页挂载时建立连接离开时关闭let source null onMounted(() { source createSeatSse(roomId, (message) { if (message.type SEAT_STATUS_CHANGE) { roomStore.updateSeatStatus(message.seatId, message.status) } }) }) onBeforeUnmount(() { source?.close() })使用 SSE 之后座位状态延迟基本降到秒级两个人抢座时后到的人能立刻看到座位变红体验好了不少。需要注意SSE 是单向推送只能后端往前端发前端控制指令仍然走普通 HTTP 接口。4.3 防重复提交与业务状态机预约系统最怕的就是连续点两次提交按钮生成两条预约。前端我已经做了两个防线按钮提交后立刻禁用直到后端返回结果再恢复提交期间显示 loading 状态从视觉上杜绝连续操作。const submitting ref(false) const handleSubmit async () { if (submitting.value) return submitting.value true try { await api.createReservation(payload) ElMessage.success(预约成功) await roomStore.fetchSeats(roomStore.date, roomStore.timeSlot) } catch (err) { ElMessage.error(err.response?.data?.message || 预约失败) } finally { submitting.value false } }但前端防重复是软防关键硬保证在后端预约接口必须做幂等控制比如同一个座位同一个时段只能存在一条有效预约。我习惯把后端业务做成一整套状态机当前状态操作目标状态待签到取消预约已释放待签到超过签到时间未到已违约已签到使用结束已完成维修中维修完成空闲前端拿到这些状态码做对应的文案和按钮渲染就够了。核心原则是前端只负责状态展示真正的状态转换永远由后端决定。5. 前后端联调、动态路由与权限控制5.1 Axios 实例封装与统一错误处理预约系统的请求不算复杂但登录 Token、错误提示、加载状态这些逻辑如果散落在每个页面后期维护会很难受。我在src/api/request.js里做了一个统一封装import axios from axios import { ElMessage } from element-plus import router from /router const instance axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动带 Token instance.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理业务错误 instance.interceptors.response.use( (response) response.data, (error) { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } else { ElMessage.error(error.response?.data?.message || 网络异常) } return Promise.reject(error) } ) export default instance这样做的直接收益是后端调整返回结构时我只需要改动截器里的response.data取值方式全项目所有请求均同步生效。页面里的请求代码也简洁很多const res await request.get(/rooms)5.2 基于角色的动态路由注册这个系统里学生和管理员能访问的页面不一样。如果直接把所有路由写死并全部注册那么普通学生虽然看不到后台入口但手动输入 URL 还是能进管理页。所以我把权限控制做到了路由层。先在router/index.js里只注册公开路由比如登录页然后登录成功后根据用户角色动态追加路由// router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /login, component: () import(/views/Login.vue) }, { path: /rooms, component: () import(/views/RoomList.vue) } ] const router createRouter({ history: createWebHistory(), routes }) // 动态添加管理员路由 export function setupAdminRoutes() { router.addRoute({ path: /admin, component: () import(/layout/AdminLayout.vue), children: [ { path: , component: () import(/views/admin/AdminDashboard.vue) }, { path: seats, component: () import(/views/admin/SeatManage.vue) } ] }) }登录后获取到用户角色时如果是管理员就调用setupAdminRoutes()。再配合全局前置守卫做一层校验router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) return } if (token to.path /login) { next(/rooms) return } next() })动态路由这个点也是面试里经常被问到的细节。真正做权限系统时后端通常会返回该用户可访问的路由表前端用router.addRoute一条条挂载比写死前端路由再判断角色要灵活得多。5.3 联调阶段的跨域处理Vite Proxy 的配置前后端分开开发时跨域是必踩的一关。最简单的方式是在 Vite 配置里加代理把/api前缀的请求转发给后端开发地址// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })配置后前端代码里请求写/api/rooms开发时 Vite 会自动转发到http://localhost:8080/rooms上浏览器里看不到跨域报错也不需要后端开启 CORS 通配。上线时再用 Nginx 或 SpringBoot 做同样路径的转发前端页面代码一行不用改。提示如果后端要求所有接口都带/api前缀那么代理只写/api即可如果后端接口没有统一前缀可以在 Axios 的 baseURL 里补齐。6. 打包部署进 SpringBoot 的完整实操记录6.1 Vite 构建配置与静态资源路径本地开发一切正常但部署是个大头。很多同学第一次把 Vue 项目打包后放到后端项目里打开页面发现样式全没或者路由直接 404。这大概率是 Vite 的base配置没设置。默认情况下Vite 打包出的资源路径是绝对路径/assets/...但后端项目如果部署在域名的子路径下比如/study/这些资源请求会直接 404。我改成相对路径// vite.config.js export default defineConfig({ base: ./, build: { outDir: dist } })这样打包后index.html里引用的资源路径就是./assets/...无论把静态文件放到哪个目录都能通过相对路径找到资源。6.2 打包产物怎么放进 SpringBoot我这里的后端是 SpringBoot最省事的托管方式是直接把前端打包产物放到src/main/resources/static/目录下。步骤很简单前端执行npm run build生成dist目录将dist目录下的文件全部复制到后端项目的static目录重新打包运行 SpringBoot访问http://localhost:8080/就能直接打开前端页面。但这里有个我绕了很久的坑如果前端路由用的是 history 模式HTML5 Mode部署后访问/rooms这种前端路由地址浏览器会向后端发一次真正的 HTTP 请求而后端没有这个路径的接口返回 404页面白屏。解决办法有两个方案一前端路由改 hash 模式这也是最省心的const router createRouter({ history: createWebHashHistory(), routes })改完以后前端路由地址带#号比如http://localhost:8080/#/rooms这个地址始终会请求/index.html天然规避了 404 问题。方案二后端配置转发把非接口路径全部转到静态首页。在 SpringBoot 里加一个简单的资源转发配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{path:[^\\.]*}) .setViewName(forward:/index.html); } }这个配置保证所有不带.的路径都回滚到index.html再交给前端路由去解析同时.js、.css等带点号的请求还能正常走静态资源。两种方案怎么选如果没有特殊的 SEO 需求内部管理系统优先选 hash 模式稳定省心。6.3 打包体积优化与接口地址适配打包部署没问题后我又做了两件优化。第一件是路由懒加载。如果直接 import 所有页面打完包生成的 JS 可能有 1MB 以上首屏加载会很慢。把页面组件改成动态导入后Vite 会自动按路由拆代码块用户访问哪页就下载哪页的代码const routes [ { path: /rooms/:id, component: () import(/views/RoomDetail.vue) } ]第二件是接口地址的适配。上线后前端的baseURL不能再写http://localhost:8080我把接口前缀统一写成/api然后通过代理层转发到真正的后端服务。可以用 Vite 环境变量区分开发与生产# .env.development VITE_API_BASE/api # .env.production VITE_API_BASE/api前端代码里配合 Axios 的baseURL读取baseURL: import.meta.env.VITE_API_BASE || /api这样开发和生产环境前端代码保持一致差异只在部署的代理配置上体现。6.4 上线前最后的体检清单结合这次实际上线经验我整理了自习室预约系统上线前的检查项避免上线后手忙脚乱检查项预期结果原因刷新/reservations页面页面正常不白屏确认路由模式与后端转发正确手机尺寸访问布局不溢出按钮可点学生多用手机端需要一定响应式处理同一账号双开页面抢同一座位只有一个能预约成功验证后端幂等前端提示合理管理员释放座位学生端 2 秒内看到状态变化验证 SSE 推送链路关闭后端服务后访问前端页面正常打开接口报错提示友好静态资源与后端逻辑分离错误拦截器正常预约记录分页数据不重复、不漏确认后端分页参数与前端组件对接一致这套系统从开发到上线前后花了两周多时间最大的体会是Vue 项目最容易出问题的从来不是某个 API 不会用而是工程化链条上那些零碎的细节——路由模式、资源路径、请求代理、状态一致性。把这些串起来才是真正把项目从“本地能跑”变成“线上可用”的关键。最后说一个个人习惯每次改动前后端联调接口时我都会先在浏览器 Network 面板里确认请求路径和响应结构排错效率比直接看代码高不少。如果你也正准备做类似的预约系统建议先用思维导图把数据模型画明白再开始写 Vue 组件后面能省一大半返工时间。