ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Node.js与Vue的球员训练报名系统全栈开发实践

基于Node.js与Vue的球员训练报名系统全栈开发实践 接手本地业余足球俱乐部的运营管理系统时我遇到的情况相当典型俱乐部里有四十多名注册球员、三名兼职教练每周安排三到四次训练还穿插着青少年训练营和周末友谊赛。在此之前球员档案散落在 Excel 表格里训练报名靠微信群接龙教练通知靠群发公告每次活动结束后统计出勤都要手工核对聊天记录经常出现“报名了没到场、到场了没报名”的乱账。后来我用 Node.js 和 Vue 框架完整做了一套足球俱乐部管理系统把球员信息、训练计划、活动报名、出勤统计全部收拢到一个前后端分离的 Web 系统里。这篇博文我会把这套球员训练活动报名系统从需求拆解、技术选型、数据库设计到前后端具体实现的整个流程掰开揉碎地讲给正在做类似管理系统、或者想练手全栈开发的读者一份能直接落地的参考。1. 项目背景与整体设计思路1.1 需求场景拆解俱乐部的管理痛点做这类系统最忌讳一上来就写代码。我花了两天时间跟俱乐部负责人、教练和几个球员挨个聊把真实的工作流梳理清楚发现核心痛点集中在四块。第一是球员档案管理。俱乐部里每个球员除了姓名和电话还涉及年龄段、场上位置、体检状态、紧急联系人。原来这些信息在教练手机上各存一份临时需要某个位置的球员名单时只能翻聊天记录。第二是训练计划安排。教练每周要发布训练时间、地点、带队安排还要区分普通训练、体能课、对抗赛和青少年训练营不同课程对应不同的适用人群。第三是报名和请假。球员要先看到训练安排再决定是否参加教练需要知道哪些人确定来以便准备训练器材和分组。第四是出勤统计。俱乐部每年要给球员做评估出勤率是重要参考靠人工统计几乎不可能准确。把这些业务规则抽象出来之后系统功能就清晰了球员档案 CRUD、训练课程排期、活动报名与取消、报名名单导出、通知公告、出勤统计。每个功能看起来简单但背后都有隐含逻辑。比如报名不是点了就算完得处理满员、截止时间、取消后名额返还、请假留痕这些都需要在数据模型上提前想清楚。这个阶段我把需求画成简单的流程草图和页面原型跟需求方确认后再进入技术设计省掉了后面很多返工。1.2 技术选型为什么是 Node.js Vue这套系统的定位是俱乐部内部使用的管理工具数据规模撑死几千条并发量也不高但对开发效率和维护成本很敏感。选 Node.js 和 Vue 主要有三个理由。第一个理由是前后端语言统一。Node.js 和 Vue 都基于 JavaScript一个人维护全栈项目不需要切换语言上下文工具链也能共用。第二个理由是生态成熟。后端用 Express 写 REST API 非常轻量配合 mysql2 操作数据库、jsonwebtoken 做登录鉴权、bcryptjs 做密码加密这些库都很稳定。前端用 Vue 2 或 Vue 3 组件化开发配合 Element UI 这类现成组件库后台管理页面的表格、表单、弹窗几乎不用自己写样式开发速度很快。第三个理由是部署简单。Node.js 应用可以直接跑在一台小服务器上前端打包成静态文件交给 Nginx 托管运维成本很低。也对比过 Java Spring Boot 方案。Spring Boot 在企业级项目和毕业设计里很常见框架规范、功能全面但对这种体量的内部系统来说偏重环境配置和构建链路更复杂开发节奏会慢不少。PHP 方案也有考虑但前后端分离后配套的工程化体验不如 Node.js 顺手。最终定下来的技术栈是后端 Node.js Express MySQL前端 Vue Vue Router Pinia Axios Element UI登录采用 JWT 方案。这套组合对中小型管理类系统非常合适既能支撑功能扩展又不会过度设计。1.3 功能边界与角色划分系统涉及三类用户角色权限边界必须在一开始就定死否则后面接口校验会写得很乱。我用一张功能矩阵来管理需求功能模块管理员教练球员球员档案维护增删改查查看查看本人训练计划创建全部操作创建/编辑自己负责的课程只读活动报名查看名单查看名单/确认到场报名/取消通知公告发布发布查看出勤统计全部查看所带班级查看本人这个表的作用不只是存需求后续每个接口的权限校验都要对照它来写。比如球员调用删除训练计划的接口后端必须在中间件里拦截并返回 403而不是等进了业务逻辑再判断。我还刻意控制住了功能边界第一期不做支付、不做多俱乐部 SaaS、不做复杂财务报表。这些在初期都属于范围蔓延真正使用起来才发现俱乐部最急需的就是把“训练 — 报名 — 出勤”这条核心链路跑通。2. 数据库设计与接口规划2.1 角色权限模型这个系统的权限模型比较简单不引入 RBAC 那套复杂的角色-权限-菜单表直接在用户表里加一个 role 字段就够了。原因很简单系统只有三种角色权限规则是固定的没有必要把关系表设计得过度抽象。用户登录成功后后端会签发一个 JWT里面带上 userId 和 role。前端拿到 token 存在本地每次请求通过 Authorization 头带上。后端写了一个 authMiddleware在需要权限的接口上挂载角色判断直接用中间件参数完成。比如只允许管理员和教练调用的接口写法大致是这样的const requireRole (...roles) { return (req, res, next) { if (!req.user) { return res.status(401).json({ code: 401, message: 未登录 }); } if (!roles.includes(req.user.role)) { return res.status(403).json({ code: 403, message: 没有权限 }); } next(); }; }; router.post(/training-sessions, requireRole(admin, coach), sessionController.create);JWT 本身是无状态的服务端不需要存 session这特别适合前后端分离部署。但要注意 JWT 密钥必须放在环境变量里不能写死在代码中密钥泄露等于所有用户的登录凭证都可伪造。2.2 核心表结构设计数据库我选了 MySQL用 Sequelize 还是直接写 SQL 也纠结过。最后决定用 mysql2 连接池加手写 SQL因为这个项目表之间关系不复杂手写 SQL 的调试成本更低也更容易控制事务边界。核心表一共规划了五张。表名主要字段说明usersid, username, password_hash, role, player_id, created_at登录账号表与球员档案关联playersid, name, age_group, position, phone, emergency_contact, medical_status, created_at球员档案training_sessionsid, title, coach_id, start_time, end_time, location, max_participants, status, created_at训练和活动安排training_registrationsid, session_id, player_id, status, register_time, remark报名记录noticesid, title, content, publisher_id, publish_time, is_important通知公告重点说一下 training_registrations 这张表。报名记录的 status 我设计了三个值registered 表示已报名待确认confirmed 表示教练确认到场cancelled 表示已取消。很多人在做报名系统时会直接物理删除报名记录但这里有个问题如果球员取消报名就删行那“某人曾经报过名但取消了”这个信息就丢失了而教练往往需要知道有人临时请假以便复盘出勤情况。保留 cancelled 状态出勤统计和请假分析都能做代价只是多一行数据而已非常划算。在 session_id 和 player_id 上还加了唯一索引防止同一个球员对同一场训练重复报名。这个索引在并发请求下是最后一道防线后面的并发问题章节会详细讲。另外所有表的主键都用了自增整数没有用 UUID。原因很简单内部系统的数据量少自增主键查询性能好、索引占用小UUID 的优势在这里体现不出来。2.3 接口路径与统一返回格式接口设计遵循 REST 风格路径按资源划分。主要接口如下请求方法路径功能权限POST/api/auth/login登录公开GET/api/players球员列表管理员/教练POST/api/players新增球员管理员GET/api/training-sessions训练计划列表登录用户POST/api/training-sessions创建训练管理员/教练GET/api/training-sessions/:id训练详情与报名状态登录用户POST/api/training-sessions/:id/register报名球员DELETE/api/training-sessions/:id/register取消报名球员GET/api/training-sessions/:id/registrations报名名单管理员/教练POST/api/notices发布通知管理员/教练所有接口统一返回格式{ code: 0, message: ok, data: {} }code 为 0 表示成功非 0 表示业务错误码。这个约定必须在项目第一天就定好否则前后端联调时容易各写各的。我还把错误码文档放在项目 README 里比如 1001 表示训练已满员、1002 表示训练已开始无法报名、1003 表示重复报名联调时双方对着文档查错误信息能省下大量沟通时间。3. 实操开发流程与关键实现3.1 环境准备Node.js 安装配置与工程初始化开发这类项目第一步是搭环境。Node.js 我直接装了官方 LTS 版本没有用最新版因为 LTS 的稳定性对项目开发更重要。装完在终端验证node -v和npm -v确保路径正常。国内环境还需要把 npm 源切换成镜像源我用的是 npm config 命令npm config set registry https://registry.npmmirror.com这个步骤很多人会跳过但装依赖时差距非常大尤其是 electron、sharp 这类带二进制文件的包用默认源下载速度慢且容易失败。后端工程用一个空目录初始化npm init -y之后安装依赖。我装的包有 express、mysql2、jsonwebtoken、bcryptjs、cors、dotenv 和 nodemon。其中 nodemon 作为开发依赖文件变更后自动重启服务。前端工程用 Vue CLI 创建vue create club-fe创建时选择了 Vue 3 和 TypeScript。这里插一句业界对 TypeScript 的态度经常分成两派但对于管理系统这类表单密集、数据类型定义清晰的项目TypeScript 的优势能被放大能显著减少字段名写错、类型不匹配这类低级问题。随后继续安装 vue-router、pinia、axios 和 element-plus。环境初始化完成后有一个细节必须做在项目根目录创建.env文件把数据库连接信息、JWT 密钥、服务端口放进环境变量用 dotenv 加载。我见过太多项目把数据库密码硬编码在 config 文件里提交到代码仓库这种习惯一旦仓库权限失守整个数据库就裸奔了。3.2 后端核心模块实现后端代码我按职责分层控制器只处理 HTTP 请求和响应业务逻辑独立成 service数据库操作通过 model 层封装。目录结构长这样server/ ├── app.js ├── config/ │ └── db.js ├── middleware/ │ └── auth.js ├── controllers/ │ ├── authController.js │ ├── sessionController.js │ └── playerController.js ├── services/ │ ├── sessionService.js │ └── registrationService.js └── routes/ ├── auth.js ├── sessions.js └── players.js登录接口是第一个需要落地的接口逻辑不复杂查出用户比对 bcrypt 哈希密码通过后签发 JWT。密码绝不能明文存储bcryptjs 的 hash 方法会自动加盐不用自己拼随机字符串。JWT 有效期我设成了 7 天前端在响应拦截器里检测到 401 就跳转登录页并清掉本地缓存。报名接口是整个系统业务逻辑最密集的地方。我梳理了以下步骤async function registerSession(sessionId, userId, db) { const conn await db.getConnection(); try { await conn.beginTransaction(); // 1. 查出球员信息顺便确认账号状态 const [playerRows] await conn.execute( SELECT id FROM players WHERE user_id ? AND status 1, [userId] ); if (playerRows.length 0) { throw new Error(球员档案不存在或已禁用); } const playerId playerRows[0].id; // 2. 查出训练信息并锁定当前行 const [sessionRows] await conn.execute( SELECT id, start_time, max_participants FROM training_sessions WHERE id ? FOR UPDATE, [sessionId] ); const session sessionRows[0]; if (!session || session.start_time new Date()) { throw new Error(训练已开始或不存在); } // 3. 统计当前有效报名人数 const [countRows] await conn.execute( SELECT COUNT(*) AS cnt FROM training_registrations WHERE session_id ? AND status ! cancelled, [sessionId] ); if (countRows[0].cnt session.max_participants) { throw new Error(训练名额已满); } // 4. 插入报名记录 await conn.execute( INSERT INTO training_registrations (session_id, player_id, status, register_time) VALUES (?, ?, registered, NOW()), [sessionId, playerId] ); await conn.commit(); return { success: true }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }这段逻辑里最关键的是第 2 步的SELECT ... FOR UPDATE。在事务里锁住训练计划记录后两个球员同时提交报名请求时后一个请求必须等前一个事务提交统计人数时拿到的才是最新值。如果不用行锁两个并发请求可能同时读到剩余名额 1然后都执行插入导致实际人数超过 max_participants。这个坑在真实项目中很容易出现尤其在活动开放报名的前几秒。3.3 前端页面与报名功能实现前端我用 Vue Router 做了三个主页面训练列表页、训练详情页、球员管理页加上登录页和仪表盘页。路由配置里加了全局前置守卫每次跳转前检查本地是否存在 token没有就强制去登录页有就继续。管理端页面再根据 role 判断是否允许进入。训练列表页是球员最常用的页面核心要求是信息清晰、操作直白。每张训练卡片展示标题、时间、地点、带队教练和剩余名额。剩余名额的计算逻辑不放在前端而是由后端接口在返回列表时直接计算好。前端拿到数据后渲染“剩余名额”字段显示为已满时按钮置灰不可点击。报名状态的判断是前端最容易出错的点。我通过详情接口一次返回三个关键信息训练基本信息、当前用户对该训练的报名状态、报名名单数组。前端拿到之后const canRegister computed(() { return ( !myRegistration.value session.value.remaining 0 new Date(session.value.start_time) new Date() ); });这里要特别说明 computed 的依赖收集。按钮是否可用受三个条件控制任何一个变化都要重新计算Vue 的响应式系统会自动处理但前提是模板里已经绑定到对应的响应式变量。如果漏掉了对 myRegistration 的依赖就会出现“报名成功后按钮还是可点”的这种情况实际上就是因为 computed 缓存没有失效。报名成功后的交互也要考虑细节。我做了两步第一步调用报名接口成功后重新拉取详情接口刷新数据而不是本地手动把按钮置灰。理由很简单本地改的只是 UI 状态真实数据可能因为其他球员同时报名已经变了以服务端返回的数据为准更稳妥。第二步给出提示告诉用户报名成功如果训练前 24 小时不能到场可以在详情页自行取消。3.4 前后端联调与细节处理前后端联调是管理类项目里最耗时间的环节很多问题不是逻辑写错而是双方对接口的约定不一致。我在 axios 封装上做了一些固定处理减少这类问题的出现。首先设置统一的 baseURL开发环境通过 Vite 的 proxy 把/api前缀代理到后端的http://localhost:3000这样前端代码里不用写死域名生产环境也不用手动改。其次在请求拦截器里统一注入 Authorization 头service.interceptors.request.use((config) { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });响应拦截器统一处理两种情况业务错误码非 0 时弹出 message 提示HTTP 401 时清除 token 并跳转登录页。这种集中式处理让业务代码里不用到处写 try catch 和错误弹窗。跨域问题在开发环境通过 proxy 已经解决生产环境则由 Nginx 反向代理解决。如果后端直接部署在云服务器上不经过 Nginx那就得在 Express 里挂 cors 中间件并指定允许的来源白名单不要直接origin: *这样存在被无关站点调用接口的风险。联调阶段我还整理了一份接口自测清单每个接口至少覆盖成功、未登录、无权限、参数缺失四类情况确保前端拿到错误时能区分处理。4. 常见问题与排查技巧实录4.1 开发环境安装配置的三个常见坑开发环境的坑往往是卡住新手最久的地方。第一个就是 npm 脚本无法运行的 PowerShell 报错错误信息长这样npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本这个问题的原因很简单Windows PowerShell 默认执行策略限制运行脚本。解决办法是以管理员身份打开 PowerShell执行Set-ExecutionPolicy RemoteSigned确认即可。这里要注意 RemoteSigned 只对本地脚本放行已签名的远程脚本才允许运行比设为 Unrestricted 要安全得多。第二个是 Node.js 版本跟 Vue CLI 或者某些依赖不兼容。比如 Node 版本过旧时Vue CLI 可能直接报错无法启动版本过新又有可能遇到某些原生模块没跟上。我的建议是长期使用 LTS 版本并且装一个 nvm-windows 用来切换版本不同项目用不同版本的 Node这是开发多年积累下来的经验。第三个是端口占用。后端默认 3000 端口被占用时Express 会报 EADDRINUSE 错误。排查方法是用netstat -ano | findstr :3000查看占用进程再决定是杀掉进程还是换端口。但在开发环境更推荐把端口配置放到.env里遇到冲突直接改环境变量不用动代码。4.2 联调过程中的典型问题联调期间遇到最多的问题就是跨域。开发环境由 Vite proxy 解决但如果你跳过 proxy 直接从前端地址请求后端地址就会出现 CORS 错误。这时候先确认 Nginx 或 proxy 配置是否生效再确认后端 cors 中间件的白名单是否正确。排查口诀是先用 curl 直接请求后端接口能通就说明问题出在前端代理层。第二个问题是时间差。前端传的start_time是带时区的 ISO 字符串后端存进 MySQL 之后可能因为时区设置不对取出来发现比本地时间差 8 小时。这种问题很难靠肉眼察觉但会导致“训练 19:00 开始系统显示 11:00”。解决方案是连接数据库时在连接串里显式指定timezone: 08:00并且数据库表字段用 DATETIME 而不是 TIMESTAMP。TIMESTAMP 会受 MySQL 时区影响而 DATETIME 不带时区配合后端统一处理更可控。第三个问题是中文乱码。创建数据库时一定要指定 utf8mb4 字符集稍微老一些的项目还在用 utf8能存中文但存不了 emoji。比如球员备注里写了象形的表情符号直接报错而 utf8mb4 没这个问题。建库语句我习惯写完整CREATE DATABASE club_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;第四个问题是 Vue Router 的 history 模式打包后刷新 404。因为前端路由是浏览器端模拟的服务器上没有对应的物理文件。开发环境没问题生产环境必须在 Nginx 里配置try_files $uri $uri/ /index.html;否则用户在某个页面按 F5 就整页白屏。这个问题我第一次部署时就踩了排查了很久才发现是 Nginx 的 fallback 没有加。4.3 报名并发与数据一致性问题报名模块作为系统的核心并发问题必须认真对待。除了前面讲的SELECT ... FOR UPDATE行锁我还在数据库层做了两道兜底。第一道是唯一索引。即使业务代码有 bug并发插入同一球员同一训练的两条报名记录唯一索引也会让第二条插入失败。第二道是状态机的约束。报名记录一旦变成 cancelled不会允许重新变回 registered必须重新插入新记录。这样保证了取消操作的不可逆性历史记录才可信。实际还遇到过一种情况球员报名时名额还剩 1 个但同一时刻有两个球员提交事务开得很晚导致其中一个失败。这个在业务上是可以接受的因为确实只剩一个名额。关键是不能出现两个都成功且超员这才是系统真正要避免的问题。我在压测阶段用并发脚本同时发 20 个报名请求逐个检查列表人数最终确认超员问题被锁和唯一索引拦住了。这里要提醒的是如果用 ORM 的findOne先查再插不主动开事务加锁ORM 层面很难保证一致性必须深入到数据库事务那一层去设计。4.4 部署上线注意事项系统开发完成后我部署在一台轻量云服务器上。前端先执行npm run build打包产物是一个 dist 目录里面全是静态文件交给 Nginx 托管。后端代码部署到服务器的/var/www/club-server目录安装生产依赖后通过 pm2 启动。Node.js 进程管理强烈建议用 pm2。直接node app.js启动的话进程一旦因为未捕获异常退出服务就断了还不会有任何自动恢复机制。pm2 常用命令非常简洁pm2 start app.js --name club-server pm2 save pm2 logs club-server其中pm2 save会把当前进程列表保存下来配合pm2 startup生成系统服务服务器重启后 Node.js 服务也会自动拉起。这个细节直接决定了系统能不能长时间稳定运行。Nginx 配置里需要同时处理静态文件托管和接口反向代理server { listen 80; server_name your-domain.com; root /var/www/club-fe/dist; 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; } }生产环境数据库密码、JWT 密钥这些敏感信息我在服务器上通过系统环境变量注入代码仓库里只保留.env.example模板文件这样即使代码泄露也不会直接连累线上数据。这套系统从立项到上线大约用了一个月的业余时间主体功能全部跑通后俱乐部用了两个赛季报名和出勤统计基本不再出错。回头看真正值得沉淀的不是 Node.js 和 Vue 的技术栈本身而是先把业务规则梳理清楚再用合适的数据结构和事务去承接它们。如果让我重新做一遍我仍然会坚持先画表格、先定接口再动手写代码这个顺序。个人实际操作中的体会是像足球俱乐部管理这类内部系统需求方要的不是花哨的界面而是清晰的操作路径和可靠的数据。报名系统尤其要处理好“并发”和“状态这两个关键词宁可一开始多花时间在事务设计和唯一索引上也不要等到线上数据出错再补救。后续这个项目还可以继续扩展比如给报名名单加二维码签到、按月份导出训练报表、接入企业微信通知等核心链路已经打通扩展方向就看你自己的业务场景需要什么了。
返回列表