资讯详情

Vue+Node.js全栈实战:体育馆场地在线预订系统设计与实现

📅 2026/10/21 8:31:00 | 华诺云谱 👁 阅读
Vue+Node.js全栈实战:体育馆场地在线预订系统设计与实现
接到这个项目的时候需求方只说了一句话我们体育馆的场地预订还在靠微信群接龙高峰期根本排不过来你帮我搞一个在线预订系统。就这句话让我把 vue nodejs ElementUi 这套组合从头到尾走了一遍完整真实的全栈实现。这个系统不是造火箭核心目标就是让用户能在线选场地、选时间段、下单付费让管理员能在后台审核订单、维护场地、看营收。但麻雀虽小五脏俱全登录鉴权、并发防冲突、订单状态管理、前后端分离部署全都有。这篇我打算把整个设计和实现过程拆开讲适合刚接触真实项目开发的同学拿来当参考也适合想独立接全栈项目但不知道从哪下手的开发者直接照抄思路。1. 先搞清楚业务本身再动手比写代码重要1.1 体育馆场地预订到底难在哪体育馆的预订场景和电商购物有很大区别。电商卖的是实物库存只要库存足够就能卖场地预订卖的是“某一个场地在某一个时间段的使用权”同一块场地同一段时间只能卖给一个用户。这里有一个天然约束叫唯一可用性听起来简单落到业务上就非常麻烦。我接手的这家体育中心有6个羽毛球场地、2个篮球半场、3张乒乓球台。原来的流程是电话预约管理员拿纸质登记本手写。周五晚上、周末白天是高峰期经常出现两个老顾客同时要订同一个场地管理员只能在电话里反复协调一不小心就重复登记。更麻烦的是用户问“明天下午还有空场吗”管理员要翻半天本子才能回答。线下流程的所有痛点本质都是“场地状态不透明”。所以这个系统首先要解决的不是“下单”而是“状态可感知”。用户登录进来要能看到今天、明天、后天每一个场地每个时段是不是可订管理员在后台要能一眼看出某个日期已经订了哪些场次。用户下单后状态要立刻从可订变成已占所有人看到的结果一致。1.2 需求方一句大白话拆成了这些功能模块把“帮我搞个在线预订系统”拆开之后功能边界大致是这样的。用户端主要包含注册和登录、场地列表展示、按日期查看可订时段、提交预订订单、订单列表、取消订单。管理端主要包含场地信息维护、时段模板和价格设置、订单审核和核销、用户禁用、基础数据统计。再加上公共能力比如登录鉴权、接口权限控制、参数校验、超时未支付订单自动释放。这里我要提醒一下接项目时最怕的不是需求多而是需求边界不清。需求方一开始还说要“自动通知”“会员积分”“小程序端”我全部先记进待办列表第一版只做上面这些核心功能。因为预订系统的核心闭环是“查询、占位、确认、核销”其它都是锦上添花。把边界圈住才有可能在三周左右真正上线。1.3 用户角色与权限怎么设计这个系统有普通用户和管理员两种主要角色外加一个用来兜底的超管概念。普通用户只能操作自己的订单和个人资料管理员可以查看所有订单、审核订单、维护场地和价格超管在管理员基础上多了禁用用户、设置系统参数的权限。权限在前端体现为路由守卫在后端体现为中间件。后端不能只靠前端隐藏按钮来防越权那样太危险了。每个管理员接口都必须校验 token 里解析出来的角色而不是直接信任前端传的参数。这一点后面讲部署时还会再提。2. 技术选型vuenodejsElementUi 为什么是这套组合2.1 前端选 Vue 和 Element UI 的原因选型时我考虑过 React、Vue、Angular最后选了 Vue 2.6 加 Element UI。原因很实际。第一团队里最熟的是 Vue上手成本最低第二Element UI 的后台管理组件非常成熟表格、表单、日期选择器、弹窗这些后台高频组件都是现成的能省掉大量样式和交互开发时间第三Vue 的中文资料多遇到问题搜解决方案几乎不费劲。这里有个版本问题要先说清楚Element UI 是 Vue 2 生态的组件库如果用 Vue 3通常要改用 Element Plus。很多刚入门的同学在 npm 里装了 element-ui结果项目用的是 Vue 3一启动就报错纯属版本不匹配。我这个项目用的是 Vue 2.6 加 Element UI 2.15文档对得上稳定性完全没问题。2.2 后端为什么选 Node.js后端我犹豫过 Spring Boot 和 Node.js。Spring Boot 在企业里确实更常用但项目不大团队又都是前端出身Node.js 明显更合适。前后端都是 JavaScript模型结构、时间处理、JSON 解析都能共用一套思路上下文切换成本非常低。另一个原因是负载模型。体育馆预订系统的并发量不会特别高高峰期可能也就几百人同时刷新页面真正产生写操作的并发非常有限。Node.js 单线程模型对这类 IO 密集型应用完全扛得住瓶颈通常在数据库不在 Node 本身。当然如果预计有大几千人同时抢同一个场次那就要在架构上做更多设计比如引入消息队列或分布式锁这是后话。2.3 工程结构怎么搭项目分了 frontend 和 server 两个独立目录按前后端分离的方式开发。目录结构大致如下booking-system/ ├── frontend/ │ ├── public/ │ ├── src/ │ │ ├── api/ # 封装 axios 请求 │ │ ├── components/ # 公共组件 │ │ ├── views/ │ │ │ ├── Login.vue │ │ │ ├── Courts.vue │ │ │ ├── Booking.vue │ │ │ ├── Orders.vue │ │ │ └── admin/ │ │ ├── router/ │ │ ├── store/ │ │ └── utils/ │ └── package.json └── server/ ├── app.js ├── config/ ├── routes/ ├── controllers/ ├── middleware/ ├── models/ ├── utils/ └── package.json后端框架选的是 Express 4数据库驱动用的 mysql2。有人会问为什么不用 Sequelize 这类 ORM。我承认 ORM 能少写很多 CRUD但这个项目查询其实很简单而且下单防冲突需要手写事务和行锁直接用 mysql2 原生 SQL 反而更可控。所以我选了“连接池加手写 SQL”的方式后续要加复杂查询也不至于被 ORM 的生成规则卡住。3. 数据库设计把并发的坑提前堵住3.1 核心表怎么设计按业务拆解主要设计了这几张表用户表、场地表、时段模板表、订单表。下面是关键表结构。用户表CREATE TABLE users ( id INT NOT NULL AUTO_INCREMENT, phone VARCHAR(20) NOT NULL COMMENT 手机号登录账号, password VARCHAR(255) NOT NULL COMMENT bcrypt 哈希值, nickname VARCHAR(50) DEFAULT , role TINYINT NOT NULL DEFAULT 1 COMMENT 1用户 2管理员 3超管, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, created_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;场地表CREATE TABLE courts ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 场地名称如羽毛球1号场, sport_type VARCHAR(20) NOT NULL COMMENT 羽毛球/篮球/乒乓球, location VARCHAR(100) DEFAULT , price_per_hour DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1开放 0停用, sort_no INT DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;时段模板表CREATE TABLE court_slots ( id INT NOT NULL AUTO_INCREMENT, court_id INT NOT NULL, start_time VARCHAR(5) NOT NULL COMMENT 09:00, end_time VARCHAR(5) NOT NULL COMMENT 10:00, PRIMARY KEY (id), KEY idx_court (court_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表CREATE TABLE orders ( id INT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, court_id INT NOT NULL, book_date DATE NOT NULL, start_time VARCHAR(5) NOT NULL, end_time VARCHAR(5) NOT NULL, total_price DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT pending, remark VARCHAR(255) DEFAULT , created_at DATETIME NOT NULL, paid_at DATETIME DEFAULT NULL, cancelled_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_court_date (court_id, book_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单状态字段我用字符串而不是数字好处是日志和接口返回值里可以直接看到可读状态排查问题不用查字典表。pending 是待支付paid 是已支付cancelled 是已取消completed 是已完成核销refunded 是已退款。简单业务不搞复杂状态机够用就行。3.2 用什么方式保证同一时段不被抢两次这是整个系统的技术核心。我当时给了两套方案。第一套是在 orders 表加联合唯一索引比如 (court_id, book_date, start_time)数据库层面强行不允许同一场地同一日期同一开始时间出现两条订单。实现简单但要命的是订单一旦取消这个唯一索引会把“已经取消的时段”也锁住除非真的去删记录。可订单历史记录不能随便删所以这方案不能直接用。第二套是使用事务加行锁。下单接口开启事务后先执行SELECT * FROM courts WHERE id ? FOR UPDATE锁住目标场地这一行再查订单表里有没有和请求时间段重叠的有效订单。没有冲突就插入订单最后提交事务。这样同一时间对这个场地发起的并发下单请求会串行执行后到的那个在查订单时已经能看到先到的记录从而提示“该时段已被预订”。我最终选了第二套。这套方案锁的是整个场地的所有时段粒度虽然粗但对体育馆这种低并发场景完全够用而且逻辑直观排查问题容易。3.3 价格为什么不直接读场地表注意订单表里存了 total_price场内表里也有 price_per_hour。有人觉得冗余其实这是必须的。场地价格后面很可能调整比如羽毛球场地从每小时50元涨到55元如果订单表不冗余价格历史订单的总价也会跟着场地现价变化财务对不上账。订单一旦生成金额必须固定。这和电商订单要冗余商品快照是一个道理。前端在展示“某个时段多少钱”时直接读场地表当前的 price_per_hour下单时后端按当前单价和时长计算金额写入订单。这样用户看到的报价永远是最新的历史订单又不会被后续调价影响。4. 后端关键接口与核心业务实现4.1 登录鉴权中间件所有接口的第一道门槛登录注册接口的实现套路很固定。注册时密码不能存明文用 bcryptjs 做哈希加盐存储登录成功后签发一个 JWT 返回给前端。JWT 里只存用户 ID、角色和过期时间不存敏感信息。前端把 token 存到 localStorage每次请求由 axios 拦截器加到 Authorization 头里。后端写一个 authMiddlewaremodule.exports function (req, res, next) { const token (req.headers.authorization || ).replace(Bearer , ); if (!token) return res.status(401).json({ message: 未登录 }); try { const payload jwt.verify(token, process.env.JWT_SECRET); req.user { id: payload.id, role: payload.role }; next(); } catch (e) { return res.status(401).json({ message: 登录已过期 }); } };需要登录的接口都挂这个中间件。需要管理员权限的接口再包一层 requireAdmin校验 req.user.role 是不是管理员或超管。千万不要在接口里只判断前端传过来的 userId否则别人换个参数就能操作别人的订单。4.2 按日期查询场地和可订时段前端加载场馆首页时会传一个日期参数给后端接口大致是 GET /api/courts?date2025-07-20。后端逻辑分三步。第一步查询所有状态为开放的场地第二步查询该日期所有状态为 pending 和 paid 的有效订单第三步对每个场地从 court_slots 模板里取出默认时段列表再减掉被有效订单占用的时段返回给前端。核心代码可以这样写const [courts] await db.query(SELECT * FROM courts WHERE status 1 ORDER BY sort_no); const [orders] await db.query( SELECT court_id, start_time, end_time FROM orders WHERE book_date ? AND status IN (pending, paid), [date] ); const occupiedMap {}; orders.forEach(o { const key ${o.court_id}_${o.start_time}_${o.end_time}; occupiedMap[key] true; }); for (const court of courts) { const [slots] await db.query( SELECT start_time, end_time FROM court_slots WHERE court_id ? ORDER BY start_time, [court.id] ); court.slotList slots .filter(s !occupiedMap[${court.id}_${s.start_time}_${s.end_time}]) .map(s ({ startTime: s.start_time, endTime: s.end_time })); } res.json({ data: courts });这样前端拿到的每个场地都带一个 slotList 数组可以直接渲染成卡片上的时段按钮。4.3 下单接口防重复占用的核心实现下单接口是 POST /api/orders请求体包含场地 ID、日期、开始时间、结束时间。完整逻辑拆解如下。先做参数校验比如场地必须存在、日期不能是过去、开始时间必须小于结束时间。然后开启数据库事务执行SELECT * FROM courts WHERE id ? FOR UPDATE锁住场地行。接着查这个场地、这个日期、时间范围有重叠的有效订单const sql SELECT id FROM orders WHERE court_id ? AND book_date ? AND status IN (pending, paid) AND start_time ? AND end_time ? ;这段 SQL 是判断时间段重叠的关键。start_time ? AND end_time ?是标准的时间重叠判断新订单是 [startTime, endTime]已有订单的开始时间小于新订单结束时间并且已有订单的结束时间大于新订单开始时间两个条件同时满足就说明有交集。如果查到记录直接抛异常回滚事务提示该时段已被预订。没有冲突就插入一条状态为 pending 的订单再根据场地单价和小时数算出总价写入 total_price。最后提交事务把订单号返回给前端。订单号我习惯用 BK 加时间戳加4位随机数对展示型需求足够了严格的分布式订单号可以用雪花算法但小项目没必要。4.4 超时未支付订单自动释放用户下单后没有立即支付订单不能一直占着时段不放否则别人看到的时段永远是已占用却订不到。我写了一个简单的定时任务每分钟执行一次setInterval(async () { await db.query( UPDATE orders SET status cancelled, cancelled_at NOW() WHERE status pending AND created_at DATE_SUB(NOW(), INTERVAL 30 MINUTE) ); }, 60 * 1000);这个方案简单但有效。用户下单后30分钟不支付订单自动取消时段自动释放。如果项目以后部署成多进程或多节点定时任务会重复执行最好用一个共享锁或改成独立任务服务触发。单机部署下这个写法完全没问题。5. 前端页面与核心交互实现5.1 路由守卫和页面骨架前端用 Vue Router 管理页面路由表里给需要登录的页面加上 meta 标记{ path: /orders, component: () import(/views/Orders.vue), meta: { requiresAuth: true } }, { path: /admin/orders, component: () import(/views/admin/Orders.vue), meta: { requiresAuth: true, requiresAdmin: true } }路由守卫里做两件事没有 token 但访问需要登录的页面就跳转登录页普通用户访问管理员页面就跳回首页。代码不复杂但能让整个系统的权限感知强很多。5.2 场地看板页面让可订状态一眼可见场地看板是用户端最核心的页面。页面顶部放一个 el-date-picker 选择日期下面是场地卡片列表。每张场地卡片展示场地名称、运动类型、位置、价格以及一排时段按钮。可用时段高亮显示占用时段置灰禁用。这个页面用 Element UI 的 el-row 和 el-col 做栅格布局时段按钮用 el-button 循环渲染。点击可用时段按钮跳转到下单页同时把场地 ID、日期、开始时间、结束时间作为路由参数带过去。这里有个小经验页面里所有时间相关的展示统一用 dayjs 格式化不要随手 new Date()否则很容易出现字符串比较不一致的问题。5.3 下单页表单校验和价格实时计算下单页的逻辑很直接。从路由参数里拿到场地和时段信息后页面展示一个确认表单包含订单详情摘要、备注输入框和提交按钮。价格部分实时计算const startHour Number(startTime.split(:)[0]); const endHour Number(endTime.split(:)[0]); const hours endHour - startHour; this.totalPrice (this.court.pricePerHour * hours).toFixed(2);这个计算目前只支持整点时段。如果业务里后面出现了半小时段就要改成按分钟差除以60。提交时用 Element UI 的 el-form 做规则校验比如备注长度不能超过50字提交按钮加 loading 状态防止重复点击。5.4 管理端订单处理管理端我按三个页面来做订单列表、场地管理、数据统计。订单列表用 el-table 展示所有订单列包括订单号、用户手机号、场地、日期、时段、金额、状态、创建时间和操作按钮。管理员可以对待支付订单做确认收款对已支付订单做核销完成对需要退款的订单做退款。这里列表请求一定要做分页Element UI 的 el-pagination 组件和接口里的 page 参数配合好就行。后端不能把所有订单一次性返回数据量一大浏览器会直接卡死。管理端场地管理页面负责增删改场地、配置时段模板也就是维护 courts 和 court_slots 两张表。5.5 数据统计最简单的营收看板第一版没有上 ECharts而是用 Element UI 的 el-card 加几个数字做简单看板今日订单数、今日营收、本月营收、待处理订单数。这些数字都是后端统计接口返回的前端只负责渲染。后面如果需要图表可以再引入 ECharts 画一个近7日营收折线图但第一版没必要卡在这个环节上。6. 联调、部署与常见问题6.1 部署方案Nginx 托管前端Node 做纯接口服务开发完成后部署也是有不少坑的环节。前端 build 之后得到纯静态文件放到服务器指定目录下Node 服务用 PM2 守护运行在 3000 端口Nginx 做反向代理。Nginx 配置大致如下server { listen 80; server_name example.com; root /var/www/booking-spa; index index.html; location / { try_files $uri $uri/ /index.html; } 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; } }这里有两个坑。一个是 Vue Router 用 history 模式时刷新非根路径会出现 404必须用 try_files 把请求回退到 index.html另一个是 /api/ 开头的请求一定要反代到 Node并且不要让 Node 服务直接暴露到公网否则攻击面会大很多。6.2 同时下单同一个场次系统会不会崩前面说过用事务和行锁这里补一个现场经验。行锁的粒度是场地也就是说用户A和用户B同时抢羽毛球1号场的上午10点到11点系统会让其中一个事务先执行另一个事务等到行锁释放后再执行第二个事务会查到冲突记录并返回失败。实测几十个并发请求同时进来数据库里不会出现两张重复订单。但有个细节要特别注意事务里锁住场地行之后不能做太耗时的外部操作比如调用第三方支付接口。事务持续时间越长锁持有的时间越久其他请求等待时间就越长。所以我在下单事务里只做数据库操作支付环节放在订单创建成功之后异步处理。6.3 联调期间最容易出的问题联调时最容易出现的就是跨域和字段名对不上。跨域用 cors 中间件解决是最简单的但更推荐在生产环境用 Nginx 做同源反代开发时才用 cors 放行。字段名对不上这个问题我习惯先把接口文档写在项目 README 里或者用 Apifox 记录前后端都以文档为准能少吵很多架。时间字段也值得说一句。前端传给后端的日期最好统一用 YYYY-MM-DD时间用 HH:mm不要传时间戳。MySQL 的 DATETIME 存本地时间如果前后端传时间戳再互相换算很容易出现8小时时区差。这个项目里订单日期和时段全部用字符串数据库里用 date 和 time 类型查询和展示都非常直白。6.4 常见问题排查速查整理一个快速排查表问题现象可能原因处理方式前端请求接口 404没有反代 /api或 Node 服务没启动检查 Nginx 配置确认 PM2 状态刷新页面后 404Vue Router history 模式缺 try_files在 Nginx location / 里加 try_files登录成功后仍被拦截token 没写入 localStorage 或已过期检查 axios 拦截器确认 expiresIn跨域报错前后端不同源且没配置 CORS开发期加 cors生产用同源反代订单重复防冲突逻辑没走事务检查下单接口是否有 FOR UPDATE时间差8小时数据库时区或字符串格式不统一统一用标准字符串检查连接参数7. 项目做完之后的一些后话7.1 这套代码还能往哪些方向扩展第一版三周上线后续需求方就开始提新需求了。最自然的扩展是接入真实线上支付把线下收款变成在线支付其次是做会员等级比如累计消费达到一定金额后订场享受折扣再者是做小程序端前端很多逻辑可以复用但需要单独写适配层和登录体系。如果并发真的变大比如抢热门时段瞬间上千个请求就要考虑引入 Redis 分布式锁或者在消息队列里做削峰。但普通体育馆场景第一版的方案已经能稳定扛住日常预约不用一上来就上重架构。7.2 独立开发这套系统的一些心得最后说几句实在话。接这类系统时先把业务规则问清楚比写代码重要得多。你要知道哪些状态算占用、订单取消后时段是否立刻释放、已支付订单是否允许退款、管理员是否允许手工代客下单。这些都是需求细节但每个都会直接影响表结构和接口设计。我在实际开发中的体会是前期多花半天把时间重叠规则、角色权限、订单状态流转画清楚后面能省出两三天返工时间。这套系统让我最满意的不是用了什么新技术而是它真正解决了电话接龙、纸质登记这些老问题。从一个模糊需求到完整系统上线完整走一圈之后对 Vue 和 Node 的熟练度提升非常明显后面再接类似项目时心里也更有底了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑