拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Node.js+Express+MySQL+Vue在线电影票购买系统实战

打开网络购票系统那一刻用户不会关心你后端用了什么框架、数据库设计了几个表、接口是 RESTful 还是 RPC。他只知道——我要在 30 秒内选好座位、下单、看到出票成功。而你作为开发者最怕的恰恰是 30 秒之后系统在并发下出现重复卖座、订单数据错乱、前端页面白屏。这篇文章不聊虚的直接拆一个完整的在线电影票购买系统。技术栈固定为后端 Node.js Express MySQL前端 Vue ElementUI题目标签叫 express-mysql。我尽量把项目里能遇到的高频问题和设计思路全部过一遍从环境搭建到数据库表设计从 JWT 登录鉴权到选座事务从 Vue 路由到 m3u8 预告片播放最后附一份实测过的报错排查清单。适合正在做课程设计、毕业设计或者准备从前端转全栈的朋友参考照着这套逻辑走完你不仅能跑通项目还能在答辩或者面试时把每一个技术选型的原因说清楚。1. 项目整体设计与技术选型思路1.1 先想清楚系统要做哪些事很多人一上来就写代码写到一半发现模块之间互相纠缠这是最浪费时间的事。做电影票购买系统第一步不是选框架而是盘点业务边界。一个标准的在线购票系统用户端至少要覆盖这些路径用户注册登录、首页电影列表、电影详情、选择场次、选择座位、确认订单、模拟支付、个人订单列表。管理端就是反过来维护电影信息、配置场次和影厅、查看订单。我把范围控制在这几条主线上刻意没做支付回调、退票流程、会员积分这些附加功能原因是作为一个教学型全栈项目核心链路越清晰越能体现你对业务和技术栈的掌控力。等后面你想扩展加功能也容易但一开始范围太散反而会让代码结构变得混乱。这个系统最核心的难点只有一个——选座并发问题。也就是两个用户同时在同一个场次买同一个座位系统必须保证只有一个能成功。这里会用到 MySQL 事务和行锁这是我在后面会重点展开的部分。1.2 为什么是 Node.js Express MySQL Vue ElementUI技术选型不是越新越好而是越合适越好。后端用 Node.js Express原因很简单前端同学本来就会 JavaScript再去学 Java 和 Spring Boot 的学习成本太高。而且 Node.js 在处理高 I/O 并发场景下表现不差购票系统的请求基本都是读多写少Node 的异步模型完全扛得住。Express 作为 Node 生态里最成熟的 Web 框架中间件机制简单清晰路由写法直观对中小型项目来说足够稳定。如果你用过 Koa也不冲突Koa 的洋葱模型更灵活但 Express 的生态和资料更全课程设计和技术选型答辩的时候Express 几乎不会被人质疑。数据库方面我坚持选 MySQL 而不是 MongoDB。订单和座位这种强事务数据必须保证 ACID 特性尤其卖座这种操作一旦用文档型数据库做非原子更新出错的概率极高。MySQL 在关系型数据持久化上的成熟度无需多言加上 InnoDB 引擎默认支持行级锁做事务控制非常顺手。前端用 Vue ElementUI 是另一个省心组合。Vue 的响应式数据流让页面状态管理变得直观ElementUI 则把表格、表单、弹窗、消息提示这些高频组件全都封装好了。你不需要从头写一个日期选择器也不用自己调样式调半天。选它最大的理由是效率一天能完成的界面你不需要花三天。相比之下如果你用 React Antd 也能实现同样效果但如果你以前没写过 React临阵学习 JSX 和 Hooks 的时间成本会明显拉高。技术没有绝对好坏关键是在有限时间里把系统做出来、说清楚。1.3 前后端分离的项目目录结构这套系统我采用前后端分离后端只提供 JSON 接口前端通过 axios 调用。分离的好处是职责边界清晰你启动两个工程互不干扰将来部署时可以把前端打包后的 dist 目录托管到 Nginx后端单独跑一个 Node 服务。后端项目按 MVC 思路分层我整理的目录结构大致是这样server/ ├── app.js # Express 入口 ├── config/ │ └── db.js # MySQL 连接池配置 ├── routes/ # 路由层 │ ├── user.routes.js │ ├── movie.routes.js │ ├── session.routes.js │ └── order.routes.js ├── controllers/ # 控制器层处理请求参数、调用 service、返回响应 ├── services/ # 业务逻辑层如选座事务、订单生成 ├── models/ # 数据访问层封装 SQL 查询 ├── middlewares/ # 中间件如 JWT 校验、错误处理 ├── utils/ # 工具函数如统一响应格式 └── uploads/ # 静态资源如电影海报、预告片前端项目以 Vue 单页应用组织核心目录web/ ├── src/ │ ├── api/ # axios 接口封装 │ ├── router/ # Vue Router 配置 │ ├── views/ # 页面组件 │ ├── components/ # 公共组件如电影卡片、座位图 │ ├── stores/ # Pinia 或 Vuex 状态管理 │ └── utils/ # 请求拦截器、工具函数 ├── vite.config.js # Vite 配置含代理 └── package.json有读者可能会问Controller、Service、Model 三层是不是过度设计我的体会是如果一个接口只有两行 SQL三层确实显得繁琐。但一旦出现选座这样的逻辑你要在一个接口里完成查场次、锁座位、插入订单、更新座位状态等多个步骤没有 Service 层这段逻辑就会全部堆在 Controller 里后期想维护几乎等于重写。分层是为了给复杂业务留出收容空间代价是一点代码量这个投入值得。2. 开发环境准备与高频踩坑实录2.1 Node.js 安装与环境变量配置开发前第一件事就是装 Node.js。很多人对这个环节不以为然实际上我见过太多同学在安装环节就卡了两小时所以这里单独拎出来说。去 Node.js 官网下载 LTS 版本安装包注意是 LTS 而不是 Current。LTS 代表长期维护版稳定性有保障Current 版本虽然新特性多但可能存在兼容性问题不一定适合项目开发。下载 msi 安装包后双击安装一路默认但有一个关键勾选必须确认——Add to PATH 这项必须选中否则安装完后你在命令行执行 node -v 会提示找不到命令。安装完成后打开命令行Windows 推荐用 cmd 或者 PowerShell输入node -v npm -v能正常输出版本号说明 Node 装好了。npm 会随着 Node 一起安装它是 Node 的包管理器后面所有依赖都靠它下载。如果你发现 node 能执行但 npm 报错大概率不是安装问题而是环境变量里 npm 路径没配好检查一下 PATH 是否包含 Node 的安装目录。如果你以前装过低版本 Node现在想升级最省心的方式不是卸载重装而是用 nvmNode Version Manager做版本管理。nvm 能在同一台机器上安装多个 Node 版本随时切换对于以后需要维护多个项目的人来说非常实用。2.2 npm.ps1 无法加载脚本的解决方案这是 Windows 上出现频率最高的报错之一完整信息如下npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。 有关详细信息请参阅 about_Execution_Policies。原因不是 npm 没装好而是 PowerShell 的执行策略默认是 Restricted禁止运行 .ps1 脚本文件。npm 命令在 PowerShell 里实际是由 npm.ps1 这个脚本执行的所以被拦截了。解决办法有三种你按自己的情况选一个就好。第一种管理员身份打开 PowerShell执行Set-ExecutionPolicy RemoteSigned输入 Y 确认。这个命令允许本机脚本运行但要求从网上下载的脚本必须有数字签名对正常开发没有任何影响。第二种直接用 CMD 代替 PowerShell。打开命令提示符输入 npm -v你会发现正常得很。因为 CMD 不执行 .ps1 脚本它执行的是 npm.cmd完全绕过了 PowerShell 的限制。第三种如果你是用 VS Code 的内置终端遇到这个问题直接把默认终端切换为 CMD 或 Git Bash效果一样。我自己的习惯是直接把 PowerShell 执行策略改成 RemoteSigned一次性解决问题。改完后重启终端npm 就能正常使用了。2.3 MySQL 安装配置与 Workbench 使用数据库端建议使用 MySQL 8.0 版本。官网下载有 msi 安装包和免安装版 zip 两种类型对新手来说 msi 安装包最友好图形化向导一步一步来。安装时选 Server only 就够用了不必勾选 Development Default后者会把一堆用不到的 MySQL 相关组件全部装进来白白占用磁盘。设置 root 密码时一定要记住后面连接数据库全靠它。字符集选择默认的 utf8mb4 就行能正确存储中文和 emoji。MySQL 8.0 默认使用 caching_sha2_password 认证插件如果你遇到连接工具连不上的情况优先排查这里是否兼容。安装完成后在命令行验证mysql -u root -p输入密码进入 MySQL 命令行说明服务正常。接着用 Workbench 创建数据库或者直接在命令行执行CREATE DATABASE movie_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;这里我强烈建议你装一个 MySQL Workbench它是官方免费的可视化工具建表、写 SQL、查看数据都方便。命令行虽然也能操作但需要你记住所有语句可视化工具能大幅减少低级错误。免安装版 zip 适合想更深入理解 MySQL 初始化流程的同学。下载 zip 包后解压在解压目录里新建 my.ini 配置文件然后执行 mysqld --initialize-insecure 初始化数据目录最后通过 net start mysql 启动服务。这个过程稍麻烦但会强迫你理解 MySQL 的目录结构和启动逻辑。2.4 安装 Node.js 报错 2203 的排查记录热词里有一个高频问题——安装 Node.js 报错 2203。我在帮朋友排查时遇到过一次错误提示为 The installer has encountered an unexpected error installing this package. This may indicate a problem with this package. The error code is 2203.。这个错误通常是 Windows Installer 服务异常或者临时目录权限不对导致的。排查思路是先用管理员身份运行命令提示符执行 services.msc 确认 Windows Installer 服务是启动状态然后清理 C:\Windows\Installer 临时文件目录注意不要删除扩展名为 .msi 的缓存文件最后重点检查 C:\Program Files\nodejs 目录是否残留了旧版本文件的只读权限之前装过 Node 但没卸载干净也容易触发这个错误。最直接的笨办法是把旧 Node 彻底卸载干净然后清理注册表残留后再装一次。整个过程比较费时间但能一次排查到底。关于 Node.js 的环境配置我整理了一个自查清单你在安装前过一遍检查项操作验证方式PATH 是否包含 Node 目录系统环境变量里手动添加cmd 执行node -vnpm 可用性安装完成后测试cmd 执行npm -v镜像源是否稳定设置淘宝镜像npm config set registry https://registry.npmmirror.com权限问题以管理员身份运行安装器和终端确认不报 2203 错误镜像源那一步建议先配好不然 npm install 下载依赖会非常慢尤其国内网络环境下。3. 数据库设计与 Express 后端实现3.1 MySQL 表结构设计与字段意图数据库设计是整个系统的基础表结构不合理后面代码写得再优雅也白搭。这个电影票系统我按业务拆分出 5 张核心表用户表、电影表、场次表、座位表和订单表。用户表 usersCREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, nickname VARCHAR(50) DEFAULT , avatar VARCHAR(255) DEFAULT , phone VARCHAR(20) DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;username 设置了唯一索引登录注册时按用户名查询会走索引不用再做用户名重复校验。password 字段长度设置成 255是因为后面要存 bcryptjs 加密后的结果加密串长度远超过明文密码的长度范围。电影表 moviesCREATE TABLE movies ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL, poster VARCHAR(255) DEFAULT , trailer_url VARCHAR(255) DEFAULT , description TEXT, duration INT DEFAULT 0 COMMENT 时长分钟, release_date DATE, rating DECIMAL(3,1) DEFAULT 0.0, status TINYINT DEFAULT 1 COMMENT 1-上映中 0-下架 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;我加了一个 trailer_url 字段用来存 m3u8 格式的电影预告片地址这个字段对应前端用 hls.js 播放 m3u8 视频的需求。rating 字段用 DECIMAL(3,1)比如 8.5 分用 FLOAT 可能会存在精度误差评分这类数据用 DECIMAL 更严谨。场次表 sessionsCREATE TABLE sessions ( id INT AUTO_INCREMENT PRIMARY KEY, movie_id INT NOT NULL, hall_name VARCHAR(50) NOT NULL COMMENT 影厅名称, start_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, total_seats INT DEFAULT 80, remaining_seats INT DEFAULT 80, FOREIGN KEY (movie_id) REFERENCES movies(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;total_seats 和 remaining_seats 是冗余字段我故意保留的。购票成功后remaining_seats 减一前端列表页直接读这个字段就可以显示余票状态不需要临时连表 count 座位表。这类冗余在场次这种读多写少场景下利大于弊。座位表 seatsCREATE TABLE seats ( id INT AUTO_INCREMENT PRIMARY KEY, session_id INT NOT NULL, row_no INT NOT NULL COMMENT 行号, col_no INT NOT NULL COMMENT 列号, status TINYINT DEFAULT 0 COMMENT 0-可选 1-已锁定 2-已售出, order_id INT DEFAULT NULL, FOREIGN KEY (session_id) REFERENCES sessions(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;status 字段是系统防止超卖的核心。0 代表空闲1 代表用户正在锁定但还没下单2 代表已经支付成功。每次购票动作都会对 status 做条件更新这是后面的关键逻辑。订单表 ordersCREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(64) NOT NULL UNIQUE, user_id INT NOT NULL, session_id INT NOT NULL, seat_id INT NOT NULL, movie_title VARCHAR(100) NOT NULL, hall_name VARCHAR(50) NOT NULL, start_time DATETIME NOT NULL, seat_label VARCHAR(20) NOT NULL COMMENT 座位号如 5排3座, amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已取消, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_session_id (session_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;movie_title、hall_name、start_time 这几个字段看似冗余实际非常必要。因为它们来自多张关联表如果只存 id下单后如果电影信息被改订单里显示的内容就会跟着变对用户来说这是不可接受的。把关键快照存进订单表是电商项目的通用做法。3.2 Express 路由分层与数据库连接池后端入口文件我推荐用 app.js 把一个 Express 应用组装起来const express require(express); const cors require(cors); const path require(path); const app express(); app.use(cors()); app.use(express.json()); app.use(/uploads, express.static(path.join(__dirname, uploads))); app.use(/api/user, require(./routes/user.routes)); app.use(/api/movie, require(./routes/movie.routes)); app.use(/api/session, require(./routes/session.routes)); app.use(/api/order, require(./routes/order.routes)); app.use((err, req, res, next) { console.error(err); res.status(500).json({ code: 500, message: 服务器内部错误 }); }); app.listen(3000, () { console.log(Server running at http://localhost:3000); });跨域用 cors 中间件直接解决不需要自己手写 response header。上传的海报图、预告片放到 uploads 目录下通过 express.static 托管成静态资源前端可以直接用 URL 访问。数据库连接这块要重点说。很多人初学时直接每次请求都创建一条新连接用完就关闭这样在开发环境看着没问题一旦并发量上来数据库会频繁创建连接导致性能急剧下降甚至报 Too many connections 错误。正确的做法是使用连接池。我用 mysql2 的 promise 版本它能配合 async/await 写出非常清爽的异步代码const mysql require(mysql2/promise); const pool mysql.createPool({ host: localhost, user: root, password: 你的密码, database: movie_ticket, waitForConnections: true, connectionLimit: 10, queueLimit: 0, charset: utf8mb4 }); module.exports pool;connectionLimit 设成 10queueLimit 设成 0 表示不限制排队数量。waitForConnections 表示连接不够用时请求进入等待队列而不是直接报错。这套配置对课程设计和中小型项目完全够用。3.3 登录注册与 JWT 鉴权的完整流程用户密码绝不能明文存储这是底线。我使用 bcryptjs 哈希它内部自动处理加盐逻辑安全性比直接 MD5 高得多。注册接口的 service 层核心代码const bcrypt require(bcryptjs); const pool require(../config/db); async function register(username, password) { const hashedPassword await bcrypt.hash(password, 10); const [result] await pool.execute( INSERT INTO users (username, password) VALUES (?, ?), [username, hashedPassword] ); return result.insertId; }bcrypt.hash 的第二个参数 10 是 saltRounds意思是哈希计算的迭代次数。数字越大越安全但耗时也越长10 是通行配置兼顾安全和性能。登录校验成功之后签发 JWT。我选择 jsonwebtokenconst jwt require(jsonwebtoken); const token jwt.sign( { userId: user.id, username: user.username }, process.env.JWT_SECRET, { expiresIn: 7d } );为什么不选 session 保存登录态核心原因是前后端分离后后端不维护会话状态JWT 天然无状态适合分布式部署。token 在有效期内不需要查数据库验证除非你要做 token 黑名单。后面前端每次请求都会在 Authorization 头带上 token后端用一个中间件统一校验function authMiddleware(req, res, next) { const authHeader req.headers.authorization; if (!authHeader) return res.status(401).json({ code: 401, message: 未登录 }); const token authHeader.split( )[1]; try { const payload jwt.verify(token, process.env.JWT_SECRET); req.userId payload.userId; next(); } catch (err) { return res.status(401).json({ code: 401, message: 登录已过期 }); } }这里有一个容易被忽略的细节——JWT 密钥绝对不能硬编码在源码里。用环境变量管理创建一个 .env 文件通过 dotenv 加载。项目提交到代码仓库时一定要把 .env 加入 .gitignore。密钥泄露意味着任何人都能伪造登录身份这是常识性安全底线的错误。3.4 选座与下单的 MySQL 事务处理选座是整个系统最核心的接口也是技术答辩时最值得讲的一个点。我在这个接口里把查询场次、锁定座位、创建订单这系列操作全部放进一个事务里保证要么全部成功要么全部失败。首先说为什么需要事务。想象一下一场电影只剩最后一个座位小明和小红同时点击购票。如果没有事务两个人可能同时读到座位状态是空闲然后同时更新成已售出最后两个人都下单成功了。这在实际业务中是绝对不能发生的。MySQL InnoDB 引擎通过事务和行锁能解决这个问题。我的下单逻辑代码是这样const conn await pool.getConnection(); try { await conn.beginTransaction(); // 锁定座位条件更新仅当该座位当前为空闲状态才更新为已锁定 const [lockResult] await conn.execute( UPDATE seats SET status 1, order_id NULL WHERE id ? AND session_id ? AND status 0, [seatId, sessionId] ); if (lockResult.affectedRows 0) { await conn.rollback(); return { code: 1, message: 该座位已被选走 }; } // 插入订单 const [orderResult] await conn.execute( INSERT INTO orders (order_no, user_id, session_id, seat_id, movie_title, hall_name, start_time, seat_label, amount, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0), [orderNo, userId, sessionId, seatId, movieTitle, hallName, startTime, seatLabel, amount] ); // 更新场次余票 await conn.execute( UPDATE sessions SET remaining_seats remaining_seats - 1 WHERE id ?, [sessionId] ); await conn.commit(); } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }关键在于 UPDATE seats ... WHERE status 0 这个条件更新它利用了 InnoDB 的行锁机制。当一个事务执行这条 UPDATE 并锁定该行时另一个事务对同一行执行相同更新会阻塞直到第一个事务提交或回滚。两个并发请求最终只有一个 affectedRows 为 1另一个为 0被判定为选座失败。这就从数据库层面杜绝了超卖。这个接口里我不推荐把整个业务封装成一个 MySQL 存储过程因为 Node 层做事务已经足够清晰易维护存过过程反而把逻辑隐藏在数据库层排查问题时需要切换上下文。存储过程适合那些大量数据搬运的定时任务在这种实时性要求高的场景里靠事务加行锁反而更可控。订单号生成我用了时间戳加随机数的方式const orderNo Date.now().toString(36) Math.random().toString(36).substring(2, 8).toUpperCase();生成结果类似 m8xj1aK2XpD唯一性足够。如果担心极端并发下重复可以在 order_no 字段上加唯一索引插入时捕获冲突异常再做一次重试。3.5 接口统一响应格式与全局错误处理后端返回给前端的数据格式如果不统一前端写接口封装时会非常痛苦。我固定使用这样一套响应结构// 成功 { code: 0, message: success, data: {} } // 失败 { code: 1, message: 具体错误信息, data: null }所有控制器都遵循这个格式前端 axios 拦截器里只处理 code 为 0 的情况其他情况直接弹错误提示。这个约定能大量减少前端对异常场景的重复判断。全局错误处理中间件放在所有路由之后、监听之前。具体代码在 3.2 的入口文件里已经写过这里不再重复。有一点要强调的是生产环境下不要把 err.stack 直接返回给前端这等于把后端源码结构泄露给了攻击者。控制台里打印完整堆栈返回给前端的只给一个通用提示。4. Vue ElementUI 前端实现4.1 工程初始化与 ElementUI 按需引入前端我建议用 Vite 而不是 Vue CLI。Vite 启动速度比 Webpack 快一个量级配置文件也更简洁现在 Vue 官方生态已经完全转向 Vite。执行下面的命令创建项目npm create vitelatest web -- --template vue安装依赖cd web npm install npm install vue-router4 axios element-ui2 pinia这里要认真说一个坑ElementUI 有两个大版本存在兼容性差异。ElementUI2.x对应 Vue 2Element Plus1.x/2.x对应 Vue 3。这套系统用的 Vue 3 Vite需要的是 Element Plus但 ElementUI 这个旧名在网络教程里出现频率太高导致很多人装错包。如果你按我上面的命令装 element-ui2配合 Vue 3 运行时会直接报错。正确的做法是安装 Element Plusnpm install element-plus element-plus/icons-vue在 main.js 里完整引入import { createApp } from vue; import ElementPlus from element-plus; import element-plus/dist/index.css; import App from ./App.vue; import router from ./router; const app createApp(App); app.use(ElementPlus); app.use(router); app.mount(#app);完整引入的优点是省心但打包体积会偏大。如果你在意首屏加载速度可以用 unplugin-vue-components 做按需引入npm install -D unplugin-vue-components然后在 vite.config.js 中配置。按需引入能让打包产物体积减少约 30%但如果你只是做课程设计完全没必要折腾这个优化完整引入即可。我见过太多同学在这个环节花了一下午配按需引入最后打包体积优化效果也没那么明显性价比不高。axios 请求封装放在 src/utils/request.js这一步很关键。要把 baseURL 统一掉请求拦截器里自动带上 token响应拦截器里统一处理错误码import axios from axios; import { ElMessage } from element-plus; const request axios.create({ baseURL: /api, timeout: 10000 }); request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); request.interceptors.response.use( response { if (response.data.code 0) { return response.data; } ElMessage.error(response.data.message || 请求失败); return Promise.reject(new Error(response.data.message)); }, error { ElMessage.error(error.response?.data?.message || 网络异常); return Promise.reject(error); } ); export default request;这样封装一次后面所有接口调用代码都会非常简洁。跨域开发环境问题通过 Vite 代理解决在 vite.config.js 里配置server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } }这个配置的意思是前端开发服务器把 /api 开头的请求转发到后端 3000 端口规避了浏览器跨域限制。4.2 路由设计、导航守卫与参数传值路由配置我按页面拆分核心路由是这样const routes [ { path: /, component: Home }, { path: /movie/:id, component: MovieDetail }, { path: /checkout, component: Checkout, meta: { requiresAuth: true } }, { path: /orders, component: OrderList, meta: { requiresAuth: true } }, { path: /login, component: Login }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true, requiresAdmin: true } } ];路由守卫处理登录校验router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next(/login); return; } next(); });这个守卫的逻辑很简单需要登录的页面没有 token 就跳登录页。管理端页面我再叠加一层管理员判断通过 token 解析出的角色字段决定是否放行。关于路由参数Vue Router 4 里 query 和 params 的用法要分清。query 适合可选的筛选条件比如按类型过滤电影刷新页面参数还会保留在 URL 中params 配合路由路径中的动态段使用比如 /movie/12 里的 12。params 有坑它不会出现在 URL 里刷新页面后参数会丢失所以不要把重要数据放在 params 里传递。传电影详情页 id 用 query 或动态段都行我自己习惯用动态段路径语义更清晰。还有一类问题常被忽视组件复用导致的生命周期不触发。从 /movie/1 跳转到 /movie/2 时Vue 会复用同一个 MovieDetail 组件实例不会重新执行 created 或 mounted也就是新电影数据不会刷新。解决办法是监听路由参数变化watch( () route.params.id, (newId) { fetchMovieDetail(newId); }, { immediate: true } );这个监听逻辑写在 setup 里能同时覆盖首次加载和后续切换两种场景。4.3 核心页面开发首页、详情页与选座组件首页用 ElementUI 布局组件快速拼装。轮播图用 el-carousel 播放大图推荐位下面电影列表用 el-row 和 el-col 做栅格布局通过 el-card 展示海报、片名、评分和购票入口。接口调用封装在 src/api/movie.js 里请求结束后把 response.data.data 赋值给响应式变量 moviesVue 模板自动渲染。电影详情页除了展示基本信息还要实现场次列表。这部分我用 el-table 展示当天所有场次每一行的核心列是场次时间、影厅名称、票价、剩余座位数和一个选座购票按钮。点按钮跳转选座页路由参数带上 sessionId。el-table 自带的分页、排序用起来很顺手但要注意如果表格列里的数据是格式化之后的不要直接在模板里写一堆三元表达式最好抽一个 computed 函数处理模板会干净很多也方便后面测试。选座组件是整个前端最有意思的地方。我用二维数组代表座位布局例如 8 排 10 座数据结构是const seatMap Array.from({ length: 8 }, () Array.from({ length: 10 }, () ({ status: 0, // 0可选 1已锁定 2已售出 selected: false })) );渲染时用 CSS Grid 布局每个座位是一个 el-button。点击可选座位时切换选中状态计算总价并显示在下单按钮旁边。座位状态的视觉区分靠不同按钮 type 实现可选为 primary选中为 success已售出为 info 禁用状态。选中一组座位后点击提交订单遍历选中的座位 id 逐个调用下单接口。这里要注意一个前端的细节如果用户一次选了多个座位后端接口应该设计成支持多座位批量下单而不是循环调用单座接口。循环调用多个接口中间任何一个失败都会产生部分订单成功的脏数据处理起来很麻烦。我建议前端攒齐座位 id 数组后端在一个事务里批量处理整体成功或失败前端只接一次结果。4.4 进阶实战播放 m3u8 预告片与 ElementUI 弹窗加载 PDF这两个需求不是电影购票系统的必备功能但热词里搜索量很高而且面试官挺吃这一套所以我在项目里也加上了。m3u8 是苹果推出的流媒体播放格式本质是一个文本索引文件里面记录了多个 ts 视频切片的地址。浏览器原生 video 标签并不支持直接播放 m3u8需要用 hls.js 库处理。安装方式npm install hls.js在电影详情页为 trailer_url 字段做判断如果是 .m3u8 结尾就使用播放器逻辑import Hls from hls.js; const video ref(null); const playTrailer () { if (!props.movie.trailerUrl) return; const hls new Hls(); hls.loadSource(props.movie.trailerUrl); hls.attachMedia(video.value); hls.on(Hls.Events.MANIFEST_PARSED, () { video.value.play(); }); };原理是 hls.js 先拉取这个 m3u8 索引文件解析出所有 ts 切片地址然后按顺序逐个加载、拼接成视频流给到 video 元素播放。这样用户在详情页就能看预告片交互效果比一张静态海报好得多。如果 trailer 是 mp4 格式直接用原生 video 的 src 就行做个格式兼容判断if (src.endsWith(.m3u8) Hls.isSupported()) { // hls.js 逻辑 } else { video.value.src src; }弹窗加载 PDF 用 el-dialog 配合 iframe 实现。el-dialog 设置 width 为较大值比如 800pxiframe 占满弹窗内容区src 指向 PDF 文件地址。这样点击观影须知就能在弹窗里预览协议内容不用跳网页。常规实现是这样el-dialog v-modelpdfVisible title观影须知 width800px iframe :srcpdfUrl stylewidth:100%;height:500px;border:none;/iframe /el-dialog用户协议、发票说明这类文档都可以用这个方案。要注意的是 iframe 默认没有边框设置 border:none 让视觉更干净弹窗高度要配合 PDF 页面比例设置一个合适的值。ElementUI 还有一个高频交互——文字超出隐藏鼠标悬浮显示完整内容。比如电影剧情简介超过两行被 CSS 截断成省略号用户想看到全文最自然的交互是 hover 浮现完整文字。实现分两步先用样式控制超出隐藏.ellipsis-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }再套一个 el-tooltipel-tooltip :contentmovie.description placementtop span classellipsis-text{{ movie.description }}/span /el-tooltip这样鼠标悬浮在省略号文本上时会浮现一个提示框显示完整内容。如果文本特别长给 el-tooltip 设置 width 属性能限制提示框宽度配合 style 里的换行样式浏览体验更好。4.5 打包后布局异常与资源路径问题热词里提到了vue 打包后 布局异常我第一次遇到这个问题的时候也排查了很久。开发环境一切正常执行 npm run build 后把 dist 目录扔到 Nginx页面居然空白或者样式全部丢失控制台报一堆资源 404。核心原因出在 Vite 的 base 配置。Vite 打包默认资源路径是绝对路径 /assets/xxx.js如果你的项目部署在服务器根目录下没问题但如果你部署在子目录比如 www.example.com/movie/那绝对路径会指向 www.example.com/assets/当然找不到资源。解决办法是把 vite.config.js 里的 base 配置成 ./export default defineConfig({ base: ./, plugins: [vue()] });这样打包后的资源路径全变成相对路径不管部署在哪一层目录都能正常加载。这个配置虽然简单但没踩过坑的人很容易忽视。另外还有一类布局异常是 CSS 被压缩后某些浏览器对特定属性兼容性不同导致的比如 flex 布局在旧版 Safari 上表现不一样。这就是另一层的问题了需要加各浏览器兼容前缀这个通过 autoprefixer 自动处理即可。5. 常见问题排查与性能优化技巧5.1 开发环境高频报错速查表把项目开发和部署过程中容易踩到的坑整理成一张表按图索骥排查比事后搜百度快很多。报错信息原因解决方案npm.ps1 无法加载脚本PowerShell 执行策略限制管理员身份运行 Set-ExecutionPolicy RemoteSignedEADDRINUSE: address already in use :::3000端口被占用换端口或netstat -ano查占用进程并结束ER_ACCESS_DENIED_ERROR: Access denied for user rootlocalhostMySQL 密码错误检查 db.js 配置用 root 密码测试能否登录Client does not support authentication protocolMySQL 8 认证插件不兼容ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 密码;Cannot find module xxx依赖未安装在项目根目录执行 npm install xxxvue-router.esm-bundler.js:42 [Vue Router warn]: No match found for location with path /xxx路由配置不匹配检查路由 path 是否写错或页面路由是否注册ERR_CONNECTION_REFUSED 网络错误后端未启动或代理配置错误确认后端已监听确认 vite proxy target 端口正确AxiosError: Network Error跨域或后端启动失败检查后端启没启动检查 CORS 的 origin 配置其中 MySQL 8 的认证插件问题我特别强调一下。MySQL 8.0 默认采用 caching_sha2_password 插件而一些旧版客户端或 Node 驱动版本不支持这个插件会报 Client does not support authentication protocol requested by server。遇到这个错一是升级 mysql2 到最新版本Node 环境用它完全可以支持二是如果你必须用旧客户端可以按上表修改认证方式为 mysql_native_password但不推荐把它当默认做法因为 caching_sha2_password 的安全性更高。5.2 接口联调与前后端调试技巧接口联调阶段最烦的不是写代码而是接口通了数据却不对。我的调试习惯是把问题分三段排查请求是否正确发出、后端是否收到并处理、响应数据格式是否被正确解析。后端排查先看控制台日志。用 morgan 中间件记录每次请求的方法、路径、状态码和耗时npm install morganconst morgan require(morgan); app.use(morgan(dev));这样每个接口调用都会输出类似POST /api/order/create 200 12.345 ms - 67的日志状态码一眼就知道问题在哪。前端排查打开浏览器开发者工具的 Network 面板检查请求头、请求体、响应体。重点确认三件事Authorization 头是否携带了正确 token请求体 JSON 格式是否符合后端预期响应结构 code 字段是否为 0。最常遇到的一类问题是后端接口测试没问题但前端调用却 404。这种情况大半是 URL 拼写不一致比如后端定义 /api/movie/list前端封装成了 /api/movie/list/ 多了个斜杠路由匹配就直接失败。用上面的 Network 面板能看到实际请求的完整 URL对照后端路由文件检查秒级定位。5.3 数据查询优化索引、慢查询与事务隔离数据量不大时感觉不到 MySQL 优化的意义但量一旦上来一条没走索引的查询就能把接口拖慢好几倍。我的优化思路是优先看哪些字段在 WHERE、JOIN、ORDER BY 子句里高频出现然后针对性建索引。订单列表接口按用户 id 查询user_id 必须建索引场次列表按 movie_id 查movie_id 也要建首页按 title 搜索电影给 title 建普通索引。创建索引的 SQLALTER TABLE orders ADD INDEX idx_user (user_id); ALTER TABLE sessions ADD INDEX idx_movie (movie_id); ALTER TABLE movies ADD INDEX idx_title (title);建立索引不是越多越好因为每次插入和更新都要同步维护索引树索引过多会拖慢写操作。一个几百条记录的商品表你建十个索引毫无必要反而浪费存储空间。课程设计场景下每张表保持 2 到 3 个关键索引就够了。MySQL 里面还有一个容易被忽略的重要知识点——事务隔离级别。InnoDB 默认是 REPEATABLE READ这个隔离级别通过 MVCC多版本并发控制和间隙锁机制基本解决了脏读和幻读问题。在选座场景里当我们 UPDATE seats SET status 1 WHERE id ? AND status 0这条语句会锁定该行索引记录。REPEATABLE READ 下并发事务都能读到一致的快照但写操作仍然会被行锁串行化。所以这个场景实际是用行锁而不是 MVCC前后端的响应由 InnoDB 的锁机制保障。有些同学问要不要为了这个系统专门去调 low 事务隔离级别我的回答是没必要。InnoDB 默认级别已经覆盖绝大多数业务需求强行调成 READ COMMITTED 或 SERIALIZABLE前者可能产生幻读后者性能下降明显。除非你的系统有明确的业务诉求要调整隔离别否则保持默认配置就好。5.4 前端避坑ElementUI 升级到 Element Plus 与组件兼容在写这套系统时ElementUI 需要根据 Vue 版本选型。如果你在维护旧项目用的是 Vue 2继续用 ElementUI 2.x 没问题但如果你在 Vue 3 项目里误装了 element-ui运行时会报错 Cannot read properties of null (reading install)这基本就是版本不兼容。从 ElementUI 升级到 Element Plus 的迁移清单里有几个高频改动组件名前缀el-dialog、el-button 等保持不变但有些属性名变了比如 el-dialog 的 visible 属性改为 v-model。尺寸属性sizesmall 在 Element Plus 里改成 sizedefault 或 sizelargesmall 变 medium 这类变化要注意。图标ElementUI 的图标类使用方式在 Element Plus 中需要显式导入组件而不是直接用 class 名。表单校验rules 校验规则基本兼容但 validate 回调风格有变化建议统一用 async/await。这个升级流程如果是在已有项目中做建议进行一次全量回归测试。优先检查表单、弹窗、表格这三个高频组件因为它们占到了 80% 的页面交互量。如果是新项目直接从 Element Plus 起步就行不用纠结要不要兼容旧版。6. 个人实操经验与后续扩展建议整套系统跑通之后我复盘了一下有几个细节是普通教程不会告诉你的但对项目体验影响很大的地方。第一个是演示数据的准备。在开发完成后别急着截图或录视频交差先往数据库里插入 5 到 10 条电影数据场次覆盖今天的各个时间段座位状态里准备几个已售出座位这样演示购票流程时页面上有真实感选座时能看到哪些座位不可选而不是每次都是空座空空如也。用一条 SQL 批量插入这些数据或者写一个 seed.js 脚本能省下大量手工造数的时间。第二个是选座时要给用户一个明确的反馈闭环。当用户提交订单后前端应该立即展示一个下单成功的反馈同时跳转到订单页面订单列表里的状态要立刻变成待支付。我用 ElementUI 的 ElMessageBox 做了一个下单成功的弹出确认用户点击确定后跳转订单页。如果按钮加载慢或者没有任何提示用户会以为下单失败了操作体验会瞬间变差。第三个是订单超时未支付的体验问题。很多人做毕设或练习系统不会考虑这一点但实际业务里用户锁座后如果一直不支付座位会被锁死。我后来用 Node 定时任务做了个简易方案每五分钟把超过 15 分钟仍未支付的订单取消同时把 seats 表的 status 改回 0。这是一个很容易扩展的加分项写进项目总结里也能显示你考虑了完整业务流程而不是只是接口。后续如果想继续深入我有三个方向可以参考。一是把前端状态管理全面切到 Pinia把用户信息和购物车类的状态统一管理代码会更清晰。二是给管理端增加一个影厅和场次的批量管理页面用 ElementUI 的日期时间选择器配置场次比手动写 SQL 高效得多。三是完善接口文档用 swagger-ui-express 自动从代码注释生成接口文档前端联调时不必再反复问后端接口参数结构。最后提醒一句系统跑通了不等于万事大吉。我实际测试时发现如果用户在下单接口还没返回时连续点击提交按钮会创建出多条重复订单。前端在提交后要立刻禁用按钮后端在接口入口处对同一用户同一座位的重复下单做去重判断双保险才能把这个坑堵住。前端体验和后端健壮性两手都要抓这才是全栈工程师的做事方式。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门