上个月帮学校图书馆做了一套自习室座位预约系统,正赶上期末季“一座难求”的节点上线,每天几千个学生同时进来抢座。前端用 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 vite@latest 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-router@latest装到了 Vue Router 5 的测试版,结果和 Vue 3 不兼容,运行直接报错。所以建议按明确版本安装:
npm install vue-router@4 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 class="layout"> <header class="layout-header"> <!-- 具名插槽:顶部栏内容由父组件决定 --> <slot name="header">默认标题</slot> </header> <aside class="layout-side"> <slot name="sidebar"></slot> </aside> <main class="layout-content"> <!-- 默认插槽:放页面主体 --> <slot></slot> </main> </div> </template>这样设计的好处是:登录后不管是学生页面还是管理后台,都复用同一个外壳,只是往插槽里喂不同的内容。如果以后要把侧边栏从左边换到右边,只改布局组件一个文件就行,业务页面完全不用动。
3.2 作用域插槽:座位状态卡片的自定义渲染
座位预约页的核心组件是SeatGrid.vue,它接收座位数组,按矩阵渲染出格子。难点在于:座位有四种状态(空闲、已约、待签到、维修中),每种状态的卡片颜色和操作按钮都不一样。
这里如果让子组件内部写死渲染逻辑,扩展性会很差。我用了作用域插槽让父页面决定每个座位格长什么样:
<!-- SeatGrid.vue --> <template> <div class="seat-grid"> <div v-for="seat in seats" :key="seat.id" class="seat-cell"> <slot name="seat" :seat="seat"> <!-- 默认渲染:按状态显示颜色 --> <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 :seats="currentSeatList"> <template #seat="{ seat }"> <el-tooltip :content="seat.tips"> <el-button :type="seat.status === 'available' ? 'primary' : 'info'" :disabled="seat.status !== 'available'" @click="selectSeat(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 秒延迟,经常出现两个人同时抢同一个座位的现象。
后来我改成 SSE(Server-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 组件,后面能省一大半返工时间。