自习室座位签到预约系统,这六个字背后其实是大多数自习室管理者的真实痛点:座位靠“占”、来了没座、人走位空,管理全靠吼。用Node.js加Vue做一套预约签到系统,本质上就是把“占座”从线下冲突变成线上契约,让每一个座位在任意时刻都有明确的归属状态。这篇博文我会从需求拆解讲到前后端实现,再把环境配置、预约状态机、联调部署这些过程中最容易踩的坑一并列出来,给正在做课设、毕设或者真想落地一套自习室系统的朋友一个完整的参考。
1. 系统要解决的真实问题与需求边界
很多人在动手前容易犯一个错误:上来就画表、写接口,结果做到一半发现核心场景没想清楚。自习室座位预约系统的核心问题很简单——座位的使用权如何在时间维度上公平、高效地分配。
1.1 自习室场景下的三类使用冲突
我走访过几个高校自习室和付费自习室,收集到的真实痛点高度一致:
- 占而不来:早上开馆时拿书占座,下午人都没出现,座位空着别人也不能坐
- 来而无座:高峰期到馆后发现满座,实际上有大量座位处于“被占”状态
- 管理无据:管理员只能靠巡查和肉眼判断,无法掌握座位使用率、高峰时段等数据
一套签到预约系统要解决的就是这三件事:预约锁定座位、签到确认入座、超时自动释放。三者形成闭环,缺一环都会回到解放前。
1.2 功能模块的合理切分
基于上面的分析,我把系统拆成两个端、五大模块:
| 模块 | 用户端 | 管理端 | 核心功能 |
|---|---|---|---|
| 账号管理 | 注册、登录、个人信息 | 管理员登录 | JWT身份认证 |
| 座位管理 | 查看实时座位状态 | 座位CRUD、状态修改 | 座位区域/编号管理 |
| 预约模块 | 选座、取消预约 | 查看全部预约记录 | 预约状态流转控制 |
| 签到模块 | 按时签到 | 签到记录查询 | 超时释放规则 |
| 统计模块 | 我的预约历史 | 座位使用率、高峰统计 | 图表展示 |
有一点必须提前说明:签到这个环节是整个系统的灵魂。如果你只做预约不做签到,系统就变成一个单纯的“抢座工具”,反而会制造新的不公平。必须有签到门槛,才能真正把座位盘活。
2. 技术选型:为什么锁定Node.js + Vue这对组合
标题已经把技术栈定死了,但作为一篇有参考价值的博文,我还是想展开讲讲选型的逻辑,毕竟网上搜“springboot vue前后端分离”的人远多于“nodejs vue自习室系统”,很多人其实是纠结过的。
2.1 前后端同语言的开发效率优势
Node.js + Vue这套组合最大的特点就是全栈JavaScript。前端用Vue写的是JavaScript/TypeScript,后端用Node.js写的还是JavaScript/TypeScript,IDE提示、调试工具、代码风格可以高度统一。跟我同期做毕设的同学用Spring Boot + Vue,后端的Java和前端的JavaScript之间频繁切换上下文,光类型转换和对象映射就耗费了大量精力。
对于课设、毕设、个人作品集这种2到4周开发周期的场景,Node.js + Express这类轻量框架能在极短时间内完成CRUD和业务逻辑,不需要像Spring Boot那样配置一堆XML和注解扫描。
2.2 与主流方案的对比
我没有无脑吹Node.js的意思,选型前得清楚它的边界:
| 对比项 | Node.js + Express | Spring Boot + Vue | 纯Vue + 云开发 |
|---|---|---|---|
| 学习曲线 | 较低 | 中高 | 最低 |
| 开发效率 | 高 | 中 | 高 |
| 并发能力 | 中上(适合本场景) | 高 | 中等 |
| 部署成本 | 低(单进程) | 较高 | 最低(免运维) |
| 适合场景 | 中小型系统 | 企业级系统 | 原型/小型Demo |
如果是一个几千人规模的自习室连锁品牌,我反而建议用Spring Boot + MySQL + Redis,但单馆或校园场景下,Node.js完全够用且更轻。自习室的并发峰值也就是早晚高峰的那几十个预约请求,Node.js事件驱动的模型处理这种IO密集型请求绰绰有余。
2.3 Vue版本选择:为什么直接上Vue 3
现在搜“vue安装及环境配置”,能搜到大量Vue 2时代的文章,新手很容易被带到沟里。2024年做新项目没有任何理由再选Vue 2,直接Vue 3 + Vite + Composition API。Vue 3的Composition API在组件逻辑复用上比Vue 2的Options API清晰得多,配合<script setup>语法糖,写起来比React还要顺手。
顺便回应一个高频搜索词“vue和react的区别”,如果非要比的话:Vue的响应式系统是自动追踪依赖、自动更新,React需要手动处理useMemo/useCallback;Vue的模板更接近HTML直觉,React的JSX更灵活。做这类管理系统,Vue是真的省心。
3. 环境配置是第一道坎:Node.js安装到npm可用的完整链路
这一节写给纯新手。热词榜里“npm : 无法加载文件...因为在此系统上禁止运行脚本”能排到前列,说明绝大多数人卡死在了起步阶段。我先把这个坑彻底踩平。
3.1 Node.js版本选择与多版本管理
一定去 Node.js官网 下载LTS版本,不要追最新版。LTS版经过两年维护期验证,稳定性和生态兼容性都是最好的。用奇数版本号(如20.x)和偶数版本号(如22.x)的差别在于:奇数版是Current版,功能新但可能踩坑;偶数版才是LTS。
热词里有“nodejs 可以安装多个版本么”,答案是可以,但不要手动装。Windows下用nvm-windows,macOS/Linux用nvm。装完nvm之后,日常只需要三条命令:
# 查看远端可安装的版本 nvm ls available # 安装指定的LTS版本 nvm install 20.11.1 # 切换使用某个版本 nvm use 20.11.1实名推荐用nvm list确认当前版本,用node -v验证。搞不定就重装,环境问题不值得浪费超过一个小时。
3.2 npm.ps1禁止运行脚本的完整解决思路
这个报错长这样:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。 有关详细信息,请参阅 about_Execution_Policies。根因:PowerShell默认执行策略是Restricted,不允许运行任何.ps1脚本文件。npm在Windows上通过npm.ps1这个PowerShell脚本暴露命令,于是直接被拦了。
三种解决方式,按推荐排序:
注意:前两种会改系统执行策略,请务必理解每条命令的含义再操作。
方式一:以管理员身份打开PowerShell,修改当前用户的执行策略
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned这条命令的意思是:允许运行本地脚本,远程脚本必须有数字签名。RemoteSigned比Unrestricted安全得多,也是微软官方推荐给开发者的配置。
方式二:仅对当前会话放行,不修改系统配置
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process-Scope Process只对当前窗口生效,重启后失效。适合临时借用别人电脑、不想动系统配置的场景。
方式三:不用PowerShell,改用cmd或Git Bash
如果你用的是原生控制台或者VS Code的默认终端,直接切换到cmd或者Git Bash,npm命令就能正常执行。这种方式不动任何配置,最省事,只是会失去PowerShell的部分高级能力。
3.3 Windows安装报错2203的处理经验
热词里还有一条“安装nodejs报错2203”,这个错通常出现在Windows Installer装MSI包的时候,报错信息类似“安装程序遇到错误2203。内部错误”。
原因一般是系统临时目录权限或Windows Installer服务异常,我实测有效的步骤是:
1. 按 Win + R,输入 services.msc,找到 "Windows Installer",确保服务状态是“已启动” 2. 删除 C:\Windows\Temp 下的异常缓存文件 3. 用右键“以管理员身份运行”重新执行MSI安装包如果还是装不了,别死磕MSI包,直接用免安装版(zip格式),解压后配置PATH环境变量,一样能用。热词里“nodejs免安装环境配置”说的就是这个路子:
1. 解压到 D:\nodejs 2. 右键“此电脑”→“属性”→“高级系统设置”→“环境变量” 3. 在系统变量的 Path 中添加 D:\nodejs 4. 新建变量 NODE_HOME=D:\nodejs 5. 重新打开终端,node -v 验证3.4 换源与npm默认配置
环境装好之后,下一步第一步是换源,否则npm install会慢到怀疑人生。我一直在用的是npmmirror镜像:
npm config set registry https://registry.npmmirror.com验证是否生效:
npm config get registry另外建议开启node的依赖安装缓存和包锁文件,默认就是开启的,不用额外操作。到这里,Node.js环境才算真正就绪。
4. 后端设计与实现:预约状态机是全部核心
环境就绪后,正式进入代码阶段。后端我用了Express 4 + Sequelize + MySQL,这套搭配在中小项目中非常成熟。以下所有代码片段都经过实际运行验证,你复制粘贴改改数据库配置就能跑。
4.1 Express项目初始化与目录结构
用官方生成器创建项目骨架(相比手写app.js,生成器能少踩不少中间件顺序的坑):
npm install -g express-generator express seat-server --view=ejs cd seat-server npm install我习惯给项目建一个清晰的按模块划分的目录结构,项目变大后查代码非常方便:
seat-server/ ├── app.js # 入口文件,挂载中间件和路由 ├── config/ │ ├── db.config.js # 数据库连接配置 │ └── jwt.config.js # JWT密钥与过期时间 ├── models/ # Sequelize模型定义 │ ├── user.model.js │ ├── seat.model.js │ └── reservation.model.js ├── routes/ # 路由定义 │ ├── auth.routes.js │ ├── seat.routes.js │ └── reservation.routes.js ├── middlewares/ │ ├── auth.js # JWT认证中间件 │ ├── admin.js # 管理员校验 │ └── validate.js # 入参校验 └── controllers/ # 业务逻辑处理 ├── auth.controller.js ├── seat.controller.js └── reservation.controller.js4.2 数据库表设计的三个要点
自习室系统就三张核心表:users(用户表)、seats(座位表)、reservations(预约记录表)。
users表:
CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, -- bcrypt加密后的密文 nickname VARCHAR(50) DEFAULT '', is_admin TINYINT DEFAULT 0, -- 0普通用户,1管理员 created_at DATETIME DEFAULT CURRENT_TIMESTAMP );seats表:
CREATE TABLE seats ( id INT PRIMARY KEY AUTO_INCREMENT, room_name VARCHAR(50) NOT NULL, -- 区域/房间名,如"三楼静音区" seat_number VARCHAR(20) NOT NULL, -- 座位编号,如"A-01" status TINYINT DEFAULT 0, -- 0空闲 1已预约 2使用中 3维护 UNIQUE KEY uk_room_seat (room_name, seat_number) );reservations表:
CREATE TABLE reservations ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, seat_id INT NOT NULL, reserve_date DATE NOT NULL, -- 预约日期 start_time DATETIME NOT NULL, -- 预约开始时间 end_time DATETIME NOT NULL, -- 预约结束时间 status TINYINT DEFAULT 0, -- 0待签到 1已签到 2已取消 3超时释放 4已完成 sign_in_time DATETIME NULL, -- 实际签到时间 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (seat_id) REFERENCES seats(id) );设计时的三个要点:
reservations.status字段是核心,它记录了整个预约生命周期。我故意用数字而不是字符串,是为了将来扩展状态方便、索引更高效- 座位状态和预约状态是两套状态。座位状态是“当前物理状态”(被谁占了没、是不是坏了),预约状态是“这条预约记录走到哪一步了”
- 加了
reserve_date日期字段,一个用户一天内能不能约多个时段,直接查这个日期字段就够
4.3 JWT登录认证与中间件设计
密码存储必须用bcrypt哈希,禁止明文存库。注册登录流程:
// controllers/auth.controller.js const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { User } = require('../models'); // 注册 exports.register = async (req, res) => { try { const { username, password } = req.body; // 1. 校验用户名是否已存在 const existed = await User.findOne({ where: { username } }); if (existed) { return res.status(400).json({ message: '用户名已被注册' }); } // 2. 加密密码 const hash = await bcrypt.hash(password, 10); // 3. 创建用户 const user = await User.create({ username, password: hash }); res.status(201).json({ message: '注册成功' }); } catch (error) { res.status(500).json({ message: '服务器内部错误' }); } }; // 登录 exports.login = async (req, res) => { try { const { username, password } = req.body; const user = await User.findOne({ where: { username } }); if (!user) return res.status(404).json({ message: '用户不存在' }); const matched = await bcrypt.compare(password, user.password); if (!matched) return res.status(401).json({ message: '密码错误' }); // 签发JWT const token = jwt.sign( { id: user.id, username: user.username, is_admin: user.is_admin }, process.env.JWT_SECRET || 'your_secret_key', { expiresIn: '7d' } ); res.json({ token, user: { id: user.id, username: user.username, is_admin: user.is_admin } }); } catch (error) { res.status(500).json({ message: '服务器内部错误' }); } };JWT的关键不是“能不能签发”,而是前端不能通过localStorage明文保存token之外再依赖别的状态。token本身是唯一凭证,expiresIn设置为7天比较合理,太短要频繁重新登录,太长有安全风险。
认证中间件(这个中间件会挂到所有需要登录的接口上):
// middlewares/auth.js const jwt = require('jsonwebtoken'); module.exports = function (req, res, next) { const authHeader = req.headers.authorization; if (!authHeader) { return res.status(401).json({ message: '未提供认证信息' }); } const token = authHeader.split(' ')[1]; // 格式 "Bearer <token>" try { const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your_secret_key'); req.user = decoded; next(); } catch (error) { return res.status(401).json({ message: 'token已过期或无效' }); } };4.4 预约与签到接口:核心业务逻辑详解
预约的核心思路是:一次请求内完成“座位状态校验+写入预约记录+更新座位状态”三个动作,并且用事务保证一致性。
// controllers/reservation.controller.js const { Reservation, Seat, sequelize } = require('../models'); const { Op } = require('sequelize'); // 创建预约 exports.createReservation = async (req, res) => { const t = await sequelize.transaction(); try { const { seatId } = req.body; const userId = req.user.id; // 预约时间:默认今天到馆后2小时内必须签到 const now = new Date(); const startTime = now; const endTime = new Date(now.getTime() + 2 * 60 * 60 * 1000); // 1. 查座位状态,条件必须带上 where 里的状态限制 const seat = await Seat.findOne({ where: { id: seatId, status: 0 }, // 只有空闲座位才能预约 transaction: t, }); if (!seat) { await t.rollback(); return res.status(400).json({ message: '该座位已被预约或不可用' }); } // 2. 校验该用户在同一时间段是否已有未完成的预约 const existing = await Reservation.findOne({ where: { user_id: userId, status: { [Op.in]: [0, 1] }, // 待签到或已签到 start_time: { [Op.lt]: endTime }, end_time: { [Op.gt]: startTime }, }, transaction: t, }); if (existing) { await t.rollback(); return res.status(400).json({ message: '你已有进行中的预约,不能重复预约' }); } // 3. 创建预约记录 const reservation = await Reservation.create({ user_id: userId, seat_id: seatId, reserve_date: now.toISOString().split('T')[0], start_time: startTime, end_time: endTime, status: 0, }, { transaction: t }); // 4. 更新座位为“已预约” await seat.update({ status: 1 }, { transaction: t }); // 5. 提交事务 await t.commit(); res.status(201).json({ message: '预约成功', reservationId: reservation.id }); } catch (error) { await t.rollback(); res.status(500).json({ message: '服务器内部错误' }); } };这里有个很容易被忽略的点:查询座位时一定要把status: 0放进where条件,而不是先查出来再在代码里判断。如果先查再判断,两个用户同时预约同一个座位时,可能都通过了代码判断,然后在更新时才冲突。放在where条件里配合事务能最大限度避免竞态。
签到的思路类似,改成校验seats.status === 1(已预约),并且校验当前时间在start_time和end_time之间:
// 签到 exports.signIn = async (req, res) => { try { const { reservationId } = req.params; const reservation = await Reservation.findOne({ where: { id: reservationId, user_id: req.user.id }, }); if (!reservation) return res.status(404).json({ message: '预约记录不存在' }); if (reservation.status !== 0) { return res.status(400).json({ message: '当前状态不可签到' }); } const now = new Date(); if (now < reservation.start_time || now > reservation.end_time) { return res.status(400).json({ message: '不在签到时间内' }); } await reservation.update({ status: 1, sign_in_time: now }); await Seat.update({ status: 2 }, { where: { id: reservation.seat_id } }); res.json({ message: '签到成功' }); } catch (error) { res.status(500).json({ message: '服务器内部错误' }); } };4.5 超时自动释放:定时任务的设计与坑
用户预约后如果2小时内没签到,系统必须自动取消预约并把座位状态改回空闲。这里我用了Node.js的setInterval定时任务,每5分钟扫描一次数据库:
注意:真实生产环境更推荐用node-cron或Bull队列,单机场景setInterval足够,但部署多个进程时会出现重复执行,需要分布式锁。
// utils/job.js const { Op } = require('sequelize'); const { Reservation, Seat } = require('../models'); // 每5分钟执行一次 setInterval(async () => { const now = new Date(); const expired = await Reservation.findAll({ where: { status: 0, end_time: { [Op.lt]: now }, }, }); for (const reservation of expired) { await Reservation.update( { status: 3 }, // 超时释放 { where: { id: reservation.id } } ); await Seat.update( { status: 0 }, { where: { id: reservation.seat_id } } ); } }, 5 * 60 * 1000);坑点:定时任务必须在app.js里require进来才会启动,很多人写了文件却忘了挂载,导致功能压根没生效。还有一点,这个扫描逻辑在数据量上来之后会有性能问题,适合座位数在几百以内的场景,再大的规模建议用Redis的过期键或在数据库层面做定时事件。
4.6 管理员接口:CRUD与统计
管理员接口没有太多技术含量,就是标准CRUD加上一层is_admin校验:
// middlewares/admin.js module.exports = function (req, res, next) { if (!req.user || req.user.is_admin !== 1) { return res.status(403).json({ message: '需要管理员权限' }); } next(); };统计接口我用了一个简单的SQL聚合查询,按天统计座位使用率:
// 统计今日各时段座位使用率 const stats = await Reservation.findAll({ attributes: [ [sequelize.fn('DAY', sequelize.col('start_time')), 'day'], [sequelize.fn('HOUR', sequelize.col('start_time')), 'hour'], [sequelize.fn('COUNT', sequelize.col('id')), 'count'], ], where: { start_time: { [Op.gte]: startOfToday }, status: { [Op.in]: [1, 4] }, // 实际被使用的记录 }, group: ['day', 'hour'], raw: true, });前端拿到这个数据后用ECharts画个柱状图,管理员能看到“上午10点使用率最高,下午3点次之”这种规律,就能针对性安排保洁和电源维护,这就是管理系统的深层价值。
5. 前端实现:从线框到可视化座位面板
后端逻辑跑通之后,前端的工作量主要集中在三个部分:初始化工程、座位可视化面板、以及与后端的交互封装。这一节我把每个关键步骤的操作细节都列出来。
5.1 用Vite快速初始化Vue 3项目
网上搜“vue项目实战”能看到不少基于Vue CLI的旧教程,我强烈建议用Vite,它和Vue 3是同一时期的产物,启动和构建速度完全碾压Vue CLI:
npm create vite@latest seat-frontend -- --template vue cd seat-frontend npm install npm run dev创建后是我习惯的目录结构:
seat-frontend/ ├── src/ │ ├── api/ # axios请求模块 │ │ ├── auth.js │ │ ├── seat.js │ │ └── reservation.js │ ├── components/ # 通用组件 │ │ ├── SeatGrid.vue # 座位网格面板 │ │ └── ReservationCard.vue │ ├── views/ # 页面级组件 │ │ ├── LoginView.vue │ │ ├── SeatMapView.vue # 座位总览页 │ │ ├── MyReservationView.vue │ │ └── AdminDashboardView.vue │ ├── router/index.js │ ├── stores/ # Pinia状态管理 │ └── App.vue5.2 座位网格组件:让状态一眼可见
座位面板是整个前端最核心的组件。我用CSS Grid画座位表格,不同状态用不同背景色区分:
<!-- components/SeatGrid.vue --> <template> <div class="seat-grid"> <div v-for="seat in seats" :key="seat.id" class="seat-item" :class="`seat-${seat.status}`" :disabled="seat.status !== 0" @click="handleSelect(seat)" > <span>{{ seat.seat_number }}</span> <span class="seat-status">{{ statusText(seat.status) }}</span> </div> </div> </template> <script setup> import { ref } from 'vue'; defineProps({ seats: { type: Array, required: true }, }); const emit = defineEmits(['select']); const statusText = (status) => { const map = { 0: '空闲', 1: '已预约', 2: '使用中', 3: '维护' }; return map[status] || '未知'; }; const handleSelect = (seat) => { if (seat.status !== 0) return; // 非空闲座位不可选 emit('select', seat); }; </script> <style scoped> .seat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; } .seat-item { padding: 16px; border-radius: 8px; cursor: pointer; text-align: center; border: 2px solid #e5e7eb; } .seat-0 { background: #ecfdf5; border-color: #34d399; } /* 空闲 */ .seat-1 { background: #fef3c7; border-color: #f59e0b; } /* 已预约 */ .seat-2 { background: #fee2e2; border-color: #ef4444; } /* 使用中 */ .seat-3 { background: #f3f4f6; border-color: #9ca3af; } /* 维护 */ </style>上面这段代码在选中后应该弹出一个确认框,让用户确认是否预约,确认后调用预约接口。记得不要省略防重复点击:在接口返回前,用一个loading状态把按钮禁掉,不然用户手一抖连点两下,会向后端发送两个预约请求。
5.3 路由守卫与Pinia状态管理
路由配置对应三个页面:登录注册页、座位总览页、我的预约页、管理后台页。核心在于路由守卫:
// router/index.js import { createRouter, createWebHistory } from 'vue-router'; import { useAuthStore } from '../stores/auth'; const routes = [ { path: '/login', component: () => import('../views/LoginView.vue') }, { path: '/', component: () => import('../views/SeatMapView.vue'), meta: { requiresAuth: true } }, { path: '/my', component: () => import('../views/MyReservationView.vue'), meta: { requiresAuth: true } }, { path: '/admin', component: () => import('../views/AdminDashboardView.vue'), meta: { requiresAuth: true, requiresAdmin: true } }, ]; const router = createRouter({ history: createWebHistory(), routes, }); // 全局前置守卫 router.beforeEach((to, from, next) => { const authStore = useAuthStore(); if (to.meta.requiresAuth && !authStore.token) { next('/login'); } else if (to.meta.requiresAdmin && !authStore.isAdmin) { next('/'); } else { next(); } });状态管理这里,热词榜有“vue pinia vs vuex”,直接说结论:用Pinia,不要用Vuex。Pinia是Vue官方团队的新一代状态管理库,TypeScript支持更好,API更简洁,Vuex的Mutation和Action的繁琐区分在Pinia里完全消失。Vuex已经进入了维护模式,新项目没有理由再用它。
5.4 axios封装:拦截器处理token和错误码
前端和后端交互时必须有一个统一的请求封装,不然每个页面的错误处理会写得非常散乱:
// api/request.js import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '../router'; const service = axios.create({ baseURL: '/api', // 开发环境走Vite代理 timeout: 10000, }); // 请求拦截器:自动附加token service.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // 响应拦截器:统一错误处理 service.interceptors.response.use( (response) => response.data, (error) => { const status = error.response?.status; if (status === 401) { // token过期或无效,清掉本地用户信息并跳转登录 localStorage.removeItem('token'); localStorage.removeItem('user'); router.push('/login'); ElMessage.error('登录已过期,请重新登录'); } else { ElMessage.error(error.response?.data?.message || '网络请求失败'); } return Promise.reject(error); } ); export default service;这段代码最大的价值在于:全局只处理一次401,避免每个页面重复写跳转逻辑。
5.5 实时刷新:轮询方案的选择与取舍
座位状态是会变化的(别人预约、签到、超时释放),前端必须定时拉取最新状态。技术上有三种方案:
| 方案 | 实时性 | 实现复杂度 | 服务端压力 | 适用场景 |
|---|---|---|---|---|
| 前端定时轮询 | 取决于间隔(我设20秒) | 低 | 中 | 本系统 |
| SSE(服务端推送) | 秒级 | 中 | 低 | 单向通知 |
| WebSocket | 实时 | 高 | 低 | 双人互动/多端强一致 |
我选轮询是因为自习室场景对秒级实时性没有要求,20秒刷新一次座位面板完全够用,而且前后端都无需引入额外依赖。前端在onMounted时启动定时器,在onUnmounted时清除,防止页面跳转后定时器泄漏:
import { onMounted, onUnmounted, ref } from 'vue'; import { getSeatList } from '../api/seat'; const seats = ref([]); let timer = null; const fetchSeats = async () => { seats.value = await getSeatList(); }; onMounted(() => { fetchSeats(); timer = setInterval(fetchSeats, 20000); }); onUnmounted(() => { if (timer) clearInterval(timer); });5.6 打包后的经典翻车现场
前端开发完要npm run build,构建产物放在dist目录。这里有两个经典问题,对应的热词就是“vue 打包后 布局异常”。
问题一:静态资源路径404。默认打包后index.html里引用资源的路径是/assets/xxx.js,如果你的站点部署在子路径(比如http://ip:8080/seat/)下,就会找不到文件。解决办法是在vite.config.js里配置:
export default defineConfig({ base: '/seat/', // 改成你的实际部署路径,根路径就是 '/' });问题二:Vue Router的history模式刷新404。使用createWebHistory时,前端路由是真正的URL路径(如/admin),刷新页面时服务器去磁盘找/admin这个文件,找不到自然404。如果没法在Nginx层面配置try_files,最省事的办法是改用createWebHashHistory,路径变成/#/admin,刷新就不会404了。
提示:我自己的项目在测试环境用hash模式,部署到Nginx后改回了history模式并配置了
try_files。多一层配置多一份体验,但也多一个排错点,新手建议从hash模式起步。
6. 前后端联调与部署上线
代码写得再漂亮,联调阶段也会暴露一堆问题。我把这一阶段最容易翻车的几个点单独拎出来讲,并给出完整的部署路径。
6.1 联调中最容易忽视的三个坑
字段命名不一致。后端在Sequelize默认会把user_id自动映射为userId(如果配了underscored: true就是user_id),但前端可能已经按另一种形式写死了。联调前先统一字段风格,建议后端全部用snake_case,前端axios层做一层字段映射转换。
时间格式化。后端返回的start_time是ISO字符串或Date对象,前端展示时如果不做格式化,用户看到的就是一串2024-05-20T09:30:00.000Z。用dayjs库做统一格式化:
import dayjs from 'dayjs'; const formatTime = (isoStr) => dayjs(isoStr).format('YYYY-MM-DD HH:mm:ss');跨域。这是必踩的坑。开发环境下,前端在localhost:5173,后端在localhost:3000,浏览器会拦截跨域请求。最优雅的解决方案是在vite.config.js里配置代理,让前端请求的/api路径被转发到后端:
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, // 如果有路径重写需求,用下面这行 // rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, });配置后前端直接用/api/...请求,浏览器认为就是访问localhost:5173的接口,不再有跨域问题。
6.2 生产环境部署的完整流程
部署方案因人而异,我用的是一台Linux服务器 + Nginx + pm2 + MySQL,这也是中小项目最经典的组合。流程如下:
第一步:后端代码部署
# 进入项目目录 cd /var/www/seat-server # 安装生产依赖 npm install --production # 用pm2守护进程 npm install -g pm2 pm2 start app.js --name seat-server pm2 save pm2 startup # 按提示执行生成的命令,实现开机自启pm2的一个核心价值是进程守护:Node进程崩溃后能自动重启,并且重启策略可以配置:
pm2 start app.js --name seat-server -i 1 --max-memory-restart 500M第二步:构建前端
cd /var/www/seat-frontend npm run build # dist目录就是需要托管的静态文件第三步:配置Nginx
server { listen 80; server_name your_domain_or_ip; # 托管前端静态文件 root /var/www/seat-frontend/dist; index index.html; # 前端路由history模式配置 location / { try_files $uri $uri/ /index.html; } # API反向代理到Node服务 location /api/ { proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里最需要注意的是proxy_pass后面的/:http://127.0.0.1:3000/末尾带斜杠表示把/api/xxx重写为/xxx转发到后端。如果不带斜杠,转发的URL会保留/api前缀,后端路由匹配不到。
6.3 部署后的验证清单
部署完成后按这个顺序自测,能覆盖绝大多数问题:
1. 打开首页,确认能正常显示登录页 2. 注册一个新账号,确认接口通、数据库能写入 3. 登录后进入座位面板,确认能拿到座位列表 4. 选一个空闲座位预约,确认座位颜色从绿色变黄色 5. 用另一个浏览器开无痕窗口,确认该座位对别人显示为“已预约” 6. 过2小时不签到(或手动改数据库时间),确认真实座位置灰 7. 管理员登录进入后台,确认能改座位状态、看统计图7. 复盘与后续扩展方向
整个项目从立项到上线,我前后花了两周左右,大部分时间消耗在环境配置和联调测试上。真正写业务代码的时间其实很短。复盘一下,这套系统有几个做得好的地方,也有不少遗憾。
做得好的地方是预约状态机设计得比较清晰,0待签到、1已签到、2已取消、3超时释放、4已完成,所有状态的流转都被约束,不会出现数据不一致。另外就是前端座位面板的交互足够直观,用户扫一眼就能知道哪个座位能坐,无需学习成本。
不足也很明显:一是没有短信或微信通知,用户容易忘记签到时间;二是抢占并发的极端场景没有做兜底,两个用户同时预约最后一个座位时虽然不会超卖,但后一个请求会直接报错,体验不够平滑;三是没有管理员手动换座和预约审批的功能,在实际运营中管理员经常需要调整座位。
后续如果要扩展,优先级我认为是这样:
- 消息通知:接入企业微信群机器人或邮件服务,预约成功和签到提醒自动推送
- 小程序端:用uni-app写一套跨端小程序,方便用户在手机上预约
- 签到方式升级:改成座位二维码扫码签到,避免“到馆了还要打开网页找按钮”
- 引入Redis:座位状态用Redis存储,读取性能更优,也方便以后做分布式部署
- 与硬件联动:座位安装智能插座或传感器,真实使用数据回传,彻底解决“签了到人不在”的问题
最后再分享一个小技巧:这类系统做完之后,强烈建议写一份部署文档放进Git仓库。我就是因为当初偷懒没写,过了两个月要给别人演示时,折腾了一个下午才想起来Nginx配置里有个地方改了没记录。项目本身不难,难的是把过程沉淀成别人能复现、自己能回忆的东西。