1. 项目概述:这到底是个什么系统
大学生社团活动签到系统,单看名字可能会觉得不过是个"签到的网页",但真正动手做的时候才会发现,它其实是一个典型的全栈实战项目,从前端交互、后端接口、数据库设计到部署上线,每一个环节都绕不开。这个项目以node.js作为后端运行环境,配合vue构建前端界面,实现的核心场景是:社团发布活动、学生查看活动并报名、到达现场后扫码或手动签到、管理员查看签到统计。技术栈听起来中规中矩,但恰好覆盖了校园信息化系统里最经典的一类需求。
我为什么说它适合拿来练手或者当毕业设计?第一,业务模型清晰,角色只有学生、社团管理员、系统管理员三类,权限边界容易设计;第二,功能闭环完整,从活动发布到签到统计是一条完整的业务链,不是那种"只有一个登录页"的敷衍项目;第三,技术栈主流,node.js + vue + mysql这套组合在中小型系统里非常常见,面试时也有话可说。如果你正准备做类似课题,或者想找一个能写进简历的真实项目,这个签到系统是个很不错的切入点。
接下来我会把这个项目从零到一拆开讲,包括技术选型思路、数据库表怎么设计、后端接口怎么划分、签到核心业务逻辑怎么实现、前端页面怎么组织,最后再把我在实际开发中踩过的坑和排查经验一并分享。内容偏实操,你看完可以直接跟着动手。
2. 技术选型与整体架构设计思路
2.1 为什么选 node.js + vue 而不是其他组合
很多同学在选型时会纠结:后端用Java SpringBoot还是node.js?前端用vue还是react?我给出的建议是,除非你的项目有明确要求必须用SpringBoot,否则node.js + vue这套组合在开发效率上有明显优势。
node.js最大的特点是JavaScript全栈,前后端语言统一,你不需要在"前端写JS,后端写Java"之间频繁切换思维。尤其是对于个人开发者来说,一个人要包揽前后端,语言统一能省下大量上下文切换的成本。另外node.js的生态非常成熟,express框架轻量灵活,几行代码就能搭起一个RESTful API服务;mysql2驱动、jsonwebtoken做登录鉴权、cors处理跨域,这些库都是久经考验的。
vue这边,我推荐使用vue 3 + vite的组合。vue 3的组合式API(Composition API)让逻辑复用变得非常舒服,比如签到页面的定位逻辑、活动列表的分页逻辑,都可以抽成自定义hook。vite的开发服务器启动速度比webpack快一个量级,热更新也是毫秒级的,这对开发体验的提升非常明显。
2.2 系统整体架构分层
这个项目虽然不大,但我仍然建议按标准的三层架构来组织,别贪图省事把代码全塞在一起。分层带来的好处在项目初期不明显,一旦开始加功能、改需求,你就知道好处了。
前端(Vue 3 + Vite + Pinia + Vue Router + Axios) ↓ HTTP请求 / JSON数据 后端(Node.js + Express) ├── 路由层(routes):接收请求,参数校验,调用控制器 ├── 控制器层(controllers):业务逻辑编排,调用服务层 ├── 服务层(services):核心业务逻辑,如签到逻辑、统计逻辑 └── 数据访问层(models):操作MySQL数据库 ↓ SQL语句 数据库(MySQL 5.7+ / 8.0)前端通过axios发送HTTP请求,后端以JSON格式返回数据。登录采用JWT(JSON Web Token)机制,前端把token存在localStorage里,每次请求在请求头带上Authorization字段,后端通过中间件统一鉴权。
2.3 为什么用JWT而不是Session
校园项目里并发量并不高,用Session其实也完全可行,但我更推荐JWT。原因有三:第一,JWT无状态,后端不需要维护会话记录,天然适合横向扩展;第二,前后端分离模式下,JWT可以轻松应对跨域问题,不需要额外配置Session共享;第三,JWT本身就携带了用户身份信息,后端解析后就能拿到用户ID和角色,省去了一次数据库查询。
JWT的缺点也是要心里有数的:token一旦签发,在过期之前无法主动失效。所以实际项目中,我会给token设置一个较短的过期时间(比如2小时),同时引入refresh token机制,或者简单一点,前端拦截401响应后跳转登录页重新登录。校园场景下,这个取舍完全够用。
3. 环境搭建与项目初始化
3.1 Node.js 版本选择与安装
node.js版本的选择是很多新手第一个踩坑的地方。社区里常说的"node.js 18.20.4 LTS"长期支持版本确实是稳妥之选。LTS版本意味着官方会持续提供安全补丁和维护,不会出现"用着用着某个依赖突然不兼容"的问题。
官网下载对应安装包,Windows下就是一路Next。macOS用户建议直接通过nvm管理node版本,方便以后切换。Linux服务器部署时,很多人习惯用yum或apt直接装,但我更推荐下载官方二进制压缩包解压后配置软链接,这样版本完全可控,不会受到系统软件源版本老旧的影响。
装完之后在终端验证一下:
node -v npm -v能正确输出版本号说明安装成功。这里有个小经验:npm在国内环境下安装依赖非常慢,建议直接配置淘宝镜像:
npm config set registry https://registry.npmmirror.com配置完可以用npm config get registry验证是否生效。
3.2 Vue 项目脚手架创建
前端部分我用Vite来创建项目,它比Vue CLI轻量很多,创建命令如下:
npm create vite@latest activity-checkin-frontend -- --template vue创建完成后进入目录,安装基础依赖:
cd activity-checkin-frontend npm install npm install vue-router@4 pinia axios element-plus这里额外装了几个东西:vue-router是前端路由,pinia是状态管理,axios是HTTP请求库,element-plus是饿了么团队的Vue 3组件库。用element-plus可以少写很多样式代码,后台管理页面、表单、表格、弹窗这些现成组件直接拿来用,开发速度能提升30%以上。
后端项目结构我习惯手动创建,不借助脚手架工具,这样更可控。一个典型的Express项目结构如下:
server/ ├── app.js # 应用入口 ├── config/ │ └── db.js # 数据库配置 ├── routes/ # 路由定义 │ ├── auth.js │ ├── activity.js │ └── checkin.js ├── controllers/ # 控制器 ├── services/ # 业务逻辑服务 ├── models/ # 数据模型 ├── middlewares/ # 中间件(鉴权、错误处理) └── utils/ # 工具函数3.3 数据库初始化准备
开发前先确保本机MySQL能正常连接,创建一个数据库:
CREATE DATABASE IF NOT EXISTS activity_checkin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;用utf8mb4而不是utf8,是因为utf8mb4才能完整支持emoji和生僻字。学生签到系统里,活动名称、学生姓名完全可能包含特殊字符,这里提前规避掉。
后端连接数据库,我用mysql2库,因为它支持Promise,配合async/await写起来很清爽:
npm install mysql2 express cors jsonwebtoken bcryptjs- express:Web框架
- cors:解决跨域请求
- jsonwebtoken:生成和校验JWT
- bcryptjs:密码加密,比明文存数据库安全得多
4. 数据库表设计:五张表撑起整个业务
4.1 用户表与角色设计
用户表是所有业务的基础。我用一张表存储所有用户,通过role字段区分角色,而不是为学生、管理员分别建表。原因是学生和社团管理员在基本信息上高度重合(姓名、学号、手机号、密码),分开建表会导致登录逻辑复杂化,而一张表加角色字段的设计在业务扩展时也更灵活。
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(255) NOT NULL, `real_name` VARCHAR(50) DEFAULT NULL, `student_no` VARCHAR(20) DEFAULT NULL, `phone` VARCHAR(20) DEFAULT NULL, `role` TINYINT NOT NULL DEFAULT 2 COMMENT '1=系统管理员, 2=社团管理员, 3=学生', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码字段注意设置为255长度,因为bcrypt加密后的字符串有60位。如果你只留50位,注册的时候就会报数据过长错误,这个问题我在项目里踩过一次。
4.2 社团表、活动表与签到表
社团表记录社团的基本信息,以及社团管理员的关联:
CREATE TABLE `club` ( `id` INT NOT NULL AUTO_INCREMENT, `club_name` VARCHAR(100) NOT NULL, `description` TEXT, `admin_id` INT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;活动表是最核心的业务表,字段设计直接影响签到逻辑的实现:
CREATE TABLE `activity` ( `id` INT NOT NULL AUTO_INCREMENT, `club_id` INT NOT NULL, `title` VARCHAR(200) NOT NULL, `description` TEXT, `location` VARCHAR(200) NOT NULL, `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `checkin_start` DATETIME NOT NULL, `checkin_end` DATETIME NOT NULL, `max_people` INT DEFAULT NULL, `status` TINYINT DEFAULT 0 COMMENT '0=待审核, 1=已发布, 2=已结束', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_club_id` (`club_id`), KEY `idx_start_time` (`start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意我把签到开始时间checkin_start和签到结束时间checkin_end独立出来了。这个设计很关键,因为签到时间窗口不一定跟活动开始时间完全一致。比如活动14:00开始,你可能希望提前30分钟就开始签到,结束后15分钟才关闭签到,独立字段才能灵活控制。
签到记录表负责存储每一次签到:
CREATE TABLE `sign_record` ( `id` INT NOT NULL AUTO_INCREMENT, `activity_id` INT NOT NULL, `user_id` INT NOT NULL, `sign_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `sign_type` TINYINT DEFAULT 1 COMMENT '1=扫码签到, 2=手动签到', `status` TINYINT DEFAULT 1 COMMENT '1=正常, 2=迟到, 3=缺勤', PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;唯一键uk_activity_user保证了一个学生对于同一场活动只能有一条签到记录,这是防止重复签到的最硬性手段,比在代码里判断要可靠得多。而且这个唯一键的报错还能作为代码里的一个逻辑分支,捕获到重复签到的情况。
4.3 报名功能与报名记录表
考虑到社团活动可能有人数限制,我还加了一个报名机制:学生先报名,活动当天再签到。报名表:
CREATE TABLE `signup_record` ( `id` INT NOT NULL AUTO_INCREMENT, `activity_id` INT NOT NULL, `user_id` INT NOT NULL, `signup_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;报名表和签到表分开的好处是,两者有清晰的语义边界——报名是意愿表达,签到是事实记录。统计时你可以分析"多少人报名、实际来了多少人",也可以分析"没报名但直接来了的人",这些维度的数据都是真实管理中会用到的。
5. 后端接口设计与核心业务逻辑实现
5.1 接口清单与状态码约定
在动手写代码之前,我建议先把接口文档列出来,避免写的过程中东一榔头西一棒子。这套系统的接口可以分成四组:
| 模块 | 接口 | 方法 | 功能 | 权限 |
|---|---|---|---|---|
| 认证 | /api/auth/register | POST | 注册 | 公开 |
| 认证 | /api/auth/login | POST | 登录 | 公开 |
| 认证 | /api/auth/profile | GET | 获取当前用户信息 | 登录用户 |
| 社团 | /api/club | POST | 创建社团 | 管理员 |
| 社团 | /api/club/mine | GET | 我管理的社团 | 社团管理员 |
| 活动 | /api/activity | POST | 发布活动 | 社团管理员 |
| 活动 | /api/activity/list | GET | 活动列表(分页) | 登录用户 |
| 活动 | /api/activity/:id | GET | 活动详情 | 登录用户 |
| 报名 | /api/activity/:id/signup | POST | 报名活动 | 学生 |
| 签到 | /api/activity/:id/checkin | POST | 签到 | 学生 |
| 统计 | /api/activity/:id/stats | GET | 签到统计 | 社团管理员 |
状态码约定:200表示成功,400表示参数错误,401表示未登录或token过期,403表示没有权限,404表示资源不存在,500表示服务器内部错误。前端axios拦截器里统一处理这些状态码,401就跳转登录页,500就弹出错误提示,不用每个请求单独处理。
5.2 JWT认证中间件的实现
登录成功后,后端签发一个token返回给前端。签发逻辑如下:
const jwt = require('jsonwebtoken'); const secretKey = process.env.JWT_SECRET || 'your-secret-key'; function generateToken(user) { return jwt.sign( { id: user.id, username: user.username, role: user.role }, secretKey, { expiresIn: '2h' } ); }对应的鉴权中间件:
function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ message: '未登录或token缺失' }); } const token = authHeader.split(' ')[1]; try { const decoded = jwt.verify(token, secretKey); req.user = decoded; next(); } catch (err) { return res.status(401).json({ message: 'token无效或已过期' }); } }需要管理员权限的接口,再加一层角色校验中间件:
function requireAdmin(req, res, next) { if (req.user.role !== 1 && req.user.role !== 2) { return res.status(403).json({ message: '无权限操作' }); } next(); }这样接口的权限控制就非常清晰了。比如发布活动接口,挂上authMiddleware和requireAdmin,学生就永远不可能调通这个接口,甚至在界面上你都看不到发布入口。
5.3 发布活动的业务逻辑
发布活动前,社团管理员必须选择自己管理的社团。为了避免出现"一个人管理多个社团时选错社团"的情况,后端要校验当前登录用户确实管理着目标社团:
async function createActivity(req, res) { const { clubId, title, description, location, startTime, endTime, checkinStart, checkinEnd, maxPeople } = req.body; const userId = req.user.id; // 校验必填字段 if (!clubId || !title || !location || !startTime || !endTime) { return res.status(400).json({ message: '必填字段不能为空' }); } // 时间逻辑校验 if (new Date(endTime) <= new Date(startTime)) { return res.status(400).json({ message: '结束时间必须晚于开始时间' }); } if (new Date(checkinEnd) <= new Date(checkinStart)) { return res.status(400).json({ message: '签到结束时间必须晚于签到开始时间' }); } // 校验社团归属 const club = await clubModel.findById(clubId); if (!club || club.admin_id !== userId) { return res.status(403).json({ message: '无权为该社团发布活动' }); } // 创建成功 const activityId = await activityModel.create({...}); res.status(201).json({ message: '活动创建成功', activityId }); }注意时间字段的处理:前端传过来的时间是字符串,比如"2025-06-01T14:00:00.000Z",插入MySQL时需要转换为MySQL能识别的格式。如果你直接用字符串去拼SQL,容易出现格式完全不匹配的问题。我习惯在service层做格式化:
const formattedTime = new Date(startTime).toISOString().slice(0, 19).replace('T', ' ');把ISO格式的时间转成"YYYY-MM-DD HH:mm:ss",这是MySQL DATETIME字段的标准格式。
5.4 签到核心逻辑:三种方案的取舍
签到是整个系统的灵魂功能,也是一开始最需要想清楚的部分。我调研过市面上常见的签到方案,主要有三种。
第一种是二维码扫码签到。活动开始时,管理员在后台生成一个二维码,二维码内容是一个固定字符串,比如包含活动ID和一个随机盐值。学生用手机扫码后跳转到签到页面,前端将活动ID和用户token一起发给后端,后端记录签到。实现上略复杂,但体验最好。
第二种是定位签到。学生签到的时候,前端通过浏览器Geolocation API获取当前经纬度,后端计算与活动地点的距离,小于设定阈值(比如500米)才允许签到。这个方案听起来很炫,但实际效果受限于GPS精度和室内定位效果,经常出现"明明在室内却签不上"的情况。
第三种是手动签到码。管理员在活动现场展示一个签到码,学生输入后完成签到。相当于把二维码变成了一段8位数字,实现最简单,兼容性也最好。
我最终的建议是采用"二维码+签到码兜底"的方案。主流程是学生扫码签到,如果扫码失败或者二维码识别不了,管理员可以在管理后台为用户手动补签。这个混合方案既保证了体验的流畅性,又保留了容错手段。
核心签到代码:
async function checkin(req, res) { const activityId = req.params.id; const userId = req.user.id; const activity = await activityModel.findById(activityId); if (!activity) { return res.status(404).json({ message: '活动不存在' }); } // 检查活动状态 const now = new Date(); if (now < new Date(activity.checkin_start) || now > new Date(activity.checkin_end)) { return res.status(400).json({ message: '不在签到时间范围内' }); } // 检查是否报名 const signup = await signupModel.find(activityId, userId); if (!signup) { return res.status(403).json({ message: '未报名该活动,无法签到' }); } try { const status = now <= new Date(activity.start_time) ? 1 : 2; // 正常或迟到 await signRecordModel.create(activityId, userId, status); res.json({ message: '签到成功', status }); } catch (err) { if (err.code === 'ER_DUP_ENTRY') { return res.status(400).json({ message: '请勿重复签到' }); } throw err; } }这里有三个判断逻辑缺一不可:活动是否存在、签到时间窗口是否开放、是否已经报名。判断顺序也有讲究,先查活动是否存在可以省掉后续无意义的查询。最后捕获唯一键冲突来拦截重复签到,这是最优雅的做法。
6. 前端页面设计与核心业务实现
6.1 路由设计与页面结构
前端路由我设计了这样几条:
| 路径 | 页面 | 角色 |
|---|---|---|
| /login | 登录页 | 公开 |
| /register | 注册页 | 公开 |
| / | 首页(活动列表) | 登录用户 |
| /activity/:id | 活动详情 | 登录用户 |
| /admin | 管理后台首页 | 管理员 |
| /admin/activity/create | 发布活动 | 社团管理员 |
| /admin/club | 社团管理 | 社团管理员 |
| /admin/stats/:id | 签到统计 | 社团管理员 |
在router配置里加全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); } else { next(); } });这里用了redirect参数,让用户登录后还能跳回原目标页面,这个细节虽然小,但很提升使用体验。
6.2 登录注册页:表单验证要点
登录页用element-plus的Form组件,表单验证规则需要同时处理前端格式校验和后端返回的错误。
前端校验示例:
const rules = { username: [ { required: true, message: '请输入用户名', trigger: 'blur' }, { min: 3, max: 20, message: '长度在3到20个字符之间', trigger: 'blur' } ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 20, message: '长度在6到20个字符之间', trigger: 'blur' } ] };后端注册时用bcryptjs加密密码:
const bcrypt = require('bcryptjs'); const saltRounds = 10; async function register(req, res) { const { username, password, realName, studentNo, phone } = req.body; if (!username || !password) { return res.status(400).json({ message: '用户名和密码不能为空' }); } const hashedPassword = await bcrypt.hash(password, saltRounds); try { const userId = await userModel.create({ username, password: hashedPassword, realName, studentNo, phone, role: 3 }); res.status(201).json({ message: '注册成功', userId }); } catch (err) { if (err.code === 'ER_DUP_ENTRY') { return res.status(400).json({ message: '用户名已存在' }); } throw err; } }密码加密的saltRounds设为10是一个合理取值。太低(比如4)安全性不足,太高(比如15)会导致注册登录接口明显变慢,10是安全性和性能的平衡点。
6.3 axios封装和统一拦截
写axios封装时,直接把baseURL、token注入、响应拦截、错误提示都统一处理掉,避免每个组件里都重复写一遍:
import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '@/router'; 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 => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push({ path: '/login' }); ElMessage.error('登录已过期,请重新登录'); } else { const message = error.response?.data?.message || '网络请求异常'; ElMessage.error(message); } return Promise.reject(error); } );注意baseURL设置为'/api',然后在Vite的devServer里配置代理,这样开发环境下前端访问'/api/xxx'会被转发到后端服务器的3000端口:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } });这里有一个小坑,如果前端项目跑在5173端口,后端跑在3000端口,不配置代理的话,浏览器会直接向5173端口发请求,然后404。代理的作用就是让浏览器以为请求是同源的,绕开跨域限制。生产部署时,可以用nginx来做同样的反向代理,配置逻辑完全一致。
6.4 活动列表页:分页与搜索
活动列表页是学生看到的第一屏,页面质量直接影响第一印象。我用element-plus的Card组件循环渲染卡片,每张卡片展示活动标题、社团名、活动时间、地点和报名状态。
数据加载用computed配合分页参数:
const queryParams = reactive({ page: 1, pageSize: 9, keyword: '' }); const activityList = ref([]); const total = ref(0); async function fetchActivities() { const res = await request.get('/activity/list', { params: queryParams }); activityList.value = res.list; total.value = res.total; } watch( () => queryParams.page, fetchActivities );分页参数pageSize设为9是因为我的卡片布局是3列,3的倍数一页显示最整齐。分页组件用el-pagination,绑定current-page和total,触发change事件时更新page。
6.5 签到页面的实现细节
签到交互有两种:扫码签到和签到码签到。扫码签到用二维码库qrcode在前端生成二维码,学生通过手机摄像头扫码时其实是用微信或浏览器自带的扫码功能,识别到二维码里的URL就自动跳到签到页面。
签到页面的核心逻辑:
async function handleCheckin() { const loading = ElLoading.service({ text: '正在签到,请稍候...' }); try { const res = await request.post(`/activity/${activityId}/checkin`); ElMessage.success(res.message === '签到成功' ? '签到成功' : `签到成功(${res.status === 2 ? '迟到' : '正常'})`); checkinStatus.value = 'done'; } catch (err) { // 错误已由axios拦截器统一处理 } finally { loading.close(); } }这里有一个体验细节:签到按钮在点击后需要立即置灰,防止用户重复点击导致重复请求。虽然后端有唯一键兜底,但前端能拦截的重复交互就别让请求浪费到后端。
7. 签到统计与数据可视化
7.1 统计接口的数据组装
社团管理员最关心的数据是:活动报名多少人、实际签到多少人、迟到多少人、缺勤多少人。这个统计接口需要联表查询:
async function getActivityStats(req, res) { const activityId = req.params.id; const activity = await activityModel.findById(activityId); if (!activity) { return res.status(404).json({ message: '活动不存在' }); } const totalSignup = await signupModel.countByActivity(activityId); const totalSign = await signRecordModel.countByActivity(activityId); const normalCount = await signRecordModel.countByActivityAndStatus(activityId, 1); const lateCount = await signRecordModel.countByActivityAndStatus(activityId, 2); res.json({ totalSignup, totalSign, normalCount, lateCount, absentCount: totalSignup - totalSign }); }7.2 前端用ECharts展示结果
数据可视化我用ECharts,饼图展示签到/迟到/缺勤的占比,柱状图展示最近几场活动的参与率趋势。ECharts在vue3中建议使用vue-echarts封装组件,按需引入模块,避免打包体积过大。
饼图的option示例:
const pieOption = { title: { text: '签到情况分布', left: 'center' }, tooltip: { trigger: 'item' }, series: [{ type: 'pie', radius: '60%', data: [ { value: stats.normalCount, name: '正常签到' }, { value: stats.lateCount, name: '迟到' }, { value: stats.absentCount, name: '缺勤' } ] }] };图表组件挂在页面上要记得处理容器高度问题,ECharts的容器必须有一个明确的高度,否则图表渲染不出来。我一般设置容器为400px固定高度,或者用aspect-ratio控制。
8. 常见问题与Debug实录
8.1 CORS跨域报错
现象:前端调用后端接口时,浏览器控制台报Access-Control-Allow-Origin错误。
原因:前端运行在localhost:5173,后端运行在localhost:3000,属于跨域请求。后端没有返回CORS响应头。
解决:后端用cors中间件,几行代码解决:
const cors = require('cors'); app.use(cors());如果同时需要支持带cookie的请求,则要配置origin和credentials:
app.use(cors({ origin: 'http://localhost:5173', credentials: true }));前端axios也要设置withCredentials: true。但我在这个项目里用的是token方案,不依赖cookie,所以不需要credentials配置。
8.2 扫码后跳转的二维码内容
问题:生成二维码时,里面到底放什么内容?如果只放活动ID,任何人都能伪造请求直接签到,安全性堪忧。
解决:二维码内容不再是纯活动ID,而是包含一个随机的签到token。管理员在发布活动时,后端生成一个随机字符串存储在活动表里(新增字段checkin_code),二维码内容指向的URL带有这个token:
http://localhost:5173/checkin/15?code=8f3a1c9e2b7d4a6f后端校验时不仅验证活动ID,还要验证code是否匹配。即便有人拿到了签到接口的URL,没有正确的code也无法直接调用。
8.3 时区问题导致签到时间判断错误
现象:后端判断"不在签到时间范围内",但前台明明显示已经开始了。
原因:前端传入的时间字符串是ISO格式带时区信息,比如2025-06-01T14:00:00.000Z,表示的是UTC时间。而JavaScript的new Date()获取的是本地时间。如果用户在UTC+8时区,一个下午2点的活动在ISO字符串里记录的是早上6点的UTC时间,后端比较时如果不做时区转换,就会认为还没到签到时间。
解决:前端提交数据时,统一转换为时间戳:
const startTimestamp = new Date(startTime).getTime();后端收到时间戳后统一用new Date(Number(startTimestamp))转换,再和new Date()比较。或者更简单,前端直接使用dayjs库格式化,确保提交的数据是"YYYY-MM-DD HH:mm:ss"这样的纯本地时间字符串,后端不要手动转换,直接用字符串比较。这里的关键是前后端要约定好一种时间格式,不要混着用。
8.4 数据库连接时间过长挂掉
现象:项目跑了一段时间后,接口突然报错ETIMEDOUT或PROTOCOL_CONNECTION_LOST。
原因:连接池中的空闲连接被MySQL服务端超时关闭,但连接池还在继续尝试复用这些失效连接。
解决:在数据库连接配置中增加连接池保活参数:
const pool = mysql2.createPool({ host: 'localhost', user: 'root', password: 'yourpassword', database: 'activity_checkin', waitForConnections: true, connectionLimit: 10, queueLimit: 0, enableKeepAlive: true, keepAliveInitialDelay: 0 });enableKeepAlive: true会让连接池定期发送心跳包,防止连接被服务端回收。这个参数对于长期运行的后端服务是必备的。
8.5 前端无法获取用户定位
现象:定位签到方案中,浏览器提示获取定位失败。
原因:Geolocation API在非HTTPS环境下会被浏览器屏蔽。如果你在本地开发用的是http://localhost,浏览器通常会放行localhost,但一旦部署到线上服务器,没有配置HTTPS证书,就无法获取定位。
解决:生产环境配置nginx时通过Let's Encrypt签发免费证书启用HTTPS;或者放弃定位方案,改用二维码/签到码。我在实际项目中建议默认用签到码方案,定位作为扩展功能,等HTTPS配置好了再启用。
8.6 token过期后页面的处理
现象:学生打开活动详情页停留很久,或者后台管理系统长时间挂着没操作,点击某个按钮时突然跳回登录页。
原因:JWT过期时间为2小时,过期后接口返回401,axios拦截器处理401会清除token并跳转登录页。这个逻辑本身没错,但体验上确实突兀。
解决:在登录页增加提示"登录已过期,请重新登录"。更优雅的方案是引入refresh_token机制,token快过期时自动刷新。但考虑到校园项目复杂度,我建议直接用401跳转 + 提示信息就够了。不过有一点要注意,axios拦截器里跳转登录页时应该带上当前页面路径,登录成功后能自动跳回来,不要让人重新找页面。
9. 部署上线与后续扩展
9.1 服务器部署的极简方案
本地开发完成后,需要部署到服务器上才能让其他人访问。我推荐一个极简但完整的部署方案:
后端用PM2管理进程,确保服务崩溃后能自动重启:
npm install -g pm2 pm2 start app.js --name activity-server pm2 save && pm2 startup前端构建后输出到静态目录:
npm run build构建产物在dist/目录下,把它上传到服务器,然后用nginx配置反向代理:
server { listen 80; server_name your-domain.com; root /var/www/activity-frontend/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; } }注意try_files $uri $uri/ /index.html这一行对于vue-router的history模式至关重要。没有它,你刷新一个子路由页面(比如/admin/stats/15)就会报404,因为nginx在磁盘上找不到对应的物理文件。加上它之后,所有不存在的路径都会回退到index.html,由前端路由来接管。
9.2 功能扩展方向
这个系统做完基础功能后,还有几个值得扩展的方向。
一是消息通知。可以引入WebSocket,当管理员发布新活动时,推送给所有已关注该社团的学生。虽然TCP长连接在小项目里可能显得"大材小用",但WebSocket在教学项目中加分很明显。
二是Excel导出。签到统计结果一键导出为Excel,方便社团管理员上报数据。可以用node.js端的exceljs库,或者在前端用xlsx库生成文件,这类需求在校园场景里出现频率很高。
三是活动反馈评价。签到的同时顺手收集参与者的满意度反馈,为社团后续办活动提供参考数据。这个功能相当于给系统增加了一个"评价"模块,跟签到记录表关联,逻辑也不复杂。
10. 我踩过的一些坑和最终建议
项目做完,回头看有几点体会特别深。
第一,不要一上来就写代码。花一晚上把表结构设计好、接口文档列出来,看起来是"浪费时间",实际上能帮你省掉至少三天的返工时间。数据库表一旦建好,后期改字段带来的连锁修改非常痛苦。
第二,签到方案的选择要在项目开始前确定。不要写到一半才想起来"要不要加定位签到",这会牵动数据库、前端页面、后端逻辑一起改。先用最简单的方案跑通全流程,再迭代增强,这是稳扎稳打的节奏。
第三,前后端联调时,最好统一用一套API文档工具。你写个简单的md文件也可以,但一定要写清楚每个字段的类型和含义。联调阶段80%的沟通成本都来自于字段名理解不一致。
第四,测试数据一定要有代表性。建几条"边界数据",比如活动时间跨天、签到时间窗口与活动时间完全错开、同一个学生报名了两场时间重叠的活动,这些场景能帮你提前发现逻辑漏洞。
最后再说一句,这个项目的价值不在于用了多高端的技术,而在于它把一条真实的业务链路完整走通了。如果你能在这个基础上把代码结构、异常处理、注释规范都做好,再在面试时把它讲清楚——从需求到表设计到核心逻辑,就已经比大部分只有"curd经验"的候选人更有竞争力了。做项目最怕的不是慢,是做到一半推倒重来;最珍贵的也不是代码量,而是你对每个设计决策背后理由的清晰认知。希望这篇拆解能让你少走一些弯路。