剧本杀预约管理系统从0到1:Node.js+Vue+ElementUI实战
2026/9/16 4:15:58 网站建设 项目流程

帮朋友的剧本杀门店做预约管理系统,这事儿一开始我是拒绝的——总觉得一个小店用微信群接龙就够了。结果真正去店里蹲了两天,看到前台小姐姐在三个微信群里翻聊天记录手动排期,还有客户一遍遍问“今晚还有没有位置”,我才意识到问题有多严重。这篇文章记录的就是这套基于Node.js + Vue + ElementUI的剧本杀预约管理系统从0到1的设计、开发、部署全过程,包括技术选型的考量、核心功能的实现思路,以及我在Node.js环境配置、ElementUI版本兼容和打包部署环节踩过的一堆坑。给准备做同类型预约管理系统的同学一份参考,也给自己留一份项目复盘。

1. 需求梳理:流量入口再多,不如一套系统管到底

任何一个项目,动手写代码之前最忌讳的就是“拍脑袋定功能”。我连着跑了三天门店,晚上回家把观察到的业务流、客户行为、店员的抱怨全部记下来,然后才决定系统到底要做什么。

1.1 剧本杀门店预约的三大痛点,用技术怎么解决

先说结论,预约管理的核心痛点其实不复杂,无外乎三个:预约渠道混乱、排期冲突频发、爽约成本高。

预约渠道这块,很多门店是微信群、电话、美团点评三个入口同时开。客户在群里说“周六晚上7点给我留一个《病娇男孩的精分日记》”,店员口头答应,但没有统一记录,这种订单十有八九会漏。系统要解决的问题就是“统一入口”:所有场次余位、房间状态都以系统数据为准,任何渠道来的客户都要在系统里落一笔预约记录。

排期冲突就更典型了。同一个房间,店长上午在Excel里给A组排了场,下午又把同一时段给了B组,两头都答应了,周末就只能吵架。系统层面解决这个问题的办法很简单——在创建场次时做校验,同一房间、同一时间段不允许存在两个未结束的场次,冲突的排期在源头就拦截掉。

爽约成本则对应预约状态管理和提醒机制。客户下单后收到确认消息,临近场次再有短信或公众号模板消息提醒,对爽约率有非常明显的抑制作用。后面我会讲这套预约状态机是怎么设计的。

1.2 系统使用角色与功能边界划分

梳理完痛点,还要把“谁在用”搞清楚。这套系统最终只服务三类角色,功能边界如下:

角色核心诉求功能权限
普通客户快速找到可约场次,下单不被拒注册登录、剧本浏览、场次查询、发起预约、取消预约
门店店员排期清晰、核销方便剧本管理、房间管理、场次排期、订单确认、到场核销
系统管理员数据在手、经营有数用户管理、订单管理、数据统计、基础参数配置

功能模块上我做成了用户端和管理端两个界面。用户端走的是“看剧本—选场次—下单”三步流程,管理端是“上剧本—排场次—核订单”的日常运营循环。两边共用一个后端接口,后续如果要扩展小程序或App,前端重写、后端基本不动。

2. 技术选型与架构设计:别迷信新框架,适合自己的才最稳

技术选型上,我核心的考量是“团队熟不熟”“生态全不全”“项目复杂度需不需要上重型框架”这三件事。这套组合选下来不是什么黑科技,但贵在稳。

2.1 后端为什么选Node.js + Express

后端选Node.js而不是Java、Go,最直接的原因是团队全是JavaScript工程师,前后端语言统一,不需要在脑内来回切换上下文。预约类系统的并发压力集中在查场次、查余位这些轻量查询操作上,Node.js的异步事件模型正好擅长这种I/O密集场景。

框架层面我没有直接裸写http模块,而是用了Express。Express足够轻,中间件生态极其成熟,JWT认证、参数校验、文件上传都有现成的方案。你甚至可以几行代码就把一个路由立起来:

// backend/routes/appointment.js const express = require('express'); const router = express.Router(); const { createAppointment } = require('../controllers/appointment'); router.post('/', authRequired, createAppointment); module.exports = router;

当然,选Express也意味着一些“重”的事情比如权限细化、分布式会话需要自己处理。但对于一家剧本杀门店的预约场景,这根本不是问题。

2.2 前端选Vue + ElementUI的版本陷阱

前端选了Vue 2.6 + ElementUI 2.15这套组合。这里必须提醒一句:ElementUI和ElementPlus是两套完全不同的东西——ElementUI对应Vue 2,ElementPlus对应Vue 3,API有差异,网上教程经常混着写,刚入门的人最容易在这里栽跟头。我这个项目启动时团队对Vue 2最熟,ElementPlus还没完全稳定,生态里第三方组件也少,所以果断选了Vue 2 + ElementUI的组合,跑完整期项目,结论是:稳。

ElementUI最大的价值在于,后台管理系统需要的那堆表格、弹窗、表单、日期选择器,它全都封装好了。你不用自己从零画一个日历组件,el-calendar拉出来配几个属性就能用。预约管理系统的业务逻辑集中在数据处理,前端最大的成本本来就不在样式而在数据交互,ElementUI刚好把这些繁琐的UI工作省掉了。

2.3 前后端分离架构与项目目录结构

整体架构是典型的前后端分离:Vue CLI构建前端,通过axios调用后端API;后端Express监听3000端口;MySQL 5.7做数据存储;ORM用Sequelize简化数据库操作;JWT负责登录态和权限控制;文件上传用multer中间件。部署时前端打包成静态文件丢给Nginx,后端用PM2守护在3000端口,Nginx再把/api请求反向代理过去。

最终项目目录长这样:

├── frontend # Vue 前端 │ ├── src │ │ ├── api # axios 接口封装 │ │ ├── router # 路由配置 │ │ ├── store # Vuex 状态管理 │ │ ├── views # 页面组件 │ │ ├── components # 公共组件 │ │ └── utils # 工具函数 │ └── package.json ├── backend # Node.js 后端 │ ├── app.js # Express 入口 │ ├── config # 数据库配置 │ ├── routes # 路由定义 │ ├── controllers # 业务逻辑 │ ├── models # Sequelize 模型 │ ├── middlewares # 中间件(JWT、权限、上传) │ └── package.json

这样拆的好处是职责清晰,前端团队和后端团队可以并行开发,只需要提前约定好接口文档。即便你们是单人开发,这样分层也能让你后面维护时不至于在一个文件里翻几百行代码。

3. 数据库设计与核心业务逻辑:状态机才是预约系统的灵魂

数据库是这类系统最不能含糊的部分。预约系统如果表设计乱掉,后期改起来是噩梦级别。我依据业务对象把核心表拆成了六张,每张表都围绕“避免冲突、防止超卖”这个核心原则来设计。

3.1 六张核心表设计,先理清实体关系

第一张是用户表users,字段包含手机号、密码(加密存储)、昵称、角色标识。这里角色我用的是整型字段role,1普通用户、2店员、3管理员,不用单独建角色表。

第二张是剧本表scripts,存剧本名称、封面图、题材类型、人数区间、时长、难度。核心字段是min_people和max_people,因为预约时要做人数匹配。

第三张是房间表rooms,字段包含房间名称、容纳人数、主题风格、设备状态。房间和剧本是多对多关系,但项目初期不拆中间表,直接在场次表里挂room_id和script_id。

第四张是场次表sessions,这是整个系统最核心的表。字段包含:所属房间room_id、剧本script_id、场次开始时间start_time、结束时间end_time、当前预约人数booked_num、可预约上限max_people、场次状态status。

第五张是预约表appointments,字段包含用户ID、场次ID、预约人数、预约状态、备注信息。这里预留了人数字段,因为同一个用户可能一次帮朋友预约好几人的位置。最后是订单日志表,用来记录一些关键操作,方便复盘。

场次表和预约表的关系是这样:一个场次下有多个预约订单,预约人数累加起来就是当前场次已占座数。创建场次的SQL大致是:

CREATE TABLE sessions ( id INT AUTO_INCREMENT PRIMARY KEY, room_id INT NOT NULL, script_id INT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, booked_num INT DEFAULT 0, max_people INT NOT NULL, status TINYINT DEFAULT 1 COMMENT '1可约 2满员 3已取消 4已完成', INDEX idx_room_time (room_id, start_time, end_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3.2 场次冲突检测:怎么拦住撞期排单

这是整个系统最关键的校验逻辑。我采用的是“重叠时间段检测”思路:一个房间在任意时刻只能承载一场游戏。当店长或管理员创建新场次时,后端要查一遍该房间已有场次,看是否存在时间重叠。

// backend/controllers/session.js const { Op } = require('sequelize'); const Session = require('../models/Session'); async function checkRoomConflict(roomId, startTime, endTime, excludeId = null) { const where = { room_id: roomId, status: 1, start_time: { [Op.lt]: endTime }, end_time: { [Op.gt]: startTime } }; if (excludeId) where.id = { [Op.ne]: excludeId }; const conflict = await Session.findOne({ where }); return Boolean(conflict); }

这段代码的思路是:若已有场次的开始时间早于新场次结束时间,且已有场次的结束时间晚于新场次开始时间,那么两个场次必然重叠,判定为排期冲突。这个判断方式比只比较“是否同一个小时”要严谨得多,能处理跨小时的长场次。

我建议在调用创建接口时把这段校验放到事务里执行,防止两个请求同时进来都查不到冲突、然后都创建成功的情况。数据库层面,可以再给room_id、start_time、end_time建联合索引,在数据量大时也能保证查询速度。

3.3 预约流程与订单状态机设计

预约状态不能只做一个简单的“下单成功”,要能覆盖整个生命周期。我的设计是四态流转:

  • 状态1:待确认(已预约,等待店员确认)
  • 状态2:已确认(店员确认接受预约)
  • 状态3:已完成(到场消费并核销)
  • 状态4:已取消(用户或店员取消预约)

下单的核心逻辑是“事务+防超卖”。当一个用户发起预约请求时,后端同时要做三件事:检查场次状态是否为可约、检查当前预约人数加上本次预约人数是否超过上限、在事务中创建预约记录并把场次booked_num加一。三件事必须要么全部成功要么全部失败,不然就会出现“扣了库存但没有订单”或“有了订单但库存没扣”的bug。

// backend/controllers/appointment.js const sequelize = require('../config/database'); const Appointment = require('../models/Appointment'); const Session = require('../models/Session'); async function createAppointment(req, res) { const t = await sequelize.transaction(); try { const { session_id, people_num, remark } = req.body; const userId = req.user.id; const session = await Session.findByPk(session_id, { transaction: t, lock: t.LOCK.UPDATE }); if (!session || session.status !== 1) { await t.rollback(); return res.status(400).json({ message: '该场次不可预约' }); } if (session.booked_num + people_num > session.max_people) { await t.rollback(); return res.status(400).json({ message: '该场次余位不足' }); } await Appointment.create({ user_id: userId, session_id, people_num, status: 1, remark }, { transaction: t }); await Session.update( { booked_num: session.booked_num + people_num, status: session.booked_num + people_num >= session.max_people ? 2 : 1 }, { where: { id: session_id }, transaction: t } ); await t.commit(); res.status(201).json({ message: '预约成功' }); } catch (err) { await t.rollback(); res.status(500).json({ message: '服务器内部错误' }); } }

这里有一个容易被忽略的经验:查询场次时必须加行级锁,也就是Sequelize里的lock: t.LOCK.UPDATE,否则两个并发请求同时读到booked_num=9、max_people=10,各自都认为还有1个余位,最后就会超卖。网上很多教程压根不提锁,等到线上并发一大就现原形。

4. 前端页面与ElementUI交互实现

前端的核心目标是让客户和管理员都“点得顺手”。我没做花哨的动效,重点都放在信息清晰和操作流畅上。

4.1 页面骨架与路由设计

用户端和管理端我拆成两套路由。用户端路由包括:首页(剧本推荐列表)、剧本详情页、场次预约页、我的订单页。管理端路由包括:工作台(今日场次概览)、剧本管理、房间管理、场次排期、预约订单、数据统计。路由的懒加载我是做了的,避免首屏一次性加载所有页面导致白屏时间过长。

路由守卫是预约系统必须做的。没有登录的客户访问“我的订单”会被重定向到登录页;店员角色访问“数据统计”会被拦截并提示无权限。通过Vue Router的beforeEach钩子配合Vuex里保存的用户信息,几十行代码就能搞定。

4.2 ElementUI组件选型与预约日历实现

管理端最常用的是el-table、el-dialog、el-form、el-tag这几个组件。剧本管理列表用el-table展示,弹窗里用el-form做新增和编辑,封面图上传用el-upload,配合后端multer接口,用户上传封面图后拿到图片URL回显。这一套组合你在任何后台项目里都见得到,ElementUI的优势就是让你不用重复造轮子。

用户端预约页,我用了el-calendar做日期选择。选好日期后,下方场次列表用el-card渲染当天所有可约场次。场次卡片上展示剧本封面、开始时间、剩余人数,剩余人数小于等于3人时用el-tag标红“仅剩X位”,这个细节对客户决策影响很大,实测能有效提升成交率。

预约页在移动端上也需要适配得好,店铺的客户绝大多数是用手机访问。ElementUI虽然定位是后台组件库,但栅格系统加媒体查询仍然能做出基础移动端适配。这个项目我前后端都做了响应式处理,用户端在手机和平板上体验基本达标。

4.3 几个ElementUI实用小技巧

做管理端时我用到了几个比较实用的小功能,是那种官方文档有但你很难一眼想到的:

  • 文字超出隐藏、鼠标悬浮显示全部:用el-tooltip包一层,配合CSS的overflow: hidden; text-overflow: ellipsis; white-space: nowrap;,表格里超长的剧本介绍和备注就不会撑破布局。
  • 弹窗加载PDF:剧本详情或用户协议需要预览PDF时,可以在el-dialog里嵌入iframe,通过后端返回的文件流或静态文件路径加载。ElementUI弹窗本身不做PDF解析,但配合第三方库或原生iframe,实现很顺手。
  • 多选周组件:排期页要做“每周重复排班”功能时,直接基于el-checkbox-group封装一个周选择器,比在日历上逐个点选效率高得多。

5. 后端接口与权限控制设计

预约系统的接口设计我全程遵循RESTful风格,方法路径尽量表达语义。下面列出核心接口:

方法路径功能权限
POST/api/auth/register用户注册公开
POST/api/auth/login用户登录公开
GET/api/scripts剧本列表(支持分页和筛选)公开
GET/api/scripts/:id剧本详情公开
GET/api/sessions按日期或剧本查询场次登录用户
POST/api/appointments创建预约登录用户
GET/api/appointments/my查看我的预约登录用户
PUT/api/appointments/:id/cancel取消预约本人/店员
PUT/api/appointments/:id/confirm确认预约店员/管理员
PUT/api/appointments/:id/finish核销完成店员/管理员
GET/api/admin/stats门店数据统计管理员

权限控制用JWT中间件实现。登录成功后后端签发一个携带用户ID和角色的token,前端存到localStorage,每次axios请求在拦截器里带上Authorization: Bearer <token>。后端在需要鉴权的接口上挂中间件,验证token有效性并解析出用户身份。

// backend/middlewares/auth.js const jwt = require('jsonwebtoken'); const SECRET = process.env.JWT_SECRET || 's3cret_key_here'; function authRequired(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) return res.status(401).json({ message: '未登录或登录已过期' }); try { req.user = jwt.verify(token, SECRET); next(); } catch (e) { return res.status(401).json({ message: 'token无效' }); } } function roleRequired(...roles) { return (req, res, next) => { if (!req.user || !roles.includes(req.user.role)) { return res.status(403).json({ message: '没有操作权限' }); } next(); }; }

有两点实操经验值得单独拎出来说。第一,JWT密钥一定不要硬编码在代码里,要用环境变量注入。项目里我建了一个.env文件,部署时在服务器上单独配置,这样代码即使传到公开仓库,别人也拿不到生产环境的密钥。第二,前端路由的权限控制只能决定“显示或隐藏按钮”,真正的安全防线必须放在后端接口校验层。前端删掉一个按钮很容易,但直接拿接口工具请求风险是挡不住的。

6. 环境配置与踩坑记录:从安装报错到打包布局全复盘

这个项目开发过程中,环境配置和构建部署阶段遇到的坑,比业务逻辑本身还多。我把这些典型问题整理出来,省得大家重复踩。

6.1 Node.js安装与环境变量配置,含2203报错

Node.js安装本身不难,去官网下载LTS版本,双击一路Next就行。但不少人卡在安装完成后,命令行运行node -v提示“不是内部或外部命令”。这多半是环境变量没配好。安装目录下的路径默认是C:\Program Files\nodejs\,需要你确认该目录是否在系统PATH环境变量里。

我还在Windows上遇到过安装到一半报2203错误的场景,这是操作系统权限导致的问题。排查方向有两个:一是以管理员身份运行安装包,右键选择“以管理员身份运行”;二是安装路径不要放在系统盘Program Files目录下,装到只有用户名控制的目录(比如D:\nodejs),能少掉一大半权限相关的幺蛾子。安装完成后,用node -vnpm -v把前后端环境都验证一遍,这步不要跳。

6.2 PowerShell执行策略导致npm命令不可用

开发前配置环境时,很多同学在PowerShell里输入npm直接报红字:

npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本

这个报错原因很简单:Windows PowerShell的默认执行策略是Restricted,不允许执行任何.ps1脚本,而npm的PowerShell包装脚本恰好就是.ps1格式。解决办法是用管理员身份打开PowerShell,执行:

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

然后输入Y确认,再重开一个PowerShell窗口,npm就能正常使用了。如果你不想改执行策略,退而求其次用cmd运行npm也没问题,但还是要建议把执行策略改成RemoteSigned,因为很多npm脚本和前端工具链在PowerShell下跑得更顺畅。

6.3 Vue绑定ElementUI的版本选择与ElementPlus迁移

ElementUI 2.x版本和Vue 3完全不兼容,如果项目升级到了Vue 3,UI组件库必须换ElementPlus。这两个库虽然API长得像,但细节差异不少:ElementPlus的组件全部用el-前缀加小写命名,弹窗的visible属性改成v-model,表格的slot-scope作用域插槽写法也有变化。

如果你是从老项目升级,我建议不要直接“就地换库”,因为这个过程几乎等同于重写所有页面组件。正确姿势是:先把公共组件和全局样式抽取出来,再逐步用ElementPlus重写页面,新旧组件可以共存一段时间,等验证新页面稳定后再彻底删除ElementUI的依赖。否则贸然全部替换,打包出的问题会非常难排查。

6.4 Vue打包后布局异常和路由白屏

项目上线前,前端npm run build打包出来丢到服务器上,结果页面打开一片空白,控制台报资源404。这是典型的静态资源路径问题。Vue CLI默认的publicPath/,意味着它会在根路径下找JS和CSS文件,如果部署在子目录或者通过非根路径访问,当然找不到。

解决办法是在vue.config.js里把publicPath改成相对路径'./'

module.exports = { publicPath: './', outputDir: 'dist', assetsDir: 'static' };

还有一类白屏是路由模式导致的。前端用的history模式,刷新页面时Nginx找不到对应的路由地址,会返回404。解决方案是Nginx配置try_files做回退:

location / { try_files $uri $uri/ /index.html; }

这样即便请求的是/appointments这类前端路由,Nginx也会把index.html返回给前端,由Vue Router接管路由渲染,刷新就不会白屏了。

7. 系统测试、部署上线与后续扩展

系统的功能测试我放在最后单独讲,因为很多项目在功能开发完成后就“自我感觉良好”直接上线,结果现场翻车。预约系统涉及资金和客户体验,上线前务必把异常路径都测一遍。

7.1 接口测试的基本要点

接口测试的核心不是测“正确路径”,而是测“异常路径”。正常创建预约能成功是应该的,关键是验证:同一个用户重复提交同一个场次、场次余位数只够1人时一次性提交3人、一个场次同时被多个用户抢最后两个座位、取消预约后又重新预约。这些场景我都用Postman和并发脚本压过。

我建议你至少写一遍这样的并发测试脚本:使用Node.js的axios库,模拟20个并发请求预约同一个只有10个名额的场次。如果最终成功创建的预约记录不超过10条,并且场次booked_num刚好等于10,说明事务和锁逻辑是可靠的。这个测试我们做了三轮,前两轮都发现了超卖问题,修复锁机制后才通过。

7.2 PM2 + Nginx部署配置

部署方案我用的是PM2加Nginx。后端代码上传到服务器后,在项目根目录执行:

npm install --production pm2 start backend/app.js --name appointment-api

PM2的好处是自带进程守护和日志管理,Node进程挂了能自动重启,线上稳定性有保障。前端打包产物上传到Nginx的静态目录,再配置反向代理把/api路径转发到3000端口:

server { listen 80; server_name example.com; root /var/www/appointment/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:3000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }

这个配置上线运行后非常稳定。唯一要注意的是,后端服务监听地址别写localhost,要监听127.0.0.1,并且Nginx和PM2的权限要分清楚,不要图省事让Nginx以root身份运行,能规避很多安全隐患。

7.3 这个系统还能怎么扩展

这套预约系统虽然叫“剧本杀预约管理系统”,但其实稍微改改业务字段,完全可以复用到密室逃脱、桌游吧、甚至是推拿理疗这类预约场景。当初把剧本、房间拆成独立表而不是写死字段,就是为了后面的可扩展性。

后面如果要继续做深,可以加两个方向:一个是营销侧,增加会员等级体系、积分抵扣、老客复购优惠券,这些能直接提升门店复购率;另一个是自动化运营,例如开场前一小时的爽约自动释放名额、超时未确认自动取消、场次结束后自动推送战报让客户发朋友圈,能大幅减轻店员的操作负担。

我个人在实际开发里最大的体会是:预约系统的命门不是界面多好看,而是数据一致性——排期不能撞、余位不能超。把事务、锁、状态机这三件事想透,这个项目就算成功了一半。最后再说一个小技巧:npm脚本报错时,先看看是不是Windows执行策略的问题;ElementUI组件不生效时,先确认版本和Vue的匹配关系,这两类问题占了前端新手踩坑的大头。项目做下来,技术含量不算高,但把一个真实业务跑顺,需要的细致和耐心,远比想象中多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询